Skip to content

Commit 1908ccc

Browse files
authored
float multiplicity (#350)
* allow float multiplicity * changelog accumulations * fix black * Update Lint.yml * Update CI.yaml
1 parent cddc531 commit 1908ccc

File tree

7 files changed

+114
-24
lines changed

7 files changed

+114
-24
lines changed

.github/workflows/CI.yaml

+2-1
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,8 @@ jobs:
1717
matrix:
1818
python-version: ["3.7", "3.9", "3.11", "3.12"]
1919
pydantic-version: ["1", "2"]
20-
runs-on: [ubuntu-latest, windows-latest]
20+
# runs-on: [ubuntu-latest, windows-latest]
21+
runs-on: [ubuntu-22.04, windows-latest] # until drop py37
2122
exclude:
2223
- runs-on: windows-latest
2324
pydantic-version: "1"

.github/workflows/Lint.yml

+2-2
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ jobs:
1414
- name: Set up Python
1515
uses: actions/setup-python@v4
1616
with:
17-
python-version: "3.7"
17+
python-version: "3.8"
1818
- name: Install black
1919
run: pip install "black>=22.1.0,<23.0a0"
2020
- name: Print code formatting with black (hints here if next step errors)
@@ -29,7 +29,7 @@ jobs:
2929
- name: Set up Python
3030
uses: actions/setup-python@v4
3131
with:
32-
python-version: "3.7"
32+
python-version: "3.8"
3333
- name: Install poetry
3434
run: pip install poetry
3535
- name: Install repo

docs/changelog.rst

+22-4
Original file line numberDiff line numberDiff line change
@@ -19,32 +19,50 @@ Changelog
1919
.. Misc.
2020
.. +++++
2121
22-
- (:pr:`340`, :issue:`330`) Add molecular charge and multiplicity to Molecule repr formula,
23-
so neutral singlet unchanged but radical cation has '2^formula+'.
24-
2522
2623
0.29.0 / 2024-MM-DD (Unreleased)
2724
--------------------------------
2825

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

3230
New Features
3331
++++++++++++
32+
- (:pr:`350`, :pr:`318`, :issue:`317`) Make behavior consistent between molecular_charge/
33+
fragment_charges and molecular_multiplicity/fragment_multiplicities by allowing floating point
34+
numbers for multiplicities. @awvwgk
3435
- (:pr:`360`) ``Molecule`` learned new functions ``element_composition`` and ``molecular_weight``.
3536
The first gives a dictionary of element symbols and counts, while the second gives the weight in amu.
3637
Both can access the whole molecule or per-fragment like the existing ``nelectrons`` and
3738
``nuclear_repulsion_energy``. All four can now select all atoms or exclude ghosts (default).
3839

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

4259
Bug Fixes
4360
+++++++++
4461

4562
Misc.
4663
+++++
47-
- (:pr:`342`) Update some docs settings and requirements for newer tools.
64+
- (:pr:`344`, :issue:`282`) Add a citation file since QCElemental doesn't have a paper. @lilyminium
65+
- (:pr:`342`, :issue:`333`) Update some docs settings and requirements for newer tools.
4866
- (:pr:`353`) copied in pkg_resources.safe_version code as follow-up to Eric switch to packaging as both nwchem and gamess were now working.
4967
the try_harder_safe_version might be even bettter
5068

qcelemental/models/molecule.py

+8-6
Original file line numberDiff line numberDiff line change
@@ -185,7 +185,7 @@ class Molecule(ProtoModel):
185185
description="Additional comments for this molecule. Intended for pure human/user consumption and clarity.",
186186
)
187187
molecular_charge: float = Field(0.0, description="The net electrostatic charge of the molecule.") # type: ignore
188-
molecular_multiplicity: int = Field(1, description="The total multiplicity of the molecule.") # type: ignore
188+
molecular_multiplicity: float = Field(1, description="The total multiplicity of the molecule.") # type: ignore
189189

190190
# Atom data
191191
masses_: Optional[Array[float]] = Field( # type: ignore
@@ -257,7 +257,7 @@ class Molecule(ProtoModel):
257257
"if not provided (and :attr:`~qcelemental.models.Molecule.fragments` are specified).",
258258
shape=["nfr"],
259259
)
260-
fragment_multiplicities_: Optional[List[int]] = Field( # type: ignore
260+
fragment_multiplicities_: Optional[List[float]] = Field( # type: ignore
261261
None,
262262
description="The multiplicity of each fragment in the :attr:`~qcelemental.models.Molecule.fragments` list. The index of this "
263263
"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):
421421
n = len(values["fragments_"])
422422
if len(v) != n:
423423
raise ValueError("Fragment Multiplicities must be same number of entries as Fragments")
424+
v = [(int(m) if m.is_integer() else m) for m in v]
424425
if any([m < 1.0 for m in v]):
425426
raise ValueError(f"Fragment Multiplicity must be positive: {v}")
426427
return v
427428

428429
@validator("molecular_multiplicity")
429430
def _int_if_possible(cls, v, values, **kwargs):
431+
if v.is_integer():
432+
# preserve existing hashes
433+
v = int(v)
430434
if v < 1.0:
431435
raise ValueError("Molecular Multiplicity must be positive")
432436
return v
@@ -502,7 +506,7 @@ def fragment_charges(self) -> List[float]:
502506
return fragment_charges
503507

504508
@property
505-
def fragment_multiplicities(self) -> List[int]:
509+
def fragment_multiplicities(self) -> List[float]:
506510
fragment_multiplicities = self.__dict__.get("fragment_multiplicities_")
507511
if fragment_multiplicities is None:
508512
fragment_multiplicities = [self.molecular_multiplicity]
@@ -803,9 +807,7 @@ def get_hash(self):
803807
data = getattr(self, field)
804808
if field == "geometry":
805809
data = float_prep(data, GEOMETRY_NOISE)
806-
elif field == "fragment_charges":
807-
data = float_prep(data, CHARGE_NOISE)
808-
elif field == "molecular_charge":
810+
elif field in ["fragment_charges", "molecular_charge", "fragment_multiplicities", "molecular_multiplicity"]:
809811
data = float_prep(data, CHARGE_NOISE)
810812
elif field == "masses":
811813
data = float_prep(data, MASS_NOISE)

qcelemental/molparse/chgmult.py

+21-3
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ def _high_spin_sum(mult_list):
1919

2020

2121
def _mult_ok(m):
22-
return isinstance(m, (int, np.integer)) and m >= 1
22+
return isinstance(m, (int, np.integer, float, np.float64)) and m >= 1
2323

2424

2525
def _sufficient_electrons_for_mult(z, c, m):
@@ -430,7 +430,16 @@ def int_if_possible(val):
430430
if molecular_multiplicity is None: # unneeded, but shortens the exact lists
431431
frag_mult_hi = _high_spin_sum(_apply_default(fragment_multiplicities, 2))
432432
frag_mult_lo = _high_spin_sum(_apply_default(fragment_multiplicities, 1))
433-
for m in range(frag_mult_lo, frag_mult_hi + 1):
433+
try:
434+
mult_range = range(frag_mult_lo, frag_mult_hi + 1)
435+
except TypeError:
436+
if frag_mult_lo == frag_mult_hi:
437+
mult_range = [frag_mult_hi]
438+
else:
439+
raise ValidationError(
440+
f"Cannot process: please fully specify float multiplicity: m: {molecular_multiplicity} fm: {fragment_multiplicities}"
441+
)
442+
for m in mult_range:
434443
cgmp_exact_m.append(m)
435444

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

451460
for ifr in range(nfr):
452461
if fragment_multiplicities[ifr] is None: # unneeded, but shortens the exact lists
453-
for m in reversed(range(max(missing_mult_lo, 1), missing_mult_hi + 1)):
462+
try:
463+
mult_range = reversed(range(max(missing_mult_lo, 1), missing_mult_hi + 1))
464+
except TypeError:
465+
if missing_mult_lo == missing_mult_hi:
466+
mult_range = [missing_mult_hi]
467+
else:
468+
raise ValidationError(
469+
f"Cannot process: please fully specify float multiplicity: m: {molecular_multiplicity} fm: {fragment_multiplicities}"
470+
)
471+
for m in mult_range:
454472
cgmp_exact_fm[ifr].append(m)
455473
cgmp_exact_fm[ifr].append(1)
456474
cgmp_exact_fm[ifr].append(2)

qcelemental/tests/test_molecule.py

+49-8
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
Tests the imports and exports of the Molecule object.
33
"""
44

5+
56
import numpy as np
67
import pytest
78

@@ -798,6 +799,15 @@ def test_extras():
798799
"triplet": "7caca87a",
799800
"disinglet": "83a85546",
800801
"ditriplet": "71d6ba82",
802+
# float mult
803+
"singlet_point1": "4e9e2587",
804+
"singlet_epsilon": "ad3f5fab",
805+
"triplet_point1": "ad35cc28",
806+
"triplet_point1_minus": "b63d6983",
807+
"triplet_point00001": "7107b7ac",
808+
"disinglet_epsilon": "fb0aaaca",
809+
"ditriplet_point1": "33d47d5f",
810+
"ditriplet_point00001": "7f0ac640",
801811
}
802812

803813

@@ -806,14 +816,26 @@ def test_extras():
806816
[
807817
pytest.param(3, 3, False, "triplet"),
808818
pytest.param(3, 3, True, "triplet"),
809-
# 3.1 -> 3 (validate=False) below documents the present bad behavior where a float mult
810-
# simply gets cast to int with no error. This will change soon. The validate=True throws a
811-
# nonspecific error that at least mentions type.
812-
pytest.param(3.1, 3, False, "triplet"),
819+
# before float multiplicity was allowed, 3.1 (below) was coerced into 3 with validate=False,
820+
# and validate=True threw a type-mentioning error. Now, 2.9 is allowed for both validate=T/F
821+
pytest.param(3.1, 3.1, False, "triplet_point1"),
822+
# validate=True counterpart fails b/c insufficient electrons in He for more than triplet
823+
pytest.param(2.9, 2.9, False, "triplet_point1_minus"),
824+
pytest.param(2.9, 2.9, True, "triplet_point1_minus"),
825+
pytest.param(3.00001, 3.00001, False, "triplet_point00001"),
826+
# validate=True counterpart fails like 3.1 above
827+
pytest.param(2.99999, 2.99999, False, "triplet_point00001"), # hash agrees w/3.00001 above b/c <CHARGE_NOISE
828+
pytest.param(2.99999, 2.99999, True, "triplet_point00001"),
813829
pytest.param(3.0, 3, False, "triplet"),
814830
pytest.param(3.0, 3, True, "triplet"),
815831
pytest.param(1, 1, False, "singlet"),
816832
pytest.param(1, 1, True, "singlet"),
833+
pytest.param(1.000000000000000000002, 1, False, "singlet"),
834+
pytest.param(1.000000000000000000002, 1, True, "singlet"),
835+
pytest.param(1.000000000000002, 1.000000000000002, False, "singlet_epsilon"),
836+
pytest.param(1.000000000000002, 1.000000000000002, True, "singlet_epsilon"),
837+
pytest.param(1.1, 1.1, False, "singlet_point1"),
838+
pytest.param(1.1, 1.1, True, "singlet_point1"),
817839
pytest.param(None, 1, False, "singlet"),
818840
pytest.param(None, 1, True, "singlet"),
819841
# fmt: off
@@ -841,6 +863,9 @@ def test_mol_multiplicity_types(mult_in, mult_store, validate, exp_hash):
841863
[
842864
pytest.param(-3, False, "Multiplicity must be positive"),
843865
pytest.param(-3, True, "Multiplicity must be positive"),
866+
pytest.param(0.9, False, "Multiplicity must be positive"),
867+
pytest.param(0.9, True, "Multiplicity must be positive"),
868+
pytest.param(3.1, True, "Inconsistent or unspecified chg/mult"), # insufficient electrons in He
844869
],
845870
)
846871
def test_mol_multiplicity_types_errors(mult_in, validate, error):
@@ -859,10 +884,11 @@ def test_mol_multiplicity_types_errors(mult_in, validate, error):
859884
[
860885
pytest.param(5, [3, 3], [3, 3], False, "ditriplet"),
861886
pytest.param(5, [3, 3], [3, 3], True, "ditriplet"),
862-
# 3.1 -> 3 (validate=False) below documents the present bad behavior where a float mult
863-
# simply gets cast to int with no error. This will change soon. The validate=True throws a
864-
# irreconcilable error.
865-
pytest.param(5, [3.1, 3.4], [3, 3], False, "ditriplet"),
887+
# before float multiplicity was allowed, [3.1, 3.4] (below) were coerced into [3, 3] with validate=False.
888+
# Now, [2.9, 2.9] is allowed for both validate=T/F.
889+
pytest.param(5, [3.1, 3.4], [3.1, 3.4], False, "ditriplet_point1"),
890+
pytest.param(5, [2.99999, 3.00001], [2.99999, 3.00001], False, "ditriplet_point00001"),
891+
pytest.param(5, [2.99999, 3.00001], [2.99999, 3.00001], True, "ditriplet_point00001"),
866892
# fmt: off
867893
pytest.param(5, [3.0, 3.], [3, 3], False, "ditriplet"),
868894
pytest.param(5, [3.0, 3.], [3, 3], True, "ditriplet"),
@@ -871,6 +897,18 @@ def test_mol_multiplicity_types_errors(mult_in, validate, error):
871897
pytest.param(1, [1, 1], [1, 1], True, "disinglet"),
872898
# None in frag_mult not allowed for validate=False
873899
pytest.param(1, [None, None], [1, 1], True, "disinglet"),
900+
pytest.param(1, [1.000000000000000000002, 0.999999999999999999998], [1, 1], False, "disinglet"),
901+
pytest.param(1, [1.000000000000000000002, 0.999999999999999999998], [1, 1], True, "disinglet"),
902+
pytest.param(
903+
1,
904+
[1.000000000000002, 1.000000000000004],
905+
[1.000000000000002, 1.000000000000004],
906+
False,
907+
"disinglet_epsilon",
908+
),
909+
pytest.param(
910+
1, [1.000000000000002, 1.000000000000004], [1.000000000000002, 1.000000000000004], True, "disinglet_epsilon"
911+
),
874912
],
875913
)
876914
def test_frag_multiplicity_types(mol_mult_in, mult_in, mult_store, validate, exp_hash):
@@ -902,6 +940,9 @@ def test_frag_multiplicity_types(mol_mult_in, mult_in, mult_store, validate, exp
902940
[
903941
pytest.param([-3, 1], False, "Multiplicity must be positive"),
904942
pytest.param([-3, 1], True, "Multiplicity must be positive"),
943+
pytest.param(
944+
[3.1, 3.4], True, "Inconsistent or unspecified chg/mult"
945+
), # insufficient e- for triplet+ on He in frag 1
905946
],
906947
)
907948
def test_frag_multiplicity_types_errors(mult_in, validate, error):

qcelemental/tests/test_molparse_validate_and_fill_chgmult.py

+10
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,12 @@
9494
(-2.4, [-2.4, 0, 0], 3, [1, 2, 2]),
9595
"a83a3356",
9696
), # 166
97+
(("He", None, [None], 2.8, [None]), (0, [0], 2.8, [2.8]), "3e10e7b5"), # 180
98+
(("He", None, [None], None, [2.8]), (0, [0], 2.8, [2.8]), "3e10e7b5"), # 181
99+
(("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
100+
(("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
101+
(("N/N/N", None, [None, None, None], None, [2, 2, 2.2]), (0, [0, 0, 0], 4.2, [2, 2, 2.2]), "ed6d1f35"), # 186
102+
(("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
97103
]
98104

99105

@@ -153,6 +159,8 @@ def none_y(inp):
153159
("Gh", None, [None], 3, [None]), # 60
154160
("Gh/He", None, [2, None], None, [None, None]), # 62
155161
("Gh/Ne", 2, [-2, None], None, [None, None]), # 65b
162+
("He", None, [None], 3.2, [None]), # 182
163+
("N/N/N", None, [None, None, None], 2.2, [None, None, 2.2]), # 184
156164
],
157165
)
158166
def test_validate_and_fill_chgmult_irreconcilable(systemtranslator, inp):
@@ -173,6 +181,8 @@ def test_validate_and_fill_chgmult_irreconcilable(systemtranslator, inp):
173181
# 35 - insufficient electrons
174182
# 55 - both (1, (1, 0.0, 0.0), 4, (1, 3, 2)) and (1, (0.0, 0.0, 1), 4, (2, 3, 1)) plausible
175183
# 65 - non-0/1 on Gh fragment errors normally but reset by zero_ghost_fragments
184+
# 182 - insufficient electrons on He
185+
# 184 - decline to guess fragment multiplicities when floats involved
176186

177187

178188
@pytest.fixture

0 commit comments

Comments
 (0)