diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index d6f1f0ff7..36aef3719 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -1,6 +1,12 @@ name: CI -on: [push, pull_request] +on: + push: + branches: + - master + pull_request: + branches: + - master jobs: build: @@ -11,10 +17,13 @@ jobs: cfg: - conda-env: minimal python-version: 3.6 + label: mindep - conda-env: base python-version: 3.6 + label: minpy - conda-env: base python-version: 3.8 + label: full env: PYVER: ${{ matrix.cfg.python-version }} CONDA_ENV: ${{ matrix.cfg.conda-env }} @@ -54,5 +63,12 @@ jobs: eval "$(conda shell.bash hook)" && conda activate test pytest -rws -v --cov=qcelemental --color=yes --cov-report=xml qcelemental/ + - name: PyTest Validate + shell: bash + if: matrix.cfg.label == 'full' + run: | + eval "$(conda shell.bash hook)" && conda activate test + pytest -rws -v --color=yes --validate qcelemental/ + - name: CodeCov uses: codecov/codecov-action@v1 diff --git a/.github/workflows/QCSchema.yml b/.github/workflows/QCSchema.yml new file mode 100644 index 000000000..3ffd2ebfc --- /dev/null +++ b/.github/workflows/QCSchema.yml @@ -0,0 +1,90 @@ +name: QCSchema + +on: [pull_request] + +jobs: + build: + runs-on: ubuntu-latest + strategy: + matrix: + conda-env: [base] + python-version: [3.7] + env: + PYVER: ${{ matrix.python-version }} + CONDA_ENV: ${{ matrix.conda-env }} + + steps: + - name: Checkout + uses: actions/checkout@v2 + with: + path: qcel + + - name: Checkout schema repo + uses: actions/checkout@v2 + with: + repository: MolSSI/QCSchema + path: qcsk + ref: qcsk_export_2 + #ref: master + persist-credentials: true + fetch-depth: 0 + token: ${{ secrets.qcschema_from_qcelemental }} + + - name: Python Setup + uses: actions/setup-python@v1 + with: + python-version: ${{ matrix.python-version }} + + - name: Create Environment + shell: bash + working-directory: ./qcel + run: | + eval "$(conda shell.bash hook)" && conda activate + python devtools/scripts/create_conda_env.py -n=test -p=$PYVER devtools/conda-envs/$CONDA_ENV.yaml + + - name: Install + shell: bash + working-directory: ./qcel + run: | + eval "$(conda shell.bash hook)" && conda activate test + python -m pip install . --no-deps + + - name: Environment Information + shell: bash + run: | + eval "$(conda shell.bash hook)" && conda activate test + conda list --show-channel-urls + + - name: QCSchema from QCElemental + shell: bash + working-directory: ./qcel + run: | + eval "$(conda shell.bash hook)" && conda activate test + make qcschema + ls -l qcschema + cp -p qcschema/* ../qcsk/qcschema/data/vdev/ + mv ../qcsk/qcschema/data/vdev/QCSchema.schema ../qcsk/qcschema/dev/ + + - name: Compare Schemas (generated vs. community) + shell: bash + working-directory: ./qcsk + run: | + git diff --color-words + pull_number=$(jq --raw-output .pull_request.number "$GITHUB_EVENT_PATH") + branch=qcel-${pull_number} + git checkout -b ${branch} + git remote -v + git config --local user.email "action@github.com" + git config --local user.name "GitHub Action" + git add -A + git commit -m "auto-generated from QCElemental" + echo "::set-env name=prbranch::${branch}" + + - name: Propose changes + uses: ad-m/github-push-action@master + with: + directory: ./qcsk + repository: MolSSI/QCSchema + branch: ${{ env.prbranch }} + github_token: ${{ secrets.qcschema_from_qcelemental }} + force: true diff --git a/.gitignore b/.gitignore index 666c74672..45f83e57e 100644 --- a/.gitignore +++ b/.gitignore @@ -119,3 +119,7 @@ runinfo/* # VSCode .vscode/ raw_data/**/*_blob.py + +# autogen +qcschema/*.schema +qcelemental/tests/qcschema_instances/*/*.json diff --git a/Makefile b/Makefile index 2bcb96c1b..386d0c205 100644 --- a/Makefile +++ b/Makefile @@ -43,6 +43,12 @@ data: cpu_data cpu_data: (cd raw_data/cpu_data; python build_cpu_data.py; mv cpu_data_blob.py ../../qcelemental/info/data/) +.PHONY: qcschema +qcschema: + mkdir -p qcschema + python -c "exec(\"import pathlib, qcelemental\nfor md in qcelemental.models.qcschema_models():\n\tmfile = (pathlib.Path('qcschema') / md.__name__).with_suffix('.schema')\n\twith open(mfile, 'w') as fp:\n\t\tfp.write(md.schema_json(indent=None))\")" + python -c "exec(\"import json, pathlib, pydantic, qcelemental\nwith open((pathlib.Path('qcschema') / 'QCSchema').with_suffix('.schema'), 'w') as fp:\n\tjson.dump(pydantic.schema.schema(qcelemental.models.qcschema_models(), title='QCSchema'), fp, indent=4)\")" + .PHONY: clean clean: rm -rf `find . -name __pycache__` diff --git a/devtools/conda-envs/base.yaml b/devtools/conda-envs/base.yaml index 0350c9f3f..ec43b74a1 100644 --- a/devtools/conda-envs/base.yaml +++ b/devtools/conda-envs/base.yaml @@ -20,3 +20,4 @@ dependencies: - pytest-cov - codecov - scipy # tests an aspect of a helper fn not used by qcel functionality + - jsonschema diff --git a/devtools/conda-envs/minimal.yaml b/devtools/conda-envs/minimal.yaml index bf609d50d..46591ed48 100644 --- a/devtools/conda-envs/minimal.yaml +++ b/devtools/conda-envs/minimal.yaml @@ -8,9 +8,10 @@ dependencies: - nomkl - python - pint=0.10.0 # technically, qcel has no lower bound for pint version for py36,37 but needs 0.10 for 38 - - pydantic=1.2.0 # technically, qcel works with 1.0.0 but c-f doesn't have py38 builds for it + - pydantic=1.5.0 # Testing - pytest=4.6.4 # technically, qcel works with 4.0.0 but c-f doesn't have py38 builds for it - pytest-cov - codecov + - jsonschema diff --git a/qcelemental/conftest.py b/qcelemental/conftest.py new file mode 100644 index 000000000..67fb4a5b6 --- /dev/null +++ b/qcelemental/conftest.py @@ -0,0 +1,29 @@ +from pathlib import Path + +import pytest + + +def pytest_addoption(parser): + parser.addoption( + "--validate", action="store_true", help="validate JSON from previous test run against exported schema" + ) + + +@pytest.fixture(scope="session", autouse=True) +def set_up_overall(request): + # in all pytest runs except --validate (which uses the files), clear away the JSON examples and generate fresh + if not request.config.getoption("--validate", default=False): + _data_path = Path(__file__).parent.resolve() / "tests" / "qcschema_instances" + for fl in _data_path.rglob("*.json"): + fl.unlink() + + +def pytest_runtest_setup(item): + # there's a bug where can only set options if specify path in call, so needs to be ``pytest qcelemental/ --validate`` + + # skip the validate-generated-instances-against-exported-schema tests on most ``pytest`` runs. + # run only the validate-generated-instances-against-exported-schema tests on ``pytest --validate`` runs. + if not item.config.getoption("--validate", default=False) and item.name.startswith("test_qcschema"): + pytest.skip("can't run with --validate option") + elif item.config.getoption("--validate", default=False) and not item.name.startswith("test_qcschema"): + pytest.skip("need --validate option to run") diff --git a/qcelemental/info/cpu_info.py b/qcelemental/info/cpu_info.py index d2719db75..ce1c4a00c 100644 --- a/qcelemental/info/cpu_info.py +++ b/qcelemental/info/cpu_info.py @@ -14,8 +14,7 @@ class VendorEnum(str, Enum): - """Allowed processor vendors, used for validation. - """ + """Allowed processor vendors, used for validation.""" amd = "amd" intel = "intel" @@ -24,8 +23,7 @@ class VendorEnum(str, Enum): class InstructionSetEnum(int, Enum): - """Allowed instruction sets for CPUs in an ordinal enum. - """ + """Allowed instruction sets for CPUs in an ordinal enum.""" none = 0 sse = 1 diff --git a/qcelemental/models/__init__.py b/qcelemental/models/__init__.py index f2c6102ce..4a69f39a6 100644 --- a/qcelemental/models/__init__.py +++ b/qcelemental/models/__init__.py @@ -12,5 +12,18 @@ from .basis import BasisSet from .common_models import ComputeError, DriverEnum, FailedOperation, Provenance from .molecule import Molecule -from .procedures import Optimization, OptimizationInput, OptimizationResult -from .results import AtomicInput, AtomicResult, AtomicResultProperties, Result, ResultInput, ResultProperties +from .procedures import OptimizationInput, OptimizationResult +from .procedures import Optimization # scheduled for removal +from .results import AtomicInput, AtomicResult, AtomicResultProperties +from .results import Result, ResultInput, ResultProperties # scheduled for removal + + +def qcschema_models(): + return [ + AtomicInput, + AtomicResult, + AtomicResultProperties, + BasisSet, + Molecule, + Provenance, + ] diff --git a/qcelemental/models/align.py b/qcelemental/models/align.py index 6e21faeb3..e41d1a2f6 100644 --- a/qcelemental/models/align.py +++ b/qcelemental/models/align.py @@ -21,15 +21,9 @@ class AlignmentMill(ProtoModel): """ - shift: Optional[Array[float]] = Field( # type: ignore - None, description="Translation array (3,) for coordinates." - ) - rotation: Optional[Array[float]] = Field( # type: ignore - None, description="Rotation array (3, 3) for coordinates." - ) - atommap: Optional[Array[int]] = Field( # type: ignore - None, description="Atom exchange map (nat,) for coordinates." - ) + shift: Optional[Array[float]] = Field(None, description="Translation array (3,) for coordinates.") # type: ignore + rotation: Optional[Array[float]] = Field(None, description="Rotation array (3, 3) for coordinates.") # type: ignore + atommap: Optional[Array[int]] = Field(None, description="Atom exchange map (nat,) for coordinates.") # type: ignore mirror: bool = Field(False, description="Do mirror invert coordinates?") class Config: diff --git a/qcelemental/models/basemodels.py b/qcelemental/models/basemodels.py index eb99a8451..1cd4328d6 100644 --- a/qcelemental/models/basemodels.py +++ b/qcelemental/models/basemodels.py @@ -125,6 +125,8 @@ def serialize( include: Optional[Set[str]] = None, exclude: Optional[Set[str]] = None, exclude_unset: Optional[bool] = None, + exclude_defaults: Optional[bool] = None, + exclude_none: Optional[bool] = None, ) -> Union[bytes, str]: """Generates a serialized representation of the model @@ -138,6 +140,10 @@ def serialize( Fields to be excluded in the serialization. exclude_unset : Optional[bool], optional If True, skips fields that have default values provided. + exclude_defaults: Optional[bool], optional + If True, skips fields that have set or defaulted values equal to the default. + exclude_none: Optional[bool], optional + If True, skips fields that have value ``None``. Returns ------- @@ -152,6 +158,10 @@ def serialize( kwargs["exclude"] = exclude if exclude_unset: kwargs["exclude_unset"] = exclude_unset + if exclude_defaults: + kwargs["exclude_defaults"] = exclude_defaults + if exclude_none: + kwargs["exclude_none"] = exclude_none data = self.dict(**kwargs) @@ -182,3 +192,6 @@ def compare(self, other: Union["ProtoModel", BaseModel], **kwargs) -> bool: class AutodocBaseSettings(BaseSettings): def __init_subclass__(cls) -> None: cls.__doc__ = AutoPydanticDocGenerator(cls, always_apply=True) + + +qcschema_draft = "http://json-schema.org/draft-04/schema#" diff --git a/qcelemental/models/basis.py b/qcelemental/models/basis.py index 952bd9406..2c31ca7c1 100644 --- a/qcelemental/models/basis.py +++ b/qcelemental/models/basis.py @@ -1,34 +1,46 @@ from enum import Enum from typing import Dict, List, Optional -from pydantic import Field, constr, validator +from pydantic import ConstrainedInt, Field, constr, validator from ..exceptions import ValidationError -from .basemodels import ProtoModel +from .basemodels import ProtoModel, qcschema_draft + + +class NonnegativeInt(ConstrainedInt): + ge = 0 class HarmonicType(str, Enum): - """ - The angular momentum representation of a shell. - """ + """The angular momentum representation of a shell.""" spherical = "spherical" cartesian = "cartesian" class ElectronShell(ProtoModel): - """ - Information for a single electronic shell - """ + """Information for a single electronic shell.""" - angular_momentum: List[int] = Field(..., description="Angular momentum for this shell.") + angular_momentum: List[NonnegativeInt] = Field( + ..., description="Angular momentum for the shell as an array of integers.", min_items=1 + ) + # function_type: FunctionType = Field(..., description=str(FunctionType.__doc__)) + # region: Region = Field(None, description=str(Region.__doc__)) harmonic_type: HarmonicType = Field(..., description=str(HarmonicType.__doc__)) - exponents: List[float] = Field(..., description="Exponents for this contracted shell.") + exponents: List[float] = Field(..., description="Exponents for the contracted shell.", min_items=1) coefficients: List[List[float]] = Field( ..., - description="General contraction coefficients for this shell, individual list components will be the individual segment contraction coefficients.", + description="General contraction coefficients for the shell; individual list components will be the individual segment contraction coefficients.", + min_items=1, ) + class Config(ProtoModel.Config): + def schema_extra(schema, model): + # edit to allow string storage of basis sets as BSE uses. alternately, could `Union[float, str]` above but that loses some validation + schema["properties"]["exponents"]["items"] = {"anyOf": [{"type": "number"}, {"type": "string"}]} + schema["properties"]["coefficients"]["items"]["items"] = {"anyOf": [{"type": "number"}, {"type": "string"}]} + schema["properties"]["angular_momentum"].update({"uniqueItems": True}) + @validator("coefficients") def _check_coefficient_length(cls, v, values): len_exp = len(values["exponents"]) @@ -75,28 +87,34 @@ def is_contracted(self) -> bool: class ECPType(str, Enum): - """ - The type of the ECP potential. - """ + """The type of the ECP potential.""" scalar = "scalar" spinorbit = "spinorbit" class ECPPotential(ProtoModel): - """ - Information for a single ECP potential. - """ + """Information for a single ECP potential.""" ecp_type: ECPType = Field(..., description=str(ECPType.__doc__)) - angular_momentum: List[int] = Field(..., description="Angular momentum for the ECPs.") - r_exponents: List[int] = Field(..., description="Exponents of the 'r' term.") - gaussian_exponents: List[float] = Field(..., description="Exponents of the 'gaussian' term.") + angular_momentum: List[NonnegativeInt] = Field( + ..., description="Angular momentum for the potential as an array of integers.", min_items=1 + ) + r_exponents: List[int] = Field(..., description="Exponents of the 'r' term.", min_items=1) + gaussian_exponents: List[float] = Field(..., description="Exponents of the 'gaussian' term.", min_items=1) coefficients: List[List[float]] = Field( ..., - description="General contraction coefficients for this shell, individual list components will be the individual segment contraction coefficients.", + description="General contraction coefficients for the potential; individual list components will be the individual segment contraction coefficients.", + min_items=1, ) + class Config(ProtoModel.Config): + def schema_extra(schema, model): + # edit to allow string storage of basis sets as BSE uses. alternately, could `Union[float, str]` above but that loses some validation + schema["properties"]["gaussian_exponents"]["items"] = {"anyOf": [{"type": "number"}, {"type": "string"}]} + schema["properties"]["coefficients"]["items"]["items"] = {"anyOf": [{"type": "number"}, {"type": "string"}]} + schema["properties"]["angular_momentum"].update({"uniqueItems": True}) + @validator("gaussian_exponents") def _check_gaussian_exponents_length(cls, v, values): len_exp = len(values["r_exponents"]) @@ -116,13 +134,18 @@ def _check_coefficient_length(cls, v, values): class BasisCenter(ProtoModel): - """ - Data for a single atom/center in a basis set. - """ + """Data for a single atom/center in a basis set.""" - electron_shells: List[ElectronShell] = Field(..., description="Electronic shells for this center.") - ecp_electrons: int = Field(0, description="Number of electrons replace by ECP potentials.") - ecp_potentials: Optional[List[ECPPotential]] = Field(None, description="ECPs for this center.") + electron_shells: List[ElectronShell] = Field(..., description="Electronic shells for this center.", min_items=1) + ecp_electrons: int = Field(0, description="Number of electrons replaced by ECP, MCP, or other field potentials.") + ecp_potentials: Optional[List[ECPPotential]] = Field( + None, description="ECPs, MCPs, or other field potentials for this center.", min_items=1 + ) + + class Config(ProtoModel.Config): + def schema_extra(schema, model): + schema["properties"]["electron_shells"].update({"uniqueItems": True}) + schema["properties"]["ecp_potentials"].update({"uniqueItems": True}) class BasisSet(ProtoModel): @@ -130,17 +153,28 @@ class BasisSet(ProtoModel): A quantum chemistry basis description. """ - schema_name: constr(strip_whitespace=True, regex="qcschema_basis") = "qcschema_basis" - schema_version: int = 1 + schema_name: constr(strip_whitespace=True, regex="^(qcschema_basis)$") = Field( # type: ignore + "qcschema_basis", + description=(f"The QCSchema specification to which this model conforms. Explicitly fixed as qcschema_basis."), + ) + schema_version: int = Field( # type: ignore + 1, description="The version number of ``schema_name`` to which this model conforms." + ) - name: str = Field(..., description="A standard basis name if available (e.g., 'cc-pVDZ').") - description: Optional[str] = Field(None, description="A brief description of the basis set.") - center_data: Dict[str, BasisCenter] = Field(..., description="A mapping of all types of centers available.") + name: str = Field(..., description="The standard basis name if available (e.g., 'cc-pVDZ').") + description: Optional[str] = Field(None, description="Brief description of the basis set.") + center_data: Dict[str, BasisCenter] = Field( + ..., description="Shared basis data for all atoms/centers in the parent molecule" + ) atom_map: List[str] = Field( - ..., description="Mapping of all centers in the parent molecule to centers in `center_data`." + ..., description="Mapping of all atoms/centers in the parent molecule to centers in ``center_data``." ) - nbf: Optional[int] = Field(None, description="The number of basis functions.") + nbf: Optional[int] = Field(None, description="The number of basis functions. Use for convenience or as checksum") + + class Config(ProtoModel.Config): + def schema_extra(schema, model): + schema["$schema"] = qcschema_draft @validator("atom_map") def _check_atom_map(cls, v, values): diff --git a/qcelemental/models/common_models.py b/qcelemental/models/common_models.py index 8c29df68f..a89100264 100644 --- a/qcelemental/models/common_models.py +++ b/qcelemental/models/common_models.py @@ -1,10 +1,11 @@ from enum import Enum -from typing import TYPE_CHECKING, Any, Dict, Optional +from typing import TYPE_CHECKING, Any, Dict, Optional, Union import numpy as np from pydantic import Field -from .basemodels import ProtoModel +from .basemodels import ProtoModel, qcschema_draft +from .basis import BasisSet if TYPE_CHECKING: from pydantic.typing import ReprArgs @@ -15,43 +16,50 @@ class Provenance(ProtoModel): - """ - Provenance information. - """ + """Provenance information.""" - creator: str = Field(..., description="The creator of the object.") - version: Optional[str] = Field(None, description="The version of the creator.") - routine: Optional[str] = Field(None, description="The routine of the creator.") + # compared to qcsk v2: + # this has defaults for version & routine, and those aren't required. + # this doesn't explicitly set `additionalProperties = True` + + creator: str = Field(..., description="The name of the program, library, or person who created the object.") + version: str = Field( + "", + description="The version of the creator, blank otherwise. This should be sortable by the very broad [PEP 440](https://www.python.org/dev/peps/pep-0440/).", + ) + routine: str = Field("", description="The name of the routine or function within the creator, blank otherwise.") class Config(ProtoModel.Config): canonical_repr = True - extra = "allow" + extra: str = "allow" + + def schema_extra(schema, model): + schema["$schema"] = qcschema_draft class Model(ProtoModel): - """ - The quantum chemistry model specification for a given operation to compute against - """ + """The computational molecular sciences model to run.""" method: str = Field( # type: ignore - ..., description="The quantum chemistry method to evaluate (e.g., B3LYP, PBE, ...)." + ..., + description="The quantum chemistry method to evaluate (e.g., B3LYP, PBE, ...). " + "For MM, name of the force field.", ) - basis: Optional[str] = Field( # type: ignore + basis: Optional[Union[str, BasisSet]] = Field( # type: ignore None, description="The quantum chemistry basis set to evaluate (e.g., 6-31g, cc-pVDZ, ...). Can be ``None`` for " - "methods without basis sets.", + "methods without basis sets. For molecular mechanics, name of the atom-typer.", ) # basis_spec: BasisSpec = None # This should be exclusive with basis, but for now will be omitted class Config(ProtoModel.Config): canonical_repr = True - extra = "allow" + extra: str = "allow" class DriverEnum(str, Enum): - """Allowed quantum chemistry driver values. - """ + """Allowed computation driver values.""" energy = "energy" gradient = "gradient" @@ -67,19 +75,20 @@ def derivative_int(self): class ComputeError(ProtoModel): - """A complete description of the error.""" + """Complete description of the error from an unsuccessful program execution.""" error_type: str = Field( # type: ignore ..., # Error enumeration not yet strict - description="The type of error which was thrown. Restrict this field short classifiers e.g. 'input_error'.", + description="The type of error which was thrown. Restrict this field to short classifiers e.g. 'input_error'. Suggested classifiers: https://github.com/MolSSI/QCEngine/blob/master/qcengine/exceptions.py", ) error_message: str = Field( # type: ignore ..., - description="Text associated with the thrown error, often the backtrace, but can contain additional " + description="Text associated with the thrown error. This is often the backtrace, but it can contain additional " "information as well.", ) extras: Optional[Dict[str, Any]] = Field( # type: ignore - None, description="Additional data to ship with the ComputeError object." + None, + description="Additional information to bundle with the error.", ) class Config: @@ -90,11 +99,7 @@ def __repr_args__(self) -> "ReprArgs": class FailedOperation(ProtoModel): - """ - A record indicating that a given operation (compute, procedure, etc.) has failed and contains the reason and - input data which generated the failure. - - """ + """Record indicating that a given operation (program, procedure, etc.) has failed and containing the reason and input data which generated the failure.""" id: str = Field( # type: ignore None, @@ -120,7 +125,7 @@ class FailedOperation(ProtoModel): ) extras: Optional[Dict[str, Any]] = Field( # type: ignore None, - description="Additional information to bundle with this Failed Operation. Details which pertain specifically " + description="Additional information to bundle with the failed operation. Details which pertain specifically " "to a thrown error should be contained in the `error` field. See :class:`ComputeError` for details.", ) diff --git a/qcelemental/models/molecule.py b/qcelemental/models/molecule.py index a7ba80ba7..59a1db5da 100644 --- a/qcelemental/models/molecule.py +++ b/qcelemental/models/molecule.py @@ -5,25 +5,30 @@ import hashlib import json import warnings +from functools import partial from pathlib import Path -from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple, Union +from typing import TYPE_CHECKING, Any, Dict, Iterable, List, Optional, Tuple, Union, cast import numpy as np -from pydantic import Field, constr, validator - -from ..molparse import from_arrays, from_schema, from_string, to_schema, to_string +from pydantic import ConstrainedFloat, ConstrainedInt, Field, constr, validator + +# molparse imports separated b/c https://github.com/python/mypy/issues/7203 +from ..molparse.from_arrays import from_arrays +from ..molparse.from_schema import from_schema +from ..molparse.from_string import from_string +from ..molparse.to_schema import to_schema +from ..molparse.to_string import to_string from ..periodic_table import periodictable from ..physical_constants import constants from ..testing import compare, compare_values from ..util import deserialize, measure_coordinates, msgpackext_loads, provenance_stamp, which_import -from .basemodels import ProtoModel +from .basemodels import ProtoModel, qcschema_draft from .common_models import Provenance, qcschema_molecule_default from .types import Array if TYPE_CHECKING: from pydantic.typing import ReprArgs - # Rounding quantities for hashing GEOMETRY_NOISE = 8 MASS_NOISE = 6 @@ -59,6 +64,15 @@ def float_prep(array, around): return array +class NonnegativeInt(ConstrainedInt): + ge = 0 + + +class BondOrderFloat(ConstrainedFloat): + ge = 0 + le = 5 + + class Identifiers(ProtoModel): """Canonical chemical identifiers""" @@ -82,20 +96,30 @@ class Config(ProtoModel.Config): class Molecule(ProtoModel): """ + The physical Cartesian representation of the molecular system. + A QCSchema representation of a Molecule. This model contains data for symbols, geometry, connectivity, charges, fragmentation, etc while also supporting a wide array of I/O and manipulation capabilities. Molecule objects geometry, masses, and charges are truncated to 8, 6, and 4 decimal places respectively to assist with duplicate detection. + + Notes + ----- + All arrays are stored flat but must be reshapable into the dimensions in attribute ``shape``, with abbreviations as follows: + nat: number of atomic = calcinfo_natom + nfr: number of fragments + : irregular dimension not systematically reshapable + """ - schema_name: constr(strip_whitespace=True, regex=qcschema_molecule_default) = Field( # type: ignore + schema_name: constr(strip_whitespace=True, regex="^(qcschema_molecule)$") = Field( # type: ignore qcschema_molecule_default, description=( - f"The QCSchema specification this model conforms to. Explicitly fixed as " f"{qcschema_molecule_default}." + f"The QCSchema specification to which this model conforms. Explicitly fixed as {qcschema_molecule_default}." ), ) schema_version: int = Field( # type: ignore - 2, description="The version number of ``schema_name`` that this Molecule model conforms to." + 2, description="The version number of ``schema_name`` to which this model conforms." ) validated: bool = Field( # type: ignore False, @@ -109,124 +133,145 @@ class Molecule(ProtoModel): # Required data symbols: Array[str] = Field( # type: ignore ..., - description="An ordered (nat,) array-like object of atomic elemental symbols of shape (nat,). The index of " - "this attribute sets atomic order for all other per-atom setting like ``real`` and the first " - "dimension of ``geometry``. Ghost/Virtual atoms must have an entry in this array-like and are " - "indicated by the matching the 0-indexed indices in ``real`` field.", + description="The ordered array of atomic elemental symbols in title case. This field's index " + "sets atomic order for all other per-atom fields like ``real`` and the first dimension of " + "``geometry``. Ghost/virtual atoms must have an entry here in ``symbols``; ghostedness is " + "indicated through the ``real`` field.", + shape=["nat"], ) geometry: Array[float] = Field( # type: ignore ..., - description="An ordered (nat,3) array-like for XYZ atomic coordinates [a0]. " + description="The ordered array for Cartesian XYZ atomic coordinates [a0]. " "Atom ordering is fixed; that is, a consumer who shuffles atoms must not reattach the input " "(pre-shuffling) molecule schema instance to any output (post-shuffling) per-atom results " "(e.g., gradient). Index of the first dimension matches the 0-indexed indices of all other " "per-atom settings like ``symbols`` and ``real``." "\n" - "Can also accept array-likes which can be mapped to (nat,3) such as a 1-D list of length 3*nat, " + "Serialized storage is always flat, (3*nat,), but QCSchema implementations will want to reshape it. " + "QCElemental can also accept array-likes which can be mapped to (nat,3) such as a 1-D list of length 3*nat, " "or the serialized version of the array in (3*nat,) shape; all forms will be reshaped to " "(nat,3) for this attribute.", + shape=["nat", 3], + units="a0", ) # Molecule data name: Optional[str] = Field( # type: ignore - None, description="A common or human-readable name to assign to this molecule. Can be arbitrary." + None, + description="Common or human-readable name to assign to this molecule. This field can be arbitrary; see ``identifiers`` for well-defined labels.", ) identifiers: Optional[Identifiers] = Field( # type: ignore None, - description="An optional dictionary of additional identifiers by which this Molecule can be referenced, " + description="An optional dictionary of additional identifiers by which this molecule can be referenced, " "such as INCHI, canonical SMILES, etc. See the :class:``Identifiers`` model for more details.", ) comment: Optional[str] = Field( # type: ignore None, - description="Additional comments for this Molecule. Intended for pure human/user consumption " "and clarity.", + 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 this Molecule.") # type: ignore - molecular_multiplicity: int = Field(1, description="The total multiplicity of this Molecule.") # type: ignore + 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 # Atom data masses_: Optional[Array[float]] = Field( # type: ignore None, - description="An ordered 1-D array-like object of atomic masses [u] of shape (nat,). Index order " - "matches the 0-indexed indices of all other per-atom settings like ``symbols`` and ``real``. If " - "this is not provided, the mass of each atom is inferred from their most common isotope. If this " + description="The ordered array of atomic masses. Index order " + "matches the 0-indexed indices of all other per-atom fields like ``symbols`` and ``real``. If " + "this is not provided, the mass of each atom is inferred from its most common isotope. If this " "is provided, it must be the same length as ``symbols`` but can accept ``None`` entries for " "standard masses to infer from the same index in the ``symbols`` field.", + shape=["nat"], + units="u", ) real_: Optional[Array[bool]] = Field( # type: ignore None, - description="An ordered 1-D array-like object of shape (nat,) indicating if each atom is real (``True``) or " + description="The ordered array indicating if each atom is real (``True``) or " "ghost/virtual (``False``). Index " "matches the 0-indexed indices of all other per-atom settings like ``symbols`` and the first " "dimension of ``geometry``. If this is not provided, all atoms are assumed to be real (``True``)." - "If this is provided, the reality or ghostality of every atom must be specified.", + "If this is provided, the reality or ghostedness of every atom must be specified.", + shape=["nat"], ) atom_labels_: Optional[Array[str]] = Field( # type: ignore None, - description="Additional per-atom labels as a 1-D array-like of of strings of shape (nat,). Typical use is in " + description="Additional per-atom labels as an array of strings. Typical use is in " "model conversions, such as Elemental <-> Molpro and not typically something which should be user " - "assigned. See the ``comments`` field for general human-consumable text to affix to the Molecule.", + "assigned. See the ``comments`` field for general human-consumable text to affix to the molecule.", + shape=["nat"], ) atomic_numbers_: Optional[Array[np.int16]] = Field( # type: ignore None, description="An optional ordered 1-D array-like object of atomic numbers of shape (nat,). Index " "matches the 0-indexed indices of all other per-atom settings like ``symbols`` and ``real``. " - "Values are inferred from the ``symbols`` list if not explicitly set.", + "Values are inferred from the ``symbols`` list if not explicitly set. " + "Ghostedness should be indicated through ``real`` field, not zeros here.", + shape=["nat"], ) mass_numbers_: Optional[Array[np.int16]] = Field( # type: ignore None, description="An optional ordered 1-D array-like object of atomic *mass* numbers of shape (nat). Index " "matches the 0-indexed indices of all other per-atom settings like ``symbols`` and ``real``. " - "Values are inferred from the most common isotopes of the ``symbols`` list if not explicitly set.", + "Values are inferred from the most common isotopes of the ``symbols`` list if not explicitly set. " + "If single isotope not (yet) known for an atom, -1 is placeholder.", + shape=["nat"], ) # Fragment and connection data - connectivity_: Optional[List[Tuple[int, int, float]]] = Field( # type: ignore + connectivity_: Optional[List[Tuple[NonnegativeInt, NonnegativeInt, BondOrderFloat]]] = Field( # type: ignore None, - description="The connectivity information between each atom in the ``symbols`` array. Each entry in this " - "list is a Tuple of ``(atom_index_A, atom_index_B, bond_order)`` where the ``atom_index`` " - "matches the 0-indexed indices of all other per-atom settings like ``symbols`` and ``real``.", + description="A list of bonds within the molecule. Each entry is a tuple " + "of ``(atom_index_A, atom_index_B, bond_order)`` where the ``atom_index`` " + "matches the 0-indexed indices of all other per-atom settings like ``symbols`` and ``real``. " + "Bonds may be freely reordered and inverted.", + min_items=1, ) fragments_: Optional[List[Array[np.int32]]] = Field( # type: ignore None, - description="An indication of which sets of atoms are fragments within the Molecule. This is a list of shape " - "(nfr) of 1-D array-like objects of arbitrary length. Each entry in the list indicates a new " - "fragment. The index " - "of the list matches the 0-indexed indices of ``fragment_charges`` and " - "``fragment_multiplicities``. The 1-D array-like objects are sets of atom indices indicating the " - "atoms which compose the fragment. The atom indices match the 0-indexed indices of all other " - "per-atom settings like ``symbols`` and ``real``.", + description="List of indices grouping atoms (0-indexed) into molecular fragments within the molecule. " + "Each entry in the outer list is a new fragment; index matches the ordering in ``fragment_charges`` and " + "``fragment_multiplicities``. Inner lists are 0-indexed atoms which compose the fragment; every atom must " + "be in exactly one inner list. Noncontiguous fragments are allowed, though no QM program is known to support them. " + "Fragment ordering is fixed; that is, a consumer who shuffles fragments must not reattach the input " + "(pre-shuffling) molecule schema instance to any output (post-shuffling) per-fragment results (e.g., n-body energy arrays).", + shape=["nfr", ""], ) fragment_charges_: Optional[List[float]] = Field( # type: ignore None, - description="The total charge of each fragment in the ``fragments`` list of shape (nfr,). The index of this " - "list matches the 0-index indices of ``fragment`` list. Will be filled in based on a set of rules " + description="The total charge of each fragment in the ``fragments`` list. The index of this " + "list matches the 0-index indices of ``fragments`` list. Will be filled in based on a set of rules " "if not provided (and ``fragments`` are specified).", + shape=["nfr"], ) fragment_multiplicities_: Optional[List[int]] = Field( # type: ignore None, - description="The multiplicity of each fragment in the ``fragments`` list of shape (nfr,). The index of this " - "list matches the 0-index indices of ``fragment`` list. Will be filled in based on a set of " + description="The multiplicity of each fragment in the ``fragments`` list. The index of this " + "list matches the 0-index indices of ``fragments`` list. Will be filled in based on a set of " "rules if not provided (and ``fragments`` are specified).", + shape=["nfr"], ) # Orientation fix_com: bool = Field( # type: ignore False, - description="An indicator which prevents pre-processing the Molecule object to translate the Center-of-Mass " - "to (0,0,0) in euclidean coordinate space. Will result in a different ``geometry`` than the " - "one provided if False.", + description="Whether translation of geometry is allowed (fix F) or disallowed (fix T)." + "When False, QCElemental will pre-process the Molecule object to translate the center of mass " + "to (0,0,0) in Euclidean coordinate space, resulting in a different ``geometry`` than the " + "one provided. " + "guidance: A consumer who translates the geometry must not reattach the input (pre-translation) molecule schema instance to any output (post-translation) origin-sensitive results (e.g., an ordinary energy when EFP present).", ) fix_orientation: bool = Field( # type: ignore False, - description="An indicator which prevents pre-processes the Molecule object to orient via the inertia tensor." - "Will result in a different ``geometry`` than the one provided if False.", + description="Whether rotation of geometry is allowed (fix F) or disallowed (fix T). " + "When False, QCElemental will pre-process the Molecule object to orient via the intertial tensor, " + "resulting in a different ``geometry`` than the one provided. " + "guidance: A consumer who rotates the geometry must not reattach the input (pre-rotation) molecule schema instance to any output (post-rotation) frame-sensitive results (e.g., molecular vibrations).", ) fix_symmetry: Optional[str] = Field( # type: ignore None, description="Maximal point group symmetry which ``geometry`` should be treated. Lowercase." ) # Extra - provenance: Provenance = Field( # type: ignore - provenance_stamp(__name__), + provenance: Provenance = Field( + default_factory=partial(provenance_stamp, __name__), description="The provenance information about how this Molecule (and its attributes) were generated, " "provided, and manipulated.", ) @@ -237,7 +282,8 @@ class Molecule(ProtoModel): "never need to be manually set.", ) extras: Dict[str, Any] = Field( # type: ignore - None, description="Extra information to associate with this Molecule." + None, + description="Additional information to bundle with the molecule. Use for schema development and scratch space.", ) class Config(ProtoModel.Config): @@ -259,6 +305,10 @@ class Config(ProtoModel.Config): "fragment_multiplicities_": "fragment_multiplicities", } + def schema_extra(schema, model): + # below addresses the draft-04 issue until https://github.com/samuelcolvin/pydantic/issues/1478 . + schema["$schema"] = qcschema_draft + def __init__(self, orient: bool = False, validate: Optional[bool] = None, **kwargs: Any) -> None: """Initializes the molecule object from dictionary-like values. @@ -1042,7 +1092,7 @@ def nuclear_repulsion_energy(self, ifr: int = None) -> float: Nuclear repulsion energy in entire molecule or in fragment. """ - Zeff = [z * int(real) for z, real in zip(self.atomic_numbers, self.real)] + Zeff = [z * int(real) for z, real in zip(cast(Iterable[int], self.atomic_numbers), self.real)] atoms = list(range(self.geometry.shape[0])) if ifr is not None: @@ -1068,7 +1118,7 @@ def nelectrons(self, ifr: int = None) -> int: Number of electrons in entire molecule or in fragment. """ - Zeff = [z * int(real) for z, real in zip(self.atomic_numbers, self.real)] + Zeff = [z * int(real) for z, real in zip(cast(Iterable[int], self.atomic_numbers), self.real)] if ifr is None: nel = sum(Zeff) - self.molecular_charge @@ -1146,7 +1196,7 @@ def align( runiq = np.asarray( [ hashlib.sha1((sym + str(mas)).encode("utf-8")).hexdigest() - for sym, mas in zip(ref_mol.symbols, ref_mol.masses) + for sym, mas in zip(cast(Iterable[str], ref_mol.symbols), ref_mol.masses) ] ) concern_mol = self @@ -1157,7 +1207,7 @@ def align( cuniq = np.asarray( [ hashlib.sha1((sym + str(mas)).encode("utf-8")).hexdigest() - for sym, mas in zip(concern_mol.symbols, concern_mol.masses) + for sym, mas in zip(cast(Iterable[str], concern_mol.symbols), concern_mol.masses) ] ) @@ -1293,7 +1343,7 @@ def scramble( runiq = np.asarray( [ hashlib.sha1((sym + str(mas)).encode("utf-8")).hexdigest() - for sym, mas in zip(ref_mol.symbols, ref_mol.masses) + for sym, mas in zip(cast(Iterable[str], ref_mol.symbols), ref_mol.masses) ] ) nat = rgeom.shape[0] diff --git a/qcelemental/models/procedures.py b/qcelemental/models/procedures.py index 879660cd0..a6ad7989b 100644 --- a/qcelemental/models/procedures.py +++ b/qcelemental/models/procedures.py @@ -57,7 +57,10 @@ class QCInputSpecification(ProtoModel): model: Model = Field(..., description=str(Model.__doc__)) keywords: Dict[str, Any] = Field({}, description="The program specific keywords to be used.") - extras: Dict[str, Any] = Field({}, description="Extra fields that are not part of the schema.") + extras: Dict[str, Any] = Field( + {}, + description="Additional information to bundle with the computation. Use for schema development and scratch space.", + ) class OptimizationInput(ProtoModel): diff --git a/qcelemental/models/results.py b/qcelemental/models/results.py index 92bf46523..db1ff358b 100644 --- a/qcelemental/models/results.py +++ b/qcelemental/models/results.py @@ -1,11 +1,12 @@ from enum import Enum +from functools import partial from typing import TYPE_CHECKING, Any, Dict, Optional, Set, Union import numpy as np from pydantic import Field, constr, validator from ..util import provenance_stamp -from .basemodels import ProtoModel +from .basemodels import ProtoModel, qcschema_draft from .basis import BasisSet from .common_models import ComputeError, DriverEnum, Model, Provenance, qcschema_input_default, qcschema_output_default from .molecule import Molecule @@ -16,8 +17,13 @@ class AtomicResultProperties(ProtoModel): - """ - Named properties of quantum chemistry computations following the MolSSI QCSchema. + """Named properties of quantum chemistry computations following the MolSSI QCSchema. + + Notes + ----- + All arrays are stored flat but must be reshapable into the dimensions in attribute ``shape``, with abbreviations as follows: + nao: number of atomic orbitals = calcinfo_nbasis + nmo: number of molecular orbitals """ # Calcinfo @@ -28,90 +34,194 @@ class AtomicResultProperties(ProtoModel): calcinfo_natom: Optional[int] = Field(None, description="The number of atoms in the computation.") # Canonical - nuclear_repulsion_energy: Optional[float] = Field(None, description="The nuclear repulsion energy energy.") + nuclear_repulsion_energy: Optional[float] = Field(None, description="The nuclear repulsion energy.") return_energy: Optional[float] = Field( - None, description="The energy of the requested method, identical to `return_value` for energy computations." + None, + description="The energy of the requested method, identical to ``return_result`` for ``driver=energy`` computations.", ) # SCF Keywords scf_one_electron_energy: Optional[float] = Field( - None, description="The one-electron (core Hamiltonian) energy contribution to the total SCF energy." + None, + description="The one-electron (core Hamiltonian) energy contribution to the total SCF energy.", + units="E_h", ) scf_two_electron_energy: Optional[float] = Field( - None, description="The two-electron energy contribution to the total SCF energy." + None, + description="The two-electron energy contribution to the total SCF energy.", + units="E_h", ) scf_vv10_energy: Optional[float] = Field( - None, description="The VV10 functional energy contribution to the total SCF energy." + None, + description="The VV10 functional energy contribution to the total SCF energy.", + units="E_h", ) scf_xc_energy: Optional[float] = Field( - None, description="The functional (XC) energy contribution to the total SCF energy." + None, + description="The functional (XC) energy contribution to the total SCF energy.", + units="E_h", ) scf_dispersion_correction_energy: Optional[float] = Field( None, description="The dispersion correction appended to an underlying functional when a DFT-D method is requested.", + units="E_h", + ) + scf_dipole_moment: Optional[Array[float]] = Field( + None, + description="The SCF X, Y, and Z dipole components", + units="e a0", ) - scf_dipole_moment: Optional[Array[float]] = Field(None, description="The X, Y, and Z dipole components.") scf_quadrupole_moment: Optional[Array[float]] = Field( - None, description="The (3, 3) quadrupole components (redundant; 6 unique)." + None, + description="The quadrupole components (redundant; 6 unique).", + shape=[3, 3], + units="e a0^2", ) scf_total_energy: Optional[float] = Field( - None, description="The total electronic energy of the SCF stage of the calculation." + None, + description="The total electronic energy of the SCF stage of the calculation.", + units="E_h", ) scf_iterations: Optional[int] = Field(None, description="The number of SCF iterations taken before convergence.") # MP2 Keywords mp2_same_spin_correlation_energy: Optional[float] = Field( - None, description="The portion of MP2 doubles correlation energy from same-spin (i.e. triplet) correlations." + None, + description="The portion of MP2 doubles correlation energy from same-spin (i.e. triplet) correlations, without any user scaling.", + units="E_h", ) mp2_opposite_spin_correlation_energy: Optional[float] = Field( None, - description="The portion of MP2 doubles correlation energy from opposite-spin (i.e. singlet) correlations.", + description="The portion of MP2 doubles correlation energy from opposite-spin (i.e. singlet) correlations, without any user scaling.", + units="E_h", ) mp2_singles_energy: Optional[float] = Field( - None, description="The singles portion of the MP2 correlation energy. Zero except in ROHF." + None, + description="The singles portion of the MP2 correlation energy. Zero except in ROHF.", + units="E_h", ) mp2_doubles_energy: Optional[float] = Field( None, description="The doubles portion of the MP2 correlation energy including same-spin and opposite-spin correlations.", + units="E_h", ) mp2_total_correlation_energy: Optional[float] = Field( None, description="The MP2 correlation energy." ) # Old name, to be deprecated - mp2_correlation_energy: Optional[float] = Field(None, description="The MP2 correlation energy.") + mp2_correlation_energy: Optional[float] = Field( + None, + description="The MP2 correlation energy.", + units="E_h", + ) mp2_total_energy: Optional[float] = Field( - None, description="The total MP2 energy (MP2 correlation energy + HF energy)." + None, + description="The total MP2 energy (MP2 correlation energy + HF energy).", + units="E_h", + ) + mp2_dipole_moment: Optional[Array[float]] = Field( + None, + description="The MP2 X, Y, and Z dipole components.", + shape=[3], + units="e a0", ) - mp2_dipole_moment: Optional[Array[float]] = Field(None, description="The MP2 X, Y, and Z dipole components.") # CCSD Keywords ccsd_same_spin_correlation_energy: Optional[float] = Field( - None, description="The portion of CCSD doubles correlation energy from same-spin (i.e. triplet) correlations." + None, + description="The portion of CCSD doubles correlation energy from same-spin (i.e. triplet) correlations, without any user scaling.", + units="E_h", ) ccsd_opposite_spin_correlation_energy: Optional[float] = Field( None, - description="The portion of CCSD doubles correlation energy from opposite-spin (i.e. singlet) correlations", + description="The portion of CCSD doubles correlation energy from opposite-spin (i.e. singlet) correlations, without any user scaling.", + units="E_h", ) ccsd_singles_energy: Optional[float] = Field( - None, description="The singles portion of the CCSD correlation energy. Zero except in ROHF." + None, + description="The singles portion of the CCSD correlation energy. Zero except in ROHF.", + units="E_h", ) ccsd_doubles_energy: Optional[float] = Field( None, description="The doubles portion of the CCSD correlation energy including same-spin and opposite-spin correlations.", + units="E_h", + ) + ccsd_correlation_energy: Optional[float] = Field( + None, + description="The CCSD correlation energy.", + units="E_h", ) - ccsd_correlation_energy: Optional[float] = Field(None, description="The CCSD correlation energy.") ccsd_total_energy: Optional[float] = Field( - None, description="The total CCSD energy (CCSD correlation energy + HF energy)." + None, + description="The total CCSD energy (CCSD correlation energy + HF energy).", + units="E_h", + ) + ccsd_dipole_moment: Optional[Array[float]] = Field( + None, + description="The CCSD X, Y, and Z dipole components.", + shape=[3], + units="e a0", ) - ccsd_dipole_moment: Optional[Array[float]] = Field(None, description="The CCSD X, Y, and Z dipole components.") ccsd_iterations: Optional[int] = Field(None, description="The number of CCSD iterations taken before convergence.") # CCSD(T) keywords - ccsd_prt_pr_correlation_energy: Optional[float] = Field(None, description="The CCSD(T) correlation energy.") + ccsd_prt_pr_correlation_energy: Optional[float] = Field( + None, + description="The CCSD(T) correlation energy.", + units="E_h", + ) ccsd_prt_pr_total_energy: Optional[float] = Field( - None, description="The total CCSD(T) energy (CCSD(T) correlation energy + HF energy)." + None, + description="The total CCSD(T) energy (CCSD(T) correlation energy + HF energy).", + units="E_h", ) ccsd_prt_pr_dipole_moment: Optional[Array[float]] = Field( - None, description="The CCSD(T) X, Y, and Z dipole components." + None, + description="The CCSD(T) X, Y, and Z dipole components.", + shape=[3], + units="e a0", + ) + + # CCSDT keywords + ccsdt_correlation_energy: Optional[float] = Field( + None, + description="The CCSDT correlation energy.", + units="E_h", + ) + ccsdt_total_energy: Optional[float] = Field( + None, + description="The total CCSDT energy (CCSDT correlation energy + HF energy).", + units="E_h", + ) + ccsdt_dipole_moment: Optional[Array[float]] = Field( + None, + description="The CCSDT X, Y, and Z dipole components.", + shape=[3], + units="e a0", + ) + ccsdt_iterations: Optional[int] = Field( + None, description="The number of CCSDT iterations taken before convergence." + ) + + # CCSDTQ keywords + ccsdtq_correlation_energy: Optional[float] = Field( + None, + description="The CCSDTQ correlation energy.", + units="E_h", + ) + ccsdtq_total_energy: Optional[float] = Field( + None, + description="The total CCSDTQ energy (CCSDTQ correlation energy + HF energy).", + units="E_h", + ) + ccsdtq_dipole_moment: Optional[Array[float]] = Field( + None, + description="The CCSDTQ X, Y, and Z dipole components.", + shape=[3], + units="e a0", + ) + ccsdtq_iterations: Optional[int] = Field( + None, description="The number of CCSDTQ iterations taken before convergence." ) class Config(ProtoModel.Config): @@ -146,6 +256,7 @@ def dict(self, *args, **kwargs): class WavefunctionProperties(ProtoModel): + """Wavefunction properties resulting from a computation. Matrix quantities are stored in column-major order. Presence and contents configurable by protocol.""" # Class properties _return_results_names: Set[str] = { @@ -171,26 +282,87 @@ class WavefunctionProperties(ProtoModel): ) # Core Hamiltonian - h_core_a: Optional[Array[float]] = Field(None, description="Alpha-spin core (one-electron) Hamiltonian.") - h_core_b: Optional[Array[float]] = Field(None, description="Beta-spin core (one-electron) Hamiltonian.") + h_core_a: Optional[Array[float]] = Field( + None, description="Alpha-spin core (one-electron) Hamiltonian in the AO basis.", shape=["nao", "nao"] + ) + h_core_b: Optional[Array[float]] = Field( + None, description="Beta-spin core (one-electron) Hamiltonian in the AO basis.", shape=["nao", "nao"] + ) h_effective_a: Optional[Array[float]] = Field( - None, description="Alpha-spin effective core (one-electron) Hamiltonian." + None, description="Alpha-spin effective core (one-electron) Hamiltonian in the AO basis.", shape=["nao", "nao"] ) h_effective_b: Optional[Array[float]] = Field( - None, description="Beta-spin effective core (one-electron) Hamiltonian " + None, description="Beta-spin effective core (one-electron) Hamiltonian in the AO basis", shape=["nao", "nao"] ) # SCF Results - scf_orbitals_a: Optional[Array[float]] = Field(None, description="SCF alpha-spin orbitals.") - scf_orbitals_b: Optional[Array[float]] = Field(None, description="SCF beta-spin orbitals.") - scf_density_a: Optional[Array[float]] = Field(None, description="SCF alpha-spin density matrix.") - scf_density_b: Optional[Array[float]] = Field(None, description="SCF beta-spin density matrix.") - scf_fock_a: Optional[Array[float]] = Field(None, description="SCF alpha-spin Fock matrix.") - scf_fock_b: Optional[Array[float]] = Field(None, description="SCF beta-spin Fock matrix.") - scf_eigenvalues_a: Optional[Array[float]] = Field(None, description="SCF alpha-spin eigenvalues.") - scf_eigenvalues_b: Optional[Array[float]] = Field(None, description="SCF beta-spin eigenvalues.") - scf_occupations_a: Optional[Array[float]] = Field(None, description="SCF alpha-spin occupations.") - scf_occupations_b: Optional[Array[float]] = Field(None, description="SCF beta-spin occupations.") + scf_orbitals_a: Optional[Array[float]] = Field( + None, description="SCF alpha-spin orbitals in the AO basis.", shape=["nao", "nmo"] + ) + scf_orbitals_b: Optional[Array[float]] = Field( + None, description="SCF beta-spin orbitals in the AO basis.", shape=["nao", "nmo"] + ) + scf_density_a: Optional[Array[float]] = Field( + None, description="SCF alpha-spin density matrix in the AO basis.", shape=["nao", "nao"] + ) + scf_density_b: Optional[Array[float]] = Field( + None, description="SCF beta-spin density matrix in the AO basis.", shape=["nao", "nao"] + ) + scf_fock_a: Optional[Array[float]] = Field( + None, description="SCF alpha-spin Fock matrix in the AO basis.", shape=["nao", "nao"] + ) + scf_fock_b: Optional[Array[float]] = Field( + None, description="SCF beta-spin Fock matrix in the AO basis.", shape=["nao", "nao"] + ) + scf_eigenvalues_a: Optional[Array[float]] = Field( + None, description="SCF alpha-spin orbital eigenvalues.", shape=["nmo"] + ) + scf_eigenvalues_b: Optional[Array[float]] = Field( + None, description="SCF beta-spin orbital eigenvalues.", shape=["nmo"] + ) + scf_occupations_a: Optional[Array[float]] = Field( + None, description="SCF alpha-spin orbital occupations.", shape=["nmo"] + ) + scf_occupations_b: Optional[Array[float]] = Field( + None, description="SCF beta-spin orbital occupations.", shape=["nmo"] + ) + + # BELOW from qcsk + scf_coulomb_a: Optional[Array[float]] = Field( + None, description="SCF alpha-spin Coulomb matrix in the AO basis.", shape=["nao", "nao"] + ) + scf_coulomb_b: Optional[Array[float]] = Field( + None, description="SCF beta-spin Coulomb matrix in the AO basis.", shape=["nao", "nao"] + ) + scf_exchange_a: Optional[Array[float]] = Field( + None, description="SCF alpha-spin exchange matrix in the AO basis.", shape=["nao", "nao"] + ) + scf_exchange_b: Optional[Array[float]] = Field( + None, description="SCF beta-spin exchange matrix in the AO basis.", shape=["nao", "nao"] + ) + + # Localized-orbital SCF wavefunction quantities + localized_orbitals_a: Optional[Array[float]] = Field( + None, + description="Localized alpha-spin orbitals in the AO basis. All nmo orbitals are included, even if only a subset were localized.", + shape=["nao", "nmo"], + ) + localized_orbitals_b: Optional[Array[float]] = Field( + None, + description="Localized beta-spin orbitals in the AO basis. All nmo orbitals are included, even if only a subset were localized.", + shape=["nao", "nmo"], + ) + localized_fock_a: Optional[Array[float]] = Field( + None, + description="Alpha-spin Fock matrix in the localized molecular orbital basis. All nmo orbitals are included, even if only a subset were localized.", + shape=["nmo", "nmo"], + ) + localized_fock_b: Optional[Array[float]] = Field( + None, + description="Beta-spin Fock matrix in the localized molecular orbital basis. All nmo orbitals are included, even if only a subset were localized.", + shape=["nmo", "nmo"], + ) + # ABOVE from qcsk # Return results, must be defined last orbitals_a: Optional[str] = Field(None, description="Index to the alpha-spin orbitals of the primary return.") @@ -199,14 +371,18 @@ class WavefunctionProperties(ProtoModel): density_b: Optional[str] = Field(None, description="Index to the beta-spin density of the primary return.") fock_a: Optional[str] = Field(None, description="Index to the alpha-spin Fock matrix of the primary return.") fock_b: Optional[str] = Field(None, description="Index to the beta-spin Fock matrix of the primary return.") - eigenvalues_a: Optional[str] = Field(None, description="Index to the alpha-spin eigenvalues of the primary return.") - eigenvalues_b: Optional[str] = Field(None, description="Index to the beta-spin eigenvalues of the primary return.") - occupations_a: Optional[str] = Field( + eigenvalues_a: Optional[str] = Field( None, description="Index to the alpha-spin orbital eigenvalues of the primary return." ) - occupations_b: Optional[str] = Field( + eigenvalues_b: Optional[str] = Field( None, description="Index to the beta-spin orbital eigenvalues of the primary return." ) + occupations_a: Optional[str] = Field( + None, description="Index to the alpha-spin orbital occupations of the primary return." + ) + occupations_b: Optional[str] = Field( + None, description="Index to the beta-spin orbital occupations of the primary return." + ) class Config(ProtoModel.Config): force_skip_defaults = True @@ -278,9 +454,7 @@ def _assert_exists(cls, v, values): class WavefunctionProtocolEnum(str, Enum): - """ - Wavefunction to keep from a Result computation. - """ + """Wavefunction to keep from a computation.""" all = "all" orbitals_and_eigenvalues = "orbitals_and_eigenvalues" @@ -290,7 +464,7 @@ class WavefunctionProtocolEnum(str, Enum): class ErrorCorrectionProtocol(ProtoModel): """Configuration for how QCEngine handles error correction - + WARNING: These protocols are currently experimental and only supported by NWChem tasks """ @@ -311,16 +485,14 @@ def allows(self, policy: str): class AtomicResultProtocols(ProtoModel): - """ - Protocols regarding the manipulation of a Result output data. - """ + """Protocols regarding the manipulation of computational result data.""" wavefunction: WavefunctionProtocolEnum = Field( WavefunctionProtocolEnum.none, description=str(WavefunctionProtocolEnum.__doc__) ) - stdout: bool = Field(True, description="Primary output file to keep from a Result computation") + stdout: bool = Field(True, description="Primary output file to keep from the computation") error_correction: ErrorCorrectionProtocol = Field( - ErrorCorrectionProtocol(), description="Policies for error correction" + default_factory=ErrorCorrectionProtocol, description="Policies for error correction" ) class Config: @@ -333,21 +505,35 @@ class Config: class AtomicInput(ProtoModel): """The MolSSI Quantum Chemistry Schema""" - id: Optional[str] = Field(None, description="An optional ID of the ResultInput object.") - schema_name: constr(strip_whitespace=True, regex=qcschema_input_default) = qcschema_input_default # type: ignore - schema_version: int = 1 + id: Optional[str] = Field(None, description="The optional ID for the computation.") + schema_name: constr(strip_whitespace=True, regex="^(qc_?schema_input)$") = Field( # type: ignore + qcschema_input_default, + description=( + f"The QCSchema specification this model conforms to. Explicitly fixed as {qcschema_input_default}." + ), + ) + schema_version: int = Field(1, description="The version number of ``schema_name`` to which this model conforms.") molecule: Molecule = Field(..., description="The molecule to use in the computation.") driver: DriverEnum = Field(..., description=str(DriverEnum.__doc__)) model: Model = Field(..., description=str(Model.__base_doc__)) - keywords: Dict[str, Any] = Field({}, description="The program specific keywords to be used.") + keywords: Dict[str, Any] = Field({}, description="The program-specific keywords to be used.") protocols: AtomicResultProtocols = Field( AtomicResultProtocols(), description=str(AtomicResultProtocols.__base_doc__) ) - extras: Dict[str, Any] = Field({}, description="Extra fields that are not part of the schema.") + extras: Dict[str, Any] = Field( + {}, + description="Additional information to bundle with the computation. Use for schema development and scratch space.", + ) + + provenance: Provenance = Field( + default_factory=partial(provenance_stamp, __name__), description=str(Provenance.__base_doc__) + ) - provenance: Provenance = Field(Provenance(**provenance_stamp(__name__)), description=str(Provenance.__base_doc__)) + class Config(ProtoModel.Config): + def schema_extra(schema, model): + schema["$schema"] = qcschema_draft def __repr_args__(self) -> "ReprArgs": return [ @@ -358,21 +544,29 @@ def __repr_args__(self) -> "ReprArgs": class AtomicResult(AtomicInput): - schema_name: constr(strip_whitespace=True, regex=qcschema_output_default) = qcschema_output_default # type: ignore + """Results from a CMS program execution.""" + schema_name: constr(strip_whitespace=True, regex="^(qc_?schema_output)$") = Field( # type: ignore + qcschema_output_default, + description=( + f"The QCSchema specification this model conforms to. Explicitly fixed as {qcschema_output_default}." + ), + ) properties: AtomicResultProperties = Field(..., description=str(AtomicResultProperties.__base_doc__)) wavefunction: Optional[WavefunctionProperties] = Field(None, description=str(WavefunctionProperties.__base_doc__)) return_result: Union[float, Array[float], Dict[str, Any]] = Field( - ..., description="The value requested by the 'driver' attribute." + ..., + description="The primary return specified by the ``driver`` field. Scalar if energy; array if gradient or hessian; dictionary with property keys if properties.", ) # type: ignore - stdout: Optional[str] = Field(None, description="The standard output of the program.") - stderr: Optional[str] = Field(None, description="The standard error of the program.") - - success: bool = Field( - ..., description="The success of a given programs execution. If False, other fields may be blank." + stdout: Optional[str] = Field( + None, + description="The primary logging output of the program, whether natively standard output or a file. Presence vs. absence (or null-ness?) configurable by protocol.", ) + stderr: Optional[str] = Field(None, description="The standard error of the program execution.") + + success: bool = Field(..., description="The success of program execution. If False, other fields may be blank.") error: Optional[ComputeError] = Field(None, description=str(ComputeError.__base_doc__)) provenance: Provenance = Field(..., description=str(Provenance.__base_doc__)) diff --git a/qcelemental/molparse/chgmult.py b/qcelemental/molparse/chgmult.py index 32d53d662..7d1ff4703 100644 --- a/qcelemental/molparse/chgmult.py +++ b/qcelemental/molparse/chgmult.py @@ -360,8 +360,8 @@ def validate_and_fill_chgmult( cgmp_rules.append("4") for ifr in range(nfr): cgmp_range.append( - lambda c, fc, m, fm, ifr=ifr: _sufficient_electrons_for_mult(fzel[ifr], fc[ifr], fm[ifr]) - ) # type: ignore + lambda c, fc, m, fm, ifr=ifr: _sufficient_electrons_for_mult(fzel[ifr], fc[ifr], fm[ifr]) # type: ignore + ) cgmp_rules.append("4-" + str(ifr)) # * (R5) require total parity consistent among neutral_electrons, chg, and mult diff --git a/qcelemental/molparse/to_string.py b/qcelemental/molparse/to_string.py index a83c83642..ff0b6d8d2 100644 --- a/qcelemental/molparse/to_string.py +++ b/qcelemental/molparse/to_string.py @@ -384,7 +384,7 @@ def to_dict(self) -> Dict: atom_format = "{elem}" ghost_format = "@{elem}" - umap = {"bohr": True, "angstrom": False} + umap = {"bohr": "True", "angstrom": "False"} atoms = _atoms_formatter(molrec, geom, atom_format, ghost_format, width, prec, 2) diff --git a/qcelemental/molutil/molecular_formula.py b/qcelemental/molutil/molecular_formula.py index 3fee6ca40..7ff1d8fdb 100644 --- a/qcelemental/molutil/molecular_formula.py +++ b/qcelemental/molutil/molecular_formula.py @@ -1,6 +1,6 @@ import collections import re -from typing import List +from typing import Dict, List def order_molecular_formula(formula: str, order: str = "alphabetical") -> str: @@ -23,7 +23,7 @@ def order_molecular_formula(formula: str, order: str = "alphabetical") -> str: matches = re.findall(r"[A-Z][^A-Z]*", formula) if not "".join(matches) == formula: raise ValueError(f"{formula} is not a valid molecular formula.") - count = collections.defaultdict(int) + count: Dict[str, int] = collections.defaultdict(int) for match in matches: match_n = re.match(r"(\D+)(\d*)", match) assert match_n diff --git a/qcelemental/molutil/test_molutil.py b/qcelemental/molutil/test_molutil.py index 4b826f5e1..bf8cf0124 100644 --- a/qcelemental/molutil/test_molutil.py +++ b/qcelemental/molutil/test_molutil.py @@ -8,7 +8,7 @@ import qcelemental as qcel from qcelemental.testing import compare, compare_molrecs, compare_recursive, compare_values -from ..tests.addons import using_networkx +from ..tests.addons import drop_qcsk, using_networkx pp = pprint.PrettyPrinter(width=120) @@ -44,8 +44,9 @@ def test_scramble_descrambles_plain(): s22_12.scramble(do_shift=True, do_rotate=True, do_resort=True, do_plot=False, verbose=0, do_test=True) -def test_relative_geoms_align_free(): +def test_relative_geoms_align_free(request): s22_12 = qcel.models.Molecule.from_data(ss22_12) + drop_qcsk(s22_12, request.node.name) for trial in range(3): cmol, _ = s22_12.scramble( @@ -57,8 +58,9 @@ def test_relative_geoms_align_free(): assert compare_molrecs(rmolrec, cmolrec, atol=1.0e-4, relative_geoms="align") -def test_relative_geoms_align_fixed(): +def test_relative_geoms_align_fixed(request): s22_12 = qcel.models.Molecule.from_data(ss22_12 + "nocom\nnoreorient\n") + drop_qcsk(s22_12, request.node.name) for trial in range(3): cmol, _ = s22_12.scramble( @@ -344,7 +346,7 @@ def test_scramble_specific(): assert compare(mill_str, mill.pretty_print()) -def test_hessian_align(): +def test_hessian_align(request): # from Psi4 test test_hessian_vs_cfour[HOOH_TS-H_analytic] # fmt: off @@ -458,6 +460,7 @@ def test_hessian_align(): p4mol = qcel.models.Molecule.from_data(p4_hooh_xyz) c4mol = qcel.models.Molecule.from_data(c4_hooh_xyz) + drop_qcsk(c4mol, request.node.name) aqmol, data = p4mol.align(c4mol, atoms_map=True, mols_align=True, verbose=4) mill = data["mill"] diff --git a/qcelemental/physical_constants/context.py b/qcelemental/physical_constants/context.py index 55896d435..c2c1ffb62 100644 --- a/qcelemental/physical_constants/context.py +++ b/qcelemental/physical_constants/context.py @@ -269,8 +269,7 @@ def get(self, physical_constant: str, return_tuple: bool = False) -> Union[float # me 'electron mass' = 9.10938215E-31 # Electron rest mass (in kg) def Quantity(self, data: str) -> "quantity._Quantity": - """Returns a Pint Quantity. - """ + """Returns a Pint Quantity.""" return self.ureg.Quantity(data) diff --git a/qcelemental/physical_constants/ureg.py b/qcelemental/physical_constants/ureg.py index 05d1727cf..f071b02a4 100644 --- a/qcelemental/physical_constants/ureg.py +++ b/qcelemental/physical_constants/ureg.py @@ -49,7 +49,7 @@ def build_units_registry(context): ureg.define("debye = 1e-18 * statcoulomb * cm = D") # Distance - ureg.define("bohr = {} * meter = bohr_radius = Bohr = au_length".format(phys_const["bohr radius"]["value"])) + ureg.define("bohr = {} * meter = bohr_radius = Bohr = a0 = au_length".format(phys_const["bohr radius"]["value"])) ureg.define("wavenumber = 1 / centimeter") ureg.define("Angstrom = angstrom") @@ -129,8 +129,7 @@ def build_units_registry(context): # Add contexts def _find_nist_unit(unit): - """Converts pint datatypes to NIST datatypes - """ + """Converts pint datatypes to NIST datatypes""" for value in unit.to_tuple()[1]: if value[1] < 1: continue diff --git a/qcelemental/testing.py b/qcelemental/testing.py index 7899b7e4b..de6bc8215 100644 --- a/qcelemental/testing.py +++ b/qcelemental/testing.py @@ -11,8 +11,7 @@ def _handle_return(passfail: bool, label: str, message: str, return_message: bool, quiet: bool = False): - """Function to print a '*label*...PASSED' line to log. - """ + """Function to print a '*label*...PASSED' line to log.""" if not quiet: if passfail: @@ -28,8 +27,7 @@ def _handle_return(passfail: bool, label: str, message: str, return_message: boo def tnm() -> str: - """Returns the name of the calling function, usually name of test case. - """ + """Returns the name of the calling function, usually name of test case.""" return sys._getframe().f_back.f_code.co_name @@ -480,11 +478,7 @@ def compare_molrecs( return_message: bool = False, return_handler: Callable = None, ) -> bool: - """Function to compare Molecule dictionaries. Prints -# :py:func:`util.success` when elements of `computed` match elements of -# `expected` to `tol` number of digits (for float arrays). - - """ + """Function to compare Molecule dictionaries.""" # Need to manipulate the dictionaries a bit, so hold values xptd = copy.deepcopy(expected) cptd = copy.deepcopy(computed) diff --git a/qcelemental/tests/addons.py b/qcelemental/tests/addons.py index e400c5ea3..fe2d9e4ec 100644 --- a/qcelemental/tests/addons.py +++ b/qcelemental/tests/addons.py @@ -1,5 +1,7 @@ +import json import socket from contextlib import contextmanager +from pathlib import Path import pytest @@ -49,3 +51,20 @@ def xfail_on_pubchem_busy(): pytest.xfail("Pubchem server busy") else: raise e + + +_data_path = Path(__file__).parent.resolve() / "qcschema_instances" + + +def drop_qcsk(instance, tnm: str, schema_name: str = None): + if isinstance(instance, qcelemental.models.ProtoModel) and schema_name is None: + schema_name = type(instance).__name__ + drop = (_data_path / schema_name / tnm).with_suffix(".json") + + with open(drop, "w") as fp: + if isinstance(instance, qcelemental.models.ProtoModel): + fp.write(instance.json(exclude_unset=True, exclude_none=True)) + elif isinstance(instance, dict): + json.dump(instance, fp, sort_keys=True, indent=2) + else: + raise TypeError diff --git a/qcelemental/tests/qcschema_instances/AtomicInput/dummy b/qcelemental/tests/qcschema_instances/AtomicInput/dummy new file mode 100644 index 000000000..e69de29bb diff --git a/qcelemental/tests/qcschema_instances/AtomicResult/dummy b/qcelemental/tests/qcschema_instances/AtomicResult/dummy new file mode 100644 index 000000000..e69de29bb diff --git a/qcelemental/tests/qcschema_instances/AtomicResultProperties/dummy b/qcelemental/tests/qcschema_instances/AtomicResultProperties/dummy new file mode 100644 index 000000000..e69de29bb diff --git a/qcelemental/tests/qcschema_instances/BasisSet/dummy b/qcelemental/tests/qcschema_instances/BasisSet/dummy new file mode 100644 index 000000000..e69de29bb diff --git a/qcelemental/tests/qcschema_instances/Molecule/dummy b/qcelemental/tests/qcschema_instances/Molecule/dummy new file mode 100644 index 000000000..e69de29bb diff --git a/qcelemental/tests/qcschema_instances/Provenance/dummy b/qcelemental/tests/qcschema_instances/Provenance/dummy new file mode 100644 index 000000000..e69de29bb diff --git a/qcelemental/tests/qcschema_instances/README.md b/qcelemental/tests/qcschema_instances/README.md new file mode 100644 index 000000000..40e34119f --- /dev/null +++ b/qcelemental/tests/qcschema_instances/README.md @@ -0,0 +1,3 @@ +These subdirectories are populated by running the QCElemental test suite. +Files are JSON representations of QCSchema instances stored or created in the course of testing. +These in turn are checked for compliance against the exported QCSchema models in test case ``test_qcschema``. diff --git a/qcelemental/tests/test_model_general.py b/qcelemental/tests/test_model_general.py index 5adee3fd3..f9f3b6585 100644 --- a/qcelemental/tests/test_model_general.py +++ b/qcelemental/tests/test_model_general.py @@ -13,10 +13,13 @@ Provenance, ) +from .addons import drop_qcsk -def test_result_properties_default_skip(): + +def test_result_properties_default_skip(request): obj = AtomicResultProperties(scf_one_electron_energy="-5.0") + drop_qcsk(obj, request.node.name) assert pytest.approx(obj.scf_one_electron_energy) == -5.0 @@ -31,9 +34,10 @@ def test_result_properties_default_repr(): assert len(repr(obj)) < 100 -def test_repr_provenance(): +def test_repr_provenance(request): prov = Provenance(creator="qcel", version="v0.3.2") + drop_qcsk(prov, request.node.name) assert "qcel" in str(prov) assert "qcel" in repr(prov) @@ -54,11 +58,12 @@ def test_repr_failed_op(): ) -def test_repr_result(): +def test_repr_result(request): result = AtomicInput( **{"driver": "gradient", "model": {"method": "UFF"}, "molecule": {"symbols": ["He"], "geometry": [0, 0, 0]}} ) + drop_qcsk(result, request.node.name) assert "molecule_hash" in str(result) assert "molecule_hash" in repr(result) assert "'gradient'" in str(result) diff --git a/qcelemental/tests/test_model_results.py b/qcelemental/tests/test_model_results.py index 01db8a0fb..2093c608c 100644 --- a/qcelemental/tests/test_model_results.py +++ b/qcelemental/tests/test_model_results.py @@ -4,6 +4,8 @@ import qcelemental as qcel from qcelemental.models import basis +from .addons import drop_qcsk + center_data = { "bs_sto3g_h": { "electron_shells": [ @@ -20,8 +22,8 @@ { "harmonic_type": "spherical", "angular_momentum": [0], - "exponents": [130.70939, 23.808861, 6.4436089], - "coefficients": [[0.15432899, 0.53532814, 0.44463454]], + "exponents": [130.70939, "23.808861", 6.4436089], + "coefficients": [[0.15432899, "0.53532814", 0.44463454]], }, { "harmonic_type": "cartesian", @@ -154,12 +156,13 @@ def test_basis_shell_centers(center_name): assert basis.BasisCenter(**center_data[center_name]) -def test_basis_set_build(): +def test_basis_set_build(request): bas = basis.BasisSet( name="custom_basis", center_data=center_data, atom_map=["bs_sto3g_o", "bs_sto3g_h", "bs_sto3g_h", "bs_def2tzvp_zr"], ) + drop_qcsk(bas, request.node.name) assert len(bas.center_data) == 3 assert len(bas.atom_map) == 4 @@ -170,6 +173,9 @@ def test_basis_set_build(): assert es[1].is_contracted() is False assert es[2].is_contracted() + assert es[0].exponents == [130.70939, 23.808861, 6.4436089] + assert es[0].coefficients == [[0.15432899, 0.53532814, 0.44463454]] + def test_basis_electron_center_raises(): data = center_data["bs_sto3g_h"]["electron_shells"][0].copy() @@ -213,19 +219,23 @@ def test_basis_map_raises(): assert basis.BasisSet(name="custom_basis", center_data=center_data, atom_map=["something_odd"]) -def test_result_build(result_data_fixture): +def test_result_build(result_data_fixture, request): ret = qcel.models.AtomicResult(**result_data_fixture) + drop_qcsk(ret, request.node.name) assert ret.wavefunction is None -def test_result_build_wavefunction_delete(wavefunction_data_fixture): +def test_result_build_wavefunction_delete(wavefunction_data_fixture, request): del wavefunction_data_fixture["protocols"] ret = qcel.models.AtomicResult(**wavefunction_data_fixture) + drop_qcsk(ret, request.node.name) assert ret.wavefunction is None -def test_wavefunction_build(wavefunction_data_fixture): - assert qcel.models.AtomicResult(**wavefunction_data_fixture) +def test_wavefunction_build(wavefunction_data_fixture, request): + ret = qcel.models.AtomicResult(**wavefunction_data_fixture) + drop_qcsk(ret, request.node.name) + assert ret def test_wavefunction_matrix_size_error(wavefunction_data_fixture): @@ -268,7 +278,7 @@ def test_wavefunction_return_result_pointer(wavefunction_data_fixture): ("return_results", True, ["orbitals_a", "fock_a", "fock_b"], ["orbitals_a", "fock_a"]), ], ) -def test_wavefunction_protocols(protocol, restricted, provided, expected, wavefunction_data_fixture): +def test_wavefunction_protocols(protocol, restricted, provided, expected, wavefunction_data_fixture, request): wfn_data = wavefunction_data_fixture["wavefunction"] @@ -289,6 +299,7 @@ def test_wavefunction_protocols(protocol, restricted, provided, expected, wavefu wfn_data[scf_name] = np.random.rand(bas.nbf, bas.nbf) wfn = qcel.models.AtomicResult(**wavefunction_data_fixture) + drop_qcsk(wfn, request.node.name) if len(expected) == 0: assert wfn.wavefunction is None @@ -316,7 +327,7 @@ def test_optimization_trajectory_protocol(keep, indices, optimization_data_fixtu "default, defined, default_result, defined_result", [(None, None, True, None), (False, {"a": True}, False, {"a": True})], ) -def test_error_correction_protocol(default, defined, default_result, defined_result, result_data_fixture): +def test_error_correction_protocol(default, defined, default_result, defined_result, result_data_fixture, request): policy = {} if default is not None: policy["default_policy"] = default @@ -324,6 +335,7 @@ def test_error_correction_protocol(default, defined, default_result, defined_res policy["policies"] = defined result_data_fixture["protocols"] = {"error_correction": policy} res = qcel.models.AtomicResult(**result_data_fixture) + drop_qcsk(res, request.node.name) assert res.protocols.error_correction.default_policy == default_result assert res.protocols.error_correction.policies == defined_result @@ -348,18 +360,20 @@ def test_error_correction_logic(): assert correction_policy.allows("a") -def test_result_build_stdout_delete(result_data_fixture): +def test_result_build_stdout_delete(result_data_fixture, request): result_data_fixture["protocols"] = {"stdout": False} ret = qcel.models.AtomicResult(**result_data_fixture) + drop_qcsk(ret, request.node.name) assert ret.stdout is None -def test_result_build_stdout(result_data_fixture): +def test_result_build_stdout(result_data_fixture, request): ret = qcel.models.AtomicResult(**result_data_fixture) + drop_qcsk(ret, request.node.name) assert ret.stdout == "I ran." -def test_failed_operation(result_data_fixture): +def test_failed_operation(result_data_fixture, request): water = qcel.models.Molecule.from_data( """ O 0 0 0 @@ -367,6 +381,7 @@ def test_failed_operation(result_data_fixture): H 0 2 0 """ ) + drop_qcsk(water, request.node.name) failed = qcel.models.FailedOperation( extras={"garbage": water}, @@ -380,12 +395,13 @@ def test_failed_operation(result_data_fixture): assert "its all good" in failed_json -def test_result_properties_array(): +def test_result_properties_array(request): lquad = [1, 2, 3, 2, 4, 5, 3, 5, 6] obj = qcel.models.AtomicResultProperties( scf_one_electron_energy="-5.0", scf_dipole_moment=[1, 2, 3], scf_quadrupole_moment=lquad ) + drop_qcsk(obj, request.node.name) assert pytest.approx(obj.scf_one_electron_energy) == -5.0 assert obj.scf_dipole_moment.shape == (3,) diff --git a/qcelemental/tests/test_molecule.py b/qcelemental/tests/test_molecule.py index 41e833602..aa56b0d79 100644 --- a/qcelemental/tests/test_molecule.py +++ b/qcelemental/tests/test_molecule.py @@ -651,14 +651,14 @@ def test_show(): def test_molecule_connectivity(): data = {"geometry": np.random.rand(5, 3), "symbols": ["he"] * 5, "validate": False} - mol = Molecule(**data, connectivity=None) + Molecule(**data, connectivity=None) connectivity = [[n, n + 1, 1] for n in range(4)] - mol = Molecule(**data, connectivity=connectivity) + Molecule(**data, connectivity=connectivity) connectivity[0][0] = -1 with pytest.raises(ValueError): - mol = Molecule(**data, connectivity=connectivity) + Molecule(**data, connectivity=connectivity) def test_orient_nomasses(): @@ -719,7 +719,7 @@ def test_sparse_molecule_connectivity(): def test_bad_isotope_spec(): - with pytest.raises(NotAnElementError) as e: + with pytest.raises(NotAnElementError): qcel.models.Molecule(symbols=["He3"], geometry=[0, 0, 0]) diff --git a/qcelemental/tests/test_molparse_from_schema.py b/qcelemental/tests/test_molparse_from_schema.py index c452509b1..5d65ce8b6 100644 --- a/qcelemental/tests/test_molparse_from_schema.py +++ b/qcelemental/tests/test_molparse_from_schema.py @@ -6,6 +6,8 @@ import qcelemental as qcel from qcelemental.testing import compare_molrecs +from .addons import drop_qcsk + _schema_prov_stamp = {"creator": "QCElemental", "version": "1.0", "routine": "qcelemental.molparse.from_schema"} @@ -131,9 +133,10 @@ def test_from_schema_1p5_14e(): assert compare_molrecs(schema14_psi4_np, ans, 4) -def test_from_schema_2_14e(): +def test_from_schema_2_14e(request): schema = copy.deepcopy(schema14_1) schema.update({"schema_name": "qcschema_molecule", "schema_version": 2}) + drop_qcsk(schema, request.node.name, "Molecule") ans = qcel.molparse.from_schema(schema) assert compare_molrecs(schema14_psi4_np, ans, 4) diff --git a/qcelemental/tests/test_molparse_to_schema.py b/qcelemental/tests/test_molparse_to_schema.py index c64c9653b..72cdc39a0 100644 --- a/qcelemental/tests/test_molparse_to_schema.py +++ b/qcelemental/tests/test_molparse_to_schema.py @@ -7,6 +7,8 @@ import qcelemental from qcelemental.testing import compare_molrecs +from .addons import drop_qcsk + _string_prov_stamp = {"creator": "QCElemental", "version": "1.0", "routine": "qcelemental.molparse.from_string"} _schema_prov_stamp = {"creator": "QCElemental", "version": "1.0", "routine": "qcelemental.molparse.from_schema"} @@ -58,12 +60,13 @@ } -def test_1_14a(): +def test_1_14a(request): fullans = copy.deepcopy(schema14_1) fullans["molecule"]["provenance"] = _string_prov_stamp final = qcelemental.molparse.from_string(subject14) kmol = qcelemental.molparse.to_schema(final["qm"], dtype=1) + drop_qcsk(kmol["molecule"], request.node.name, "Molecule") assert compare_molrecs(fullans["molecule"], kmol["molecule"]) fullans = copy.deepcopy(schema14_psi4) @@ -74,12 +77,13 @@ def test_1_14a(): assert compare_molrecs(fullans, molrec) -def test_2_14b(): +def test_2_14b(request): fullans = copy.deepcopy(schema14_2) fullans["provenance"] = _string_prov_stamp final = qcelemental.molparse.from_string(subject14) kmol = qcelemental.molparse.to_schema(final["qm"], dtype=2) + drop_qcsk(kmol, request.node.name, "Molecule") assert compare_molrecs(fullans, kmol) fullans = copy.deepcopy(schema14_psi4) @@ -109,7 +113,7 @@ def test_dtype_error(): @pytest.mark.parametrize("dtype", [1, 2]) -def test_qcschema_ang_error(dtype): +def test_atomic_units_qcschema_ang_error(dtype): final = qcelemental.molparse.from_string(subject14) with pytest.raises(qcelemental.ValidationError) as e: @@ -182,13 +186,14 @@ def test_psi4_nm_error(): } -def test_1_15a(): +def test_1_15a(request): fullans = copy.deepcopy(schema15_1) fullans["molecule"]["provenance"] = _string_prov_stamp final = qcelemental.molparse.from_string(subject15) final["qm"]["comment"] = "I has a comment" kmol = qcelemental.molparse.to_schema(final["qm"], dtype=1) + drop_qcsk(kmol["molecule"], request.node.name, "Molecule") assert compare_molrecs(fullans["molecule"], kmol["molecule"]) fullans = copy.deepcopy(schema15_psi4) @@ -201,13 +206,14 @@ def test_1_15a(): assert compare_molrecs(fullans, molrec) -def test_2_15b(): +def test_2_15b(request): fullans = copy.deepcopy(schema15_2) fullans["provenance"] = _string_prov_stamp final = qcelemental.molparse.from_string(subject15) final["qm"]["comment"] = "I has a comment" kmol = qcelemental.molparse.to_schema(final["qm"], dtype=2) + drop_qcsk(kmol, request.node.name, "Molecule") assert compare_molrecs(fullans, kmol) fullans = copy.deepcopy(schema15_psi4) @@ -280,7 +286,7 @@ def test_psi4_15c(): } -def test_froto_1_16a(): +def test_froto_1_16a(request): basic = { "schema_name": "qc_schema_output", "schema_version": 1, @@ -296,10 +302,11 @@ def test_froto_1_16a(): fullans["molecule"]["provenance"] = _schema_prov_stamp roundtrip = qcelemental.molparse.to_schema(qcelemental.molparse.from_schema(basic), dtype=1) + drop_qcsk(roundtrip["molecule"], request.node.name, "Molecule") assert compare_molrecs(fullans["molecule"], roundtrip["molecule"]) -def test_froto_2_16a(): +def test_froto_2_16a(request): basic = { "schema_name": "qcschema_molecule", "schema_version": 2, @@ -313,14 +320,18 @@ def test_froto_2_16a(): fullans["provenance"] = _schema_prov_stamp roundtrip = qcelemental.molparse.to_schema(qcelemental.molparse.from_schema(basic), dtype=2) + drop_qcsk(roundtrip, request.node.name, "Molecule") assert compare_molrecs(fullans, roundtrip) @pytest.mark.parametrize("dtype", [1, 2]) -def test_tofro_16b(dtype): +def test_tofro_16b(dtype, request): fullans = copy.deepcopy(schema16_psi4) fullans["provenance"] = _schema_prov_stamp roundtrip = qcelemental.molparse.from_schema(qcelemental.molparse.to_schema(schema16_psi4, dtype=dtype)) + qcsk = qcelemental.molparse.to_schema(schema16_psi4, dtype=dtype) + qcsk = qcsk["molecule"] if dtype == 1 else qcsk + drop_qcsk(qcsk, request.node.name, "Molecule") assert compare_molrecs(fullans, roundtrip) diff --git a/qcelemental/tests/test_molparse_to_string.py b/qcelemental/tests/test_molparse_to_string.py index 2653e3c42..bef3ece5f 100644 --- a/qcelemental/tests/test_molparse_to_string.py +++ b/qcelemental/tests/test_molparse_to_string.py @@ -3,6 +3,8 @@ import qcelemental as qcel from qcelemental.testing import compare +from .addons import drop_qcsk + # CODATA2014 = 1.05835442134 # CODATA2018 = 1.058354421806 au2 = 2.0 * qcel.constants.bohr2angstroms @@ -295,9 +297,10 @@ def test_to_string_xyz(inp, expected): ("subject2", {"dtype": "nglview-sdf"}, "ans2_ngslviewsdf"), ], ) -def test_molecule_to_string(inp, kwargs, expected): +def test_molecule_to_string(inp, kwargs, expected, request): smol = _molecule_inputs[inp].to_string(**kwargs) + drop_qcsk(_molecule_inputs[inp], request.node.name) assert compare(_molecule_outputs[expected], smol) diff --git a/qcelemental/tests/test_zqcschema.py b/qcelemental/tests/test_zqcschema.py new file mode 100644 index 000000000..6d314b8dc --- /dev/null +++ b/qcelemental/tests/test_zqcschema.py @@ -0,0 +1,36 @@ +import json + +import pytest + +import qcelemental as qcel + +from .addons import _data_path + + +@pytest.fixture(scope="module") +def qcschema_models(): + return {md.__name__: json.loads(md.schema_json()) for md in qcel.models.qcschema_models()} + + +files = sorted(_data_path.rglob("*.json")) +ids = [fl.parent.stem + "_" + fl.stem[5:] for fl in files] + + +@pytest.mark.parametrize("fl", files, ids=ids) +def test_qcschema(fl, qcschema_models): + import jsonschema + + model = fl.parent.stem + instance = json.loads(fl.read_text()) + + res = jsonschema.validate(instance, qcschema_models[model]) + assert res is None + + +# import pprint +# print("\n\n<<< SCHEMA") +# pprint.pprint(schemas["BasisSet"]) +# print("\n\n<<< INSTANCE") +# pprint.pprint(instance) + +# assert 0 diff --git a/qcelemental/util/gph_uno_bipartite.py b/qcelemental/util/gph_uno_bipartite.py index 4b7f28fb8..41f639b5a 100644 --- a/qcelemental/util/gph_uno_bipartite.py +++ b/qcelemental/util/gph_uno_bipartite.py @@ -45,10 +45,10 @@ def _formDirected(g, match): Returns ------- networkx.DiGraph - Directed graph, with edges in `match` pointing from set-0 - (bipartite attribute==0) to set-1 (bipartite attrbiute==1), and - the other edges in `g` but not in `match` pointing from set-1 to - set-0. + Directed graph, with edges in `match` pointing from set-0 + (bipartite attribute==0) to set-1 (bipartite attrbiute==1), and + the other edges in `g` but not in `match` pointing from set-1 to + set-0. """ import networkx as nx @@ -127,8 +127,8 @@ def _enumMaximumMatchingIter(g, match, all_matches, add_e=None): match : List of edges forming one maximum matching of `g`. all_matches : - List, each is a list of edges forming a maximum matching of `g`. - Newly found matchings will be appended into this list. + List, each is a list of edges forming a maximum matching of `g`. + Newly found matchings will be appended into this list. add_e : tuple, optional Edge used to form subproblems. If not `None`, will be added to each newly found matchings. @@ -335,32 +335,32 @@ def _enumMaximumMatching2(g): def _enumMaximumMatchingIter2(adj, matchadj, all_matches, n1, add_e=None, check_cycle=True): """Recurively search maximum matchings. - Similar to _enumMaximumMatching but implemented using adjacency matrix - of graph for a slight speed boost. - - Parameters - ---------- -# g : -# Undirected bipartite graph. Nodes are separated by their -# 'bipartite' attribute. -# match : -# List of edges forming one maximum matching of `g`. -# all_matches : -# List, each is a list of edges forming a maximum matching of `g`. -# Newly found matchings will be appended into this list. - add_e : tuple, optional - Edge used to form subproblems. If not `None`, will be added to each - newly found matchings. - - Returns - ------- - list - Updated list of all maximum matchings. - - Author - ------ - guangzhi XU (xugzhi1987@gmail.com; guangzhi.xu@outlook.com) - Update time: 2017-05-21 20:09:06. + Similar to _enumMaximumMatching but implemented using adjacency matrix + of graph for a slight speed boost. + + Parameters + ---------- + # g : + # Undirected bipartite graph. Nodes are separated by their + # 'bipartite' attribute. + # match : + # List of edges forming one maximum matching of `g`. + # all_matches : + # List, each is a list of edges forming a maximum matching of `g`. + # Newly found matchings will be appended into this list. + add_e : tuple, optional + Edge used to form subproblems. If not `None`, will be added to each + newly found matchings. + + Returns + ------- + list + Updated list of all maximum matchings. + + Author + ------ + guangzhi XU (xugzhi1987@gmail.com; guangzhi.xu@outlook.com) + Update time: 2017-05-21 20:09:06. """ import networkx as nx diff --git a/qcelemental/util/importing.py b/qcelemental/util/importing.py index 4bd120989..cacd3b81b 100644 --- a/qcelemental/util/importing.py +++ b/qcelemental/util/importing.py @@ -1,7 +1,7 @@ import os import shutil import sys -from typing import Union +from typing import List, Union def which_import( @@ -12,7 +12,7 @@ def which_import( raise_msg: str = None, package: str = None, namespace_ok: bool = False, -) -> Union[bool, None, str]: +) -> Union[bool, None, str, List[str]]: """Tests to see if a Python module is available. Returns diff --git a/setup.py b/setup.py index b88ce62aa..345fc00e9 100644 --- a/setup.py +++ b/setup.py @@ -31,7 +31,7 @@ package_data={'': [os.path.join('qcelemental', 'data', '*.json')]}, setup_requires=[] + pytest_runner, python_requires='>=3.6', - install_requires=['numpy >= 1.12.0', 'pint >= 0.10.0', 'pydantic >= 1.0.0'], + install_requires=["numpy >= 1.12.0", "pint >= 0.10.0", "pydantic >= 1.5.0"], extras_require={ 'docs': [ 'numpydoc',