From 11a52a3fb57064722f435f525d864436e5967b28 Mon Sep 17 00:00:00 2001 From: "Lori A. Burns" Date: Tue, 21 Jan 2025 13:28:23 -0500 Subject: [PATCH] model reorganization (#366) bare rearrangement bare rearrangement 2 fix up imports new molecule fns mol v2 convert foundational models clear away version_stamp and placeholder Mol v3 basisset move and OptRes.trajectory rename post-Christmas, mostly opt/td edits --- .github/workflows/CI.yaml | 4 +- docs/changelog.rst | 29 +- qcelemental/models/v1/__init__.py | 2 +- qcelemental/models/v1/basemodels.py | 13 +- qcelemental/models/v1/basis.py | 31 +- qcelemental/models/v1/common_models.py | 13 +- qcelemental/models/v1/molecule.py | 32 +- qcelemental/models/v1/procedures.py | 91 +++-- qcelemental/models/v1/results.py | 64 ++-- qcelemental/models/v2/__init__.py | 30 +- .../models/v2/{results.py => atomic.py} | 93 ++--- qcelemental/models/v2/basemodels.py | 13 +- .../models/v2/{basis.py => basis_set.py} | 34 +- qcelemental/models/v2/common_models.py | 110 +----- qcelemental/models/v2/failed_operation.py | 102 ++++++ qcelemental/models/v2/molecule.py | 58 ++-- .../v2/{procedures.py => optimization.py} | 321 +++--------------- qcelemental/models/v2/torsion_drive.py | 307 +++++++++++++++++ qcelemental/molparse/from_schema.py | 2 + qcelemental/molparse/to_schema.py | 8 +- .../qcschema_instances/AtomicProperties/dummy | 0 qcelemental/tests/test_model_general.py | 10 +- qcelemental/tests/test_model_results.py | 219 ++++++++---- qcelemental/tests/test_molecule.py | 4 +- qcelemental/tests/test_utils.py | 4 +- 25 files changed, 966 insertions(+), 628 deletions(-) rename qcelemental/models/v2/{results.py => atomic.py} (93%) rename qcelemental/models/v2/{basis.py => basis_set.py} (89%) create mode 100644 qcelemental/models/v2/failed_operation.py rename qcelemental/models/v2/{procedures.py => optimization.py} (51%) create mode 100644 qcelemental/models/v2/torsion_drive.py create mode 100644 qcelemental/tests/qcschema_instances/AtomicProperties/dummy diff --git a/.github/workflows/CI.yaml b/.github/workflows/CI.yaml index 02aa4a43..d82b667f 100644 --- a/.github/workflows/CI.yaml +++ b/.github/workflows/CI.yaml @@ -5,12 +5,12 @@ on: branches: - master - next2024 - - next2024_atop_v29 + - next2025 pull_request: branches: - master - next2024 - - next2024_atop_v29 + - next2025 schedule: - cron: "9 16 * * 1" diff --git a/docs/changelog.rst b/docs/changelog.rst index 4312726a..4d35bdca 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -37,8 +37,33 @@ New Features Enhancements ++++++++++++ -- (:pr:`363`) -- (:pr:`363`) +- (:pr:`364`) +- (:pr:`364`) +- (:pr:`364`) +- (:pr:`364`) main storage for ``v2.TorsionDriveResult`` moved from ``optimization_history`` to ``scan_results``. +- (:pr:`364`) ``v2.TorsionDriveInput.initial_molecule`` restored from ``initial_molecules``. +- (:pr:`364`) default of OptimizationProtocol.trajectory_results changed to "none" from "all" in v1. much info can now come from properties. +- (:pr:`364`) v2.OptimizationProtocol renamed trajectory_results from trajectory in accordance with the protocol naming the controlled field. no default change yet. +- (:pr:`364`) v1/v2: import ElectronShell, BasisCenter, ECPPotential from top level models +- (:pr:`364`) molparse learns to pass through schema v3, though no new field for Mol yet. +- (:pr:`364`) ``v2.FailedOperation`` gained schema_name and schema_version=2. unversioned in v1 +- (:pr:`364`) ``v2.BasisSet.schema_version`` is now 2, with no layout change. +- (:pr:`364`) ``v2.Molecule.schema_version`` is now 3. convert_v of all the models learned to handle the new schema_version. +- (:pr:`364`) v2: standardizing on In/Res get versions, Ptcl/Kw/Spec get only schema_name. At, Opt, TD +- (:pr:`364`) v1/v2: removing the version_stamps from the models: At, Opt, TD, Fail, BAsis, Mol. so it will error rather than clobber if constructed with wrong version. convert_v now handles. +- (:pr:`364`) convert_v functions learned to handle model.basis=BasisSet, not just str. +- (:pr:`364`) ``Molecule`` and ``BasisSet`` and ``WavefunctionProperties`` learned to ``convert_v`` to interconvert between v1 and v2. No layout changes. + ``BasisSet.schema_name`` standardized to ``qcschema_basis_set``. + Both classes get their ``schema_name`` as Literal now +- (:pr:`360`) ``Molecule`` learned new functions ``element_composition`` and ``molecular_weight``. + The first gives a dictionary of element symbols and counts, while the second gives the weight in amu. + Both can access the whole molecule or per-fragment like the existing ``nelectrons`` and + ``nuclear_repulsion_energy``. All four can now select all atoms or exclude ghosts (default). +- (:pr:`364`) separated procedures.py and renamed results.py so models are separated into atomic.py, optimization.py, torsion_drive.py, failure models moved to failed_operation.py. basis.py to basis_set.py +- (:pr:`364`) ``schema_name`` output chanded to result ``qcschema_output`` to ``qcschema_atomic_result``. also opt +- (:pr:`364`) ``TDKeywords`` renamed to ``TorsionDriveKeywords`` +- (:pr:`364`) ``AtomicResultProtocols`` renamed to ``AtomicProtocols`` and ``AtomicResultProperties`` to ``AtomicProperties`` +- (:pr:`364`) new ``v2.TorsionDriveProtocols`` model with field ``scan_results`` to control all/none/lowest saving of optimizationresults at each grid point. Use "all" for proper conversion to v1. - (:pr:`363`) ``v2.TorsionDriveResult`` no longer inherits from Input and now has indep id and extras and new native_files. - (:pr:`363`) ``v2.TorsionDriveInput.initial_molecule`` now ``initial_molecules`` as it's a list of >=1 molecules. keep change? - (:pr:`363`) ``v2. TorsionDriveSpecification`` is a new model. instead of ``v2.TorsionDriveInput`` having a ``input_specification`` and an ``optimization_spec`` fields, it has a ``specification`` field that is a ``TorsionDriveSpecification`` which in turn hold opt info and in turn gradient/atomic info. diff --git a/qcelemental/models/v1/__init__.py b/qcelemental/models/v1/__init__.py index 4271ee5a..e47636df 100644 --- a/qcelemental/models/v1/__init__.py +++ b/qcelemental/models/v1/__init__.py @@ -2,7 +2,7 @@ from .align import AlignmentMill from .basemodels import AutodocBaseSettings # remove when QCFractal merges `next` from .basemodels import ProtoModel -from .basis import BasisSet +from .basis import BasisCenter, BasisSet, ECPPotential, ElectronShell from .common_models import ComputeError, DriverEnum, FailedOperation, Model, Provenance from .molecule import Molecule from .procedures import Optimization # scheduled for removal diff --git a/qcelemental/models/v1/basemodels.py b/qcelemental/models/v1/basemodels.py index acf7c99b..61a964f2 100644 --- a/qcelemental/models/v1/basemodels.py +++ b/qcelemental/models/v1/basemodels.py @@ -200,11 +200,22 @@ def compare(self, other: Union["ProtoModel", BaseModel], **kwargs) -> bool: bool True if the objects match. """ - from ..testing import compare_recursive + from ...testing import compare_recursive return compare_recursive(self, other, **kwargs) +def check_convertible_version(ver: int, error: str): + """Standardize version/error handling for v1 QCSchema.""" + + if ver == 1: + return "self" + elif ver == 2: + return True + else: + raise ValueError(f"QCSchema {error} version={ver} does not exist for conversion.") + + # remove when QCFractal merges `next` class AutodocBaseSettings(BaseSettings): """Old class for pydantic docstring before autodoc-pydantic came about.""" diff --git a/qcelemental/models/v1/basis.py b/qcelemental/models/v1/basis.py index 2fe71148..f02ef785 100644 --- a/qcelemental/models/v1/basis.py +++ b/qcelemental/models/v1/basis.py @@ -1,10 +1,13 @@ from enum import Enum -from typing import Dict, List, Literal, Optional +from typing import TYPE_CHECKING, Dict, List, Literal, Optional, Union from pydantic.v1 import ConstrainedInt, Field, constr, validator from ...exceptions import ValidationError -from .basemodels import ProtoModel, qcschema_draft +from .basemodels import ProtoModel, check_convertible_version, qcschema_draft + +if TYPE_CHECKING: + import qcelemental class NonnegativeInt(ConstrainedInt): @@ -175,10 +178,6 @@ class Config(ProtoModel.Config): def schema_extra(schema, model): schema["$schema"] = qcschema_draft - @validator("schema_version", pre=True) - def _version_stamp(cls, v): - return 1 - @validator("atom_map") def _check_atom_map(cls, v, values): sv = set(v) @@ -230,3 +229,23 @@ def _calculate_nbf(cls, atom_map, center_data) -> int: ret += center_count[center] return ret + + def convert_v( + self, target_version: int, / + ) -> Union["qcelemental.models.v1.BasisSet", "qcelemental.models.v2.BasisSet"]: + """Convert to instance of particular QCSchema version.""" + import qcelemental as qcel + + if check_convertible_version(target_version, error="BasisSet") == "self": + return self + + dself = self.model_dump() + if target_version == 2: + dself.pop("schema_name") # changes in v2 + dself.pop("schema_version") # changes in v2 + + self_vN = qcel.models.v2.BasisSet(**dself) + else: + assert False, target_version + + return self_vN diff --git a/qcelemental/models/v1/common_models.py b/qcelemental/models/v1/common_models.py index 55de2f18..2f2b90d4 100644 --- a/qcelemental/models/v1/common_models.py +++ b/qcelemental/models/v1/common_models.py @@ -4,12 +4,14 @@ import numpy as np from pydantic.v1 import Field -from .basemodels import ProtoModel, qcschema_draft +from .basemodels import ProtoModel, check_convertible_version, qcschema_draft from .basis import BasisSet if TYPE_CHECKING: from pydantic.v1.typing import ReprArgs + import qcelemental + # Encoders, to be deprecated ndarray_encoder = {np.ndarray: lambda v: v.flatten().tolist()} @@ -147,15 +149,6 @@ def convert_v( return self_vN -def check_convertible_version(ver: int, error: str): - if ver == 1: - return "self" - elif ver == 2: - return True - else: - raise ValueError(f"QCSchema {error} version={version} does not exist for conversion.") - - qcschema_input_default = "qcschema_input" qcschema_output_default = "qcschema_output" qcschema_optimization_input_default = "qcschema_optimization_input" diff --git a/qcelemental/models/v1/molecule.py b/qcelemental/models/v1/molecule.py index 0ba53ad7..0f699c31 100644 --- a/qcelemental/models/v1/molecule.py +++ b/qcelemental/models/v1/molecule.py @@ -28,13 +28,15 @@ 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, qcschema_draft +from .basemodels import ProtoModel, check_convertible_version, qcschema_draft from .common_models import Provenance, qcschema_molecule_default from .types import Array if TYPE_CHECKING: from pydantic.v1.typing import ReprArgs + import qcelemental + # Rounding quantities for hashing GEOMETRY_NOISE = 8 MASS_NOISE = 6 @@ -376,12 +378,6 @@ def __init__(self, orient: bool = False, validate: Optional[bool] = None, **kwar elif validate or geometry_prep: values["geometry"] = float_prep(values["geometry"], geometry_noise) - @validator("schema_version", pre=True) - def _version_stamp(cls, v): - # seemingly unneeded, this lets conver_v re-label the model w/o discarding model and - # submodel version fields first. - return 2 - @validator("geometry") def _must_be_3n(cls, v, values, **kwargs): n = len(values["symbols"]) @@ -1562,6 +1558,28 @@ def scramble( return cmol, {"rmsd": rmsd, "mill": perturbation} + def convert_v( + self, target_version: int, / + ) -> Union["qcelemental.models.v1.Molecule", "qcelemental.models.v2.Molecule"]: + """Convert to instance of particular QCSchema version.""" + import qcelemental as qcel + + if check_convertible_version(target_version, error="Molecule") == "self": + return self + + loss_store = {} + dself = self.model_dump() + if target_version == 2: + # below is assignment rather than popping so Mol() records as set and future Mol.model_dump() includes the field. + # QCEngine _build_model convert_v(2) can lose it otherwise, and molparse machinery wants to see the field. + dself["schema_version"] = 3 + + self_vN = qcel.models.v2.Molecule(**dself) + else: + assert False, target_version + + return self_vN + def _filter_defaults(dicary): nat = len(dicary["symbols"]) diff --git a/qcelemental/models/v1/procedures.py b/qcelemental/models/v1/procedures.py index b3ace774..3efd80be 100644 --- a/qcelemental/models/v1/procedures.py +++ b/qcelemental/models/v1/procedures.py @@ -4,13 +4,13 @@ from pydantic.v1 import Field, conlist, constr, validator from ...util import provenance_stamp -from .basemodels import ProtoModel +from .basemodels import ProtoModel, check_convertible_version +from .basis import BasisSet from .common_models import ( ComputeError, DriverEnum, Model, Provenance, - check_convertible_version, qcschema_input_default, qcschema_optimization_input_default, qcschema_optimization_output_default, @@ -23,6 +23,8 @@ if TYPE_CHECKING: from pydantic.v1.typing import ReprArgs + import qcelemental + class TrajectoryProtocolEnum(str, Enum): """ @@ -47,6 +49,27 @@ class OptimizationProtocols(ProtoModel): class Config: force_skip_defaults = True + def convert_v( + self, target_version: int, / + ) -> Union["qcelemental.models.v1.OptimizationProtocols", "qcelemental.models.v2.OptimizationProtocols"]: + """Convert to instance of particular QCSchema version.""" + import qcelemental as qcel + + if check_convertible_version(target_version, error="OptimizationProtocols") == "self": + return self + + dself = self.dict() + if target_version == 2: + # serialization is compact, so use model to assure value + dself.pop("trajectory", None) + dself["trajectory_results"] = self.trajectory.value + + self_vN = qcel.models.v2.OptimizationProtocols(**dself) + else: + assert False, target_version + + return self_vN + class QCInputSpecification(ProtoModel): """ @@ -65,10 +88,6 @@ class QCInputSpecification(ProtoModel): description="Additional information to bundle with the computation. Use for schema development and scratch space.", ) - @validator("schema_version", pre=True) - def _version_stamp(cls, v): - return 1 - def convert_v( self, target_version: int, / ) -> Union["qcelemental.models.v1.QCInputSpecification", "qcelemental.models.v2.AtomicSpecification"]: @@ -83,6 +102,12 @@ def convert_v( dself.pop("schema_name") dself.pop("schema_version") + # TODO consider Model.convert_v + model = dself.pop("model") + if isinstance(self.model.basis, BasisSet): + model["basis"] = self.model.basis.convert_v(target_version) + dself["model"] = model + self_vN = qcel.models.v2.AtomicSpecification(**dself) else: assert False, target_version @@ -115,10 +140,6 @@ def __repr_args__(self) -> "ReprArgs": ("molecule_hash", self.initial_molecule.get_hash()[:7]), ] - @validator("schema_version", pre=True) - def _version_stamp(cls, v): - return 1 - def convert_v( self, target_version: int, / ) -> Union["qcelemental.models.v1.OptimizationInput", "qcelemental.models.v2.OptimizationInput"]: @@ -130,11 +151,15 @@ def convert_v( dself = self.dict() if target_version == 2: + dself.pop("schema_version") # changed in v2 dself.pop("hash_index", None) # no longer used, so dropped in v2 + dself["initial_molecule"] = self.initial_molecule.convert_v(target_version) + spec = {} spec["extras"] = dself.pop("extras") - spec["protocols"] = dself.pop("protocols") + dself.pop("protocols") + spec["protocols"] = self.protocols.convert_v(target_version).model_dump() spec["specification"] = self.input_specification.convert_v(target_version).model_dump() dself.pop("input_specification") spec["specification"]["program"] = dself["keywords"].pop( @@ -156,7 +181,7 @@ class OptimizationResult(OptimizationInput): schema_name: constr( # type: ignore strip_whitespace=True, regex=qcschema_optimization_output_default ) = qcschema_optimization_output_default - schema_version: Literal[1] = 1 + # Note no schema_version: Literal[1] = Field(1) b/c inherited from OptimizationInput final_molecule: Optional[Molecule] = Field(..., description="The final molecule of the geometry optimization.") trajectory: List[AtomicResult] = Field( @@ -195,10 +220,6 @@ def _trajectory_protocol(cls, v, values): return v - @validator("schema_version", pre=True) - def _version_stamp(cls, v): - return 1 - def convert_v( self, target_version: int, @@ -229,7 +250,11 @@ def convert_v( if check_convertible_version(target_version, error="OptimizationResult") == "self": return self - trajectory_class = self.trajectory[0].__class__ + try: + trajectory_class = self.trajectory[0].__class__ + except IndexError: + trajectory_class = None + dself = self.dict() if target_version == 2: # remove harmless empty error field that v2 won't accept. if populated, pydantic will catch it. @@ -237,6 +262,8 @@ def convert_v( dself.pop("error") dself.pop("hash_index", None) # no longer used, so dropped in v2 + dself.pop("schema_name") # changed in v2 + dself.pop("schema_version") # changed in v2 v1_input_data = { k: dself.pop(k) @@ -267,7 +294,9 @@ def convert_v( dself["input_data"] = v2_input_data optsubptcl = None + dself["final_molecule"] = self.final_molecule.convert_v(target_version) dself["properties"] = { + "nuclear_repulsion_energy": self.final_molecule.nuclear_repulsion_energy(), "return_energy": dself["energies"][-1], "optimization_iterations": len(dself["energies"]), } @@ -313,15 +342,11 @@ class OptimizationSpecification(ProtoModel): keywords: Dict[str, Any] = Field({}, description="The optimization specific keywords to be used.") protocols: OptimizationProtocols = Field(OptimizationProtocols(), description=str(OptimizationProtocols.__doc__)) - @validator("schema_version", pre=True) - def _version_stamp(cls, v): - return 1 - @validator("procedure") def _check_procedure(cls, v): return v.lower() - # NOTE: def convert_v() is missing deliberately. Because the v1 schema has a minor and different role only for + # NOTE: def convert_v() is missing deliberately. Because the v1 schema has a different role only for # TorsionDrive, it doesn't have nearly enough info to create a v2 schema. @@ -392,10 +417,6 @@ def _check_input_specification(cls, value): assert value.driver == DriverEnum.gradient, "driver must be set to gradient" return value - @validator("schema_version", pre=True) - def _version_stamp(cls, v): - return 1 - def convert_v( self, target_version: int, / ) -> Union["qcelemental.models.v1.TorsionDriveInput", "qcelemental.models.v2.TorsionDriveInput"]: @@ -414,7 +435,8 @@ def convert_v( optspec = {} optspec["program"] = dself["optimization_spec"].pop("procedure") - optspec["protocols"] = dself["optimization_spec"].pop("protocols") + dself["optimization_spec"].pop("protocols") + optspec["protocols"] = self.optimization_spec.protocols.convert_v(target_version).model_dump() optspec["keywords"] = dself["optimization_spec"].pop("keywords") optspec["specification"] = gradspec dself["optimization_spec"].pop("schema_name") @@ -426,11 +448,13 @@ def convert_v( tdspec["program"] = "torsiondrive" tdspec["extras"] = dself.pop("extras") tdspec["keywords"] = dself.pop("keywords") + tdspec["protocols"] = {"scan_results": "all"} tdspec["specification"] = optspec dtop = {} dtop["provenance"] = dself.pop("provenance") - dtop["initial_molecules"] = dself.pop("initial_molecule") + dself.pop("initial_molecule") + dtop["initial_molecule"] = [mol.convert_v(target_version) for mol in self.initial_molecule] dtop["specification"] = tdspec dself.pop("schema_name") dself.pop("schema_version") @@ -452,7 +476,7 @@ class TorsionDriveResult(TorsionDriveInput): """ schema_name: constr(strip_whitespace=True, regex=qcschema_torsion_drive_output_default) = qcschema_torsion_drive_output_default # type: ignore - schema_version: Literal[1] = 1 + # Note no schema_version: Literal[1] = Field(1) b/c inherited from TorsionDriveInput final_energies: Dict[str, float] = Field( ..., description="The final energy at each angle of the TorsionDrive scan." @@ -475,10 +499,6 @@ class TorsionDriveResult(TorsionDriveInput): error: Optional[ComputeError] = Field(None, description=str(ComputeError.__doc__)) provenance: Provenance = Field(..., description=str(Provenance.__doc__)) - @validator("schema_version", pre=True) - def _version_stamp(cls, v): - return 1 - def convert_v( self, target_version: int, /, *, external_input_data: "TorsionDriveInput" = None ) -> Union["qcelemental.models.v1.TorsionDriveResult", "qcelemental.models.v2.TorsionDriveResult"]: @@ -530,8 +550,9 @@ def convert_v( dtop["stderr"] = dself.pop("stderr") dtop["success"] = dself.pop("success") dtop["final_energies"] = dself.pop("final_energies") - dtop["final_molecules"] = dself.pop("final_molecules") - dtop["optimization_history"] = { + dself.pop("final_molecules") + dtop["final_molecules"] = {k: m.convert_v(target_version) for k, m in self.final_molecules.items()} + dtop["scan_results"] = { k: [opthist_class(**res).convert_v(target_version) for res in lst] for k, lst in dself["optimization_history"].items() } diff --git a/qcelemental/models/v1/results.py b/qcelemental/models/v1/results.py index 3f14ca45..1f13671e 100644 --- a/qcelemental/models/v1/results.py +++ b/qcelemental/models/v1/results.py @@ -6,23 +6,17 @@ from pydantic.v1 import Field, constr, validator from ...util import provenance_stamp -from .basemodels import ProtoModel, qcschema_draft +from .basemodels import ProtoModel, check_convertible_version, qcschema_draft from .basis import BasisSet -from .common_models import ( - ComputeError, - DriverEnum, - Model, - Provenance, - check_convertible_version, - qcschema_input_default, - qcschema_output_default, -) +from .common_models import ComputeError, DriverEnum, Model, Provenance, qcschema_input_default, qcschema_output_default from .molecule import Molecule from .types import Array if TYPE_CHECKING: from pydantic.v1.typing import ReprArgs + import qcelemental + class AtomicResultProperties(ProtoModel): r""" @@ -502,6 +496,25 @@ def _assert_exists(cls, v, values): raise ValueError(f"Return quantity {v} does not exist in the values.") return v + def convert_v( + self, target_version: int, / + ) -> Union["qcelemental.models.v1.WavefunctionProperties", "qcelemental.models.v2.WavefunctionProperties"]: + """Convert to instance of particular QCSchema version.""" + import qcelemental as qcel + + if check_convertible_version(target_version, error="Molecule") == "self": + return self + + dself = self.dict() + if target_version == 2: + dself["basis"] = self.basis.convert_v(target_version).model_dump() + + self_vN = qcel.models.v2.WavefunctionProperties(**dself) + else: + assert False, target_version + + return self_vN + class WavefunctionProtocolEnum(str, Enum): r"""Wavefunction to keep from a computation.""" @@ -606,12 +619,6 @@ def __repr_args__(self) -> "ReprArgs": ("molecule_hash", self.molecule.get_hash()[:7]), ] - @validator("schema_version", pre=True) - def _version_stamp(cls, v): - # seemingly unneeded, this lets conver_v re-label the model w/o discarding model and - # submodel version fields first. - return 1 - def convert_v( self, target_version: int, / ) -> Union["qcelemental.models.v1.AtomicInput", "qcelemental.models.v2.AtomicInput"]: @@ -624,10 +631,17 @@ def convert_v( dself = self.dict() if target_version == 2: dself.pop("schema_name") # changes in v2 + dself.pop("schema_version") # changes in v2 + + # TODO consider Model.convert_v + model = dself.pop("model") + if isinstance(self.model.basis, BasisSet): + model["basis"] = self.model.basis.convert_v(target_version) + dself["molecule"] = self.molecule.convert_v(target_version) spec = {} spec["driver"] = dself.pop("driver") - spec["model"] = dself.pop("model") + spec["model"] = model spec["keywords"] = dself.pop("keywords", None) spec["protocols"] = dself.pop("protocols", None) spec["extras"] = dself.pop("extras", None) @@ -681,10 +695,6 @@ def _input_to_output(cls, v): "which will be converted to {0}".format(qcschema_output_default, qcschema_input_default) ) - @validator("schema_version", pre=True) - def _version_stamp(cls, v): - return 1 - @validator("return_result") def _validate_return_result(cls, v, values): if values["driver"] == "gradient": @@ -837,6 +847,9 @@ def convert_v( dself = self.dict() if target_version == 2: dself.pop("schema_name") # changes in v2 + dself.pop("schema_version") # changes in v2 + + molecule = self.molecule.convert_v(target_version) # remove harmless empty error field that v2 won't accept. if populated, pydantic will catch it. if not dself.get("error", True): @@ -846,12 +859,15 @@ def convert_v( "specification": { k: dself.pop(k) for k in list(dself.keys()) if k in ["driver", "keywords", "model", "protocols"] }, - "molecule": dself["molecule"], # duplicate since input mol has been overwritten + "molecule": molecule, # duplicate since input mol has been overwritten } in_extras = { k: dself["extras"].pop(k) for k in list(dself["extras"].keys()) if k in [] } # sep any merged extras known to belong to input input_data["specification"]["extras"] = in_extras + # TODO consider Model.convert_v + if isinstance(self.model.basis, BasisSet): + input_data["specification"]["model"]["basis"] = self.model.basis.convert_v(target_version) # any input provenance has been overwritten # if dself["id"]: @@ -873,6 +889,10 @@ def convert_v( if external_protocols: dself["input_data"]["specification"]["protocols"] = external_protocols + dself["molecule"] = molecule + if self.wavefunction is not None: + dself["wavefunction"] = self.wavefunction.convert_v(target_version).model_dump() + self_vN = qcel.models.v2.AtomicResult(**dself) else: assert False, target_version diff --git a/qcelemental/models/v2/__init__.py b/qcelemental/models/v2/__init__.py index 57dee12d..386ada5a 100644 --- a/qcelemental/models/v2/__init__.py +++ b/qcelemental/models/v2/__init__.py @@ -1,35 +1,39 @@ from . import types from .align import AlignmentMill +from .atomic import ( + AtomicInput, + AtomicProperties, + AtomicProtocols, + AtomicResult, + AtomicSpecification, + WavefunctionProperties, +) from .basemodels import ProtoModel -from .basis import BasisSet -from .common_models import ComputeError, DriverEnum, FailedOperation, Model, Provenance +from .basis_set import BasisCenter, BasisSet, ECPPotential, ElectronShell +from .common_models import DriverEnum, Model, Provenance +from .failed_operation import ComputeError, FailedOperation from .molecule import Molecule -from .procedures import ( +from .optimization import ( OptimizationInput, OptimizationProperties, OptimizationProtocols, OptimizationResult, OptimizationSpecification, - TDKeywords, +) +from .torsion_drive import ( TorsionDriveInput, + TorsionDriveKeywords, + TorsionDriveProtocols, TorsionDriveResult, TorsionDriveSpecification, ) -from .results import ( - AtomicInput, - AtomicResult, - AtomicResultProperties, - AtomicResultProtocols, - AtomicSpecification, - WavefunctionProperties, -) def qcschema_models(): return [ AtomicInput, AtomicResult, - AtomicResultProperties, + AtomicProperties, BasisSet, Molecule, Provenance, diff --git a/qcelemental/models/v2/results.py b/qcelemental/models/v2/atomic.py similarity index 93% rename from qcelemental/models/v2/results.py rename to qcelemental/models/v2/atomic.py index 7b21f84d..32ad335b 100644 --- a/qcelemental/models/v2/results.py +++ b/qcelemental/models/v2/atomic.py @@ -3,39 +3,37 @@ from typing import TYPE_CHECKING, Any, Dict, Literal, Optional, Set, Union import numpy as np -from pydantic import Field, constr, field_validator +from pydantic import Field, field_validator from ...util import provenance_stamp -from .basemodels import ExtendedConfigDict, ProtoModel, qcschema_draft -from .basis import BasisSet -from .common_models import ComputeError, DriverEnum, Model, Provenance, check_convertible_version +from .basemodels import ExtendedConfigDict, ProtoModel, check_convertible_version, qcschema_draft +from .basis_set import BasisSet +from .common_models import DriverEnum, Model, Provenance from .molecule import Molecule from .types import Array if TYPE_CHECKING: + import qcelemental + from .common_models import ReprArgs # ==== Properties ============================================================= -class AtomicResultProperties(ProtoModel): +class AtomicProperties(ProtoModel): r""" Named properties of quantum chemistry computations following the MolSSI QCSchema. All arrays are stored flat but must be reshapable into the dimensions in attribute ``shape``, with abbreviations as follows: - * nao: number of atomic orbitals = :attr:`~qcelemental.models.AtomicResultProperties.calcinfo_nbasis` - * nmo: number of molecular orbitals = :attr:`~qcelemental.models.AtomicResultProperties.calcinfo_nmo` + * nao: number of atomic orbitals = :attr:`~qcelemental.models.AtomicProperties.calcinfo_nbasis` + * nmo: number of molecular orbitals = :attr:`~qcelemental.models.AtomicProperties.calcinfo_nmo` """ schema_name: Literal["qcschema_atomic_properties"] = Field( "qcschema_atomic_properties", description=(f"The QCSchema specification to which this model conforms.") ) - # TRIAL schema_version: Literal[2] = Field( - # TRIAL 2, - # TRIAL description="The version number of :attr:`~qcelemental.models.AtomicResultProperties.schema_name` to which this model conforms.", - # TRIAL ) # ======== Calcinfo ======================================================= @@ -301,10 +299,6 @@ def _validate_derivs(cls, v, info): raise ValueError(f"Derivative must be castable to shape {shape}!") return v - # TRIAL @field_validator("schema_version", mode="before") - # TRIAL def _version_stamp(cls, v): - # TRIAL return 2 - def dict(self, *args, **kwargs): # pure-json dict repr for QCFractal compliance, see https://github.com/MolSSI/QCFractal/issues/579 # Sep 2021: commenting below for now to allow recomposing AtomicResult.properties for qcdb. @@ -519,6 +513,9 @@ class WavefunctionProperties(ProtoModel): None, description="Index to the beta-spin orbital occupations of the primary return." ) + # Note that serializing WfnProp skips unset fields (and indeed the validator will error upon None values) + # while including all fields for the submodel BasisSet. This is the right behavior, imo, but note that + # v1 skips unset fields in BasisSet as well as the top-level model. model_config = ProtoModel._merge_config_with(force_skip_defaults=True) @field_validator("scf_eigenvalues_a", "scf_eigenvalues_b", "scf_occupations_a", "scf_occupations_b") @@ -588,6 +585,25 @@ def _assert_exists(cls, v, info): raise ValueError(f"Return quantity {v} does not exist in the values.") return v + def convert_v( + self, target_version: int, / + ) -> Union["qcelemental.models.v1.WavefunctionProperties", "qcelemental.models.v2.WavefunctionProperties"]: + """Convert to instance of particular QCSchema version.""" + import qcelemental as qcel + + if check_convertible_version(target_version, error="WavefunctionProperties") == "self": + return self + + dself = self.model_dump() + if target_version == 1: + dself["basis"] = self.basis.convert_v(target_version).dict() + + self_vN = qcel.models.v1.WavefunctionProperties(**dself) + else: + assert False, target_version + + return self_vN + # ==== Protocols ============================================================== @@ -632,7 +648,7 @@ class NativeFilesProtocolEnum(str, Enum): none = "none" -class AtomicResultProtocols(ProtoModel): +class AtomicProtocols(ProtoModel): r"""Protocols regarding the manipulation of computational result data.""" schema_name: Literal["qcschema_atomic_protocols"] = "qcschema_atomic_protocols" @@ -659,19 +675,16 @@ class AtomicSpecification(ProtoModel): """Specification for a single point QC calculation""" schema_name: Literal["qcschema_atomic_specification"] = "qcschema_atomic_specification" - # schema_version: Literal[2] = Field( - # 2, - # description="The version number of ``schema_name`` to which this model conforms.", - # ) + keywords: Dict[str, Any] = Field({}, description="The program specific keywords to be used.") program: str = Field( "", description="The program for which the Specification is intended." ) # TODO interaction with cmdline driver: DriverEnum = Field(..., description=DriverEnum.__doc__) model: Model = Field(..., description=Model.__doc__) - protocols: AtomicResultProtocols = Field( - AtomicResultProtocols(), - description=AtomicResultProtocols.__doc__, + protocols: AtomicProtocols = Field( + AtomicProtocols(), + description=AtomicProtocols.__doc__, ) extras: Dict[str, Any] = Field( {}, @@ -696,7 +709,8 @@ def convert_v( loss_store["program"] = dself.pop("program") if loss_store: - dself["extras"]["_qcsk_conversion_loss"] = loss_store + pass + # TODO dself["extras"]["_qcsk_conversion_loss"] = loss_store self_vN = qcel.models.v1.QCInputSpecification(**dself) else: @@ -742,12 +756,6 @@ def __repr_args__(self) -> "ReprArgs": ("molecule_hash", self.molecule.get_hash()[:7]), ] - @field_validator("schema_version", mode="before") - def _version_stamp(cls, v): - # seemingly unneeded, this lets conver_v re-label the model w/o discarding model and - # submodel version fields first. - return 2 - def convert_v( self, target_version: int, / ) -> Union["qcelemental.models.v1.AtomicInput", "qcelemental.models.v2.AtomicInput"]: @@ -760,9 +768,16 @@ def convert_v( dself = self.model_dump() if target_version == 1: dself.pop("schema_name") + dself.pop("schema_version") + # TODO consider Model.convert_v + model = dself["specification"].pop("model") + if isinstance(self.specification.model.basis, BasisSet): + model["basis"] = self.specification.model.basis.convert_v(target_version) + + dself["molecule"] = self.molecule.convert_v(target_version).model_dump() dself["driver"] = dself["specification"].pop("driver") - dself["model"] = dself["specification"].pop("model") + dself["model"] = model dself["keywords"] = dself["specification"].pop("keywords", None) dself["protocols"] = dself["specification"].pop("protocols", None) dself["extras"] = dself["specification"].pop("extras", {}) @@ -784,8 +799,8 @@ def convert_v( class AtomicResult(ProtoModel): r"""Results from a CMS program execution.""" - schema_name: Literal["qcschema_atomic_output"] = Field( - "qcschema_atomic_output", description=(f"The QCSchema specification to which this model conforms.") + schema_name: Literal["qcschema_atomic_result"] = Field( + "qcschema_atomic_result", description=(f"The QCSchema specification to which this model conforms.") ) schema_version: Literal[2] = Field( 2, @@ -794,7 +809,7 @@ class AtomicResult(ProtoModel): id: Optional[str] = Field(None, description="The optional ID for the computation.") input_data: AtomicInput = Field(..., description=str(AtomicInput.__doc__)) molecule: Molecule = Field(..., description="The molecule with frame and orientation of the results.") - properties: AtomicResultProperties = Field(..., description=str(AtomicResultProperties.__doc__)) + properties: AtomicProperties = Field(..., description=str(AtomicProperties.__doc__)) wavefunction: Optional[WavefunctionProperties] = Field(None, description=str(WavefunctionProperties.__doc__)) return_result: Union[float, Array[float], Dict[str, Any]] = Field( @@ -818,10 +833,6 @@ class AtomicResult(ProtoModel): description="Additional information to bundle with the computation. Use for schema development and scratch space.", ) - @field_validator("schema_version", mode="before") - def _version_stamp(cls, v): - return 2 - @field_validator("return_result") @classmethod def _validate_return_result(cls, v, info): @@ -965,12 +976,16 @@ def convert_v( dself = self.model_dump() if target_version == 1: dself.pop("schema_name") + dself.pop("schema_version") # for input_data, work from model, not dict, to use convert_v dself.pop("input_data") - input_data = self.input_data.convert_v(1).model_dump() # exclude_unset=True, exclude_none=True + input_data = self.input_data.convert_v(target_version).model_dump() # exclude_unset=True, exclude_none=True input_data.pop("molecule", None) # discard input_data.pop("provenance", None) # discard + if self.wavefunction is not None: + dself["wavefunction"] = self.wavefunction.convert_v(target_version).model_dump() + dself["molecule"] = self.molecule.convert_v(target_version) dself["extras"] = {**input_data.pop("extras", {}), **dself.pop("extras", {})} # merge dself = {**input_data, **dself} diff --git a/qcelemental/models/v2/basemodels.py b/qcelemental/models/v2/basemodels.py index 8e4b5d9c..475dffe4 100644 --- a/qcelemental/models/v2/basemodels.py +++ b/qcelemental/models/v2/basemodels.py @@ -261,7 +261,7 @@ def compare(self, other: Union["ProtoModel", BaseModel], **kwargs) -> bool: bool True if the objects match. """ - from ..testing import compare_recursive + from ...testing import compare_recursive return compare_recursive(self, other, **kwargs) @@ -282,4 +282,15 @@ def _merge_config_with(cls, *args, **kwargs): return ExtendedConfigDict(**output_dict) +def check_convertible_version(ver: int, error: str): + """Standardize the version/error handling for v2 QCSchema.""" + + if ver == 1: + return True + elif ver == 2: + return "self" + else: + raise ValueError(f"QCSchema {error} version={ver} does not exist for conversion.") + + qcschema_draft = "http://json-schema.org/draft-04/schema#" diff --git a/qcelemental/models/v2/basis.py b/qcelemental/models/v2/basis_set.py similarity index 89% rename from qcelemental/models/v2/basis.py rename to qcelemental/models/v2/basis_set.py index 65296461..a6130a5d 100644 --- a/qcelemental/models/v2/basis.py +++ b/qcelemental/models/v2/basis_set.py @@ -1,11 +1,14 @@ from enum import Enum -from typing import Dict, List, Literal, Optional +from typing import TYPE_CHECKING, Dict, List, Literal, Optional, Union from pydantic import Field, constr, field_validator from typing_extensions import Annotated from ...exceptions import ValidationError -from .basemodels import ProtoModel, qcschema_draft +from .basemodels import ProtoModel, check_convertible_version, qcschema_draft + +if TYPE_CHECKING: + import qcelemental NonnegativeInt = Annotated[int, Field(ge=0)] @@ -167,9 +170,8 @@ class BasisSet(ProtoModel): A quantum chemistry basis description. """ - schema_name: constr(strip_whitespace=True, pattern="^(qcschema_basis)$") = Field( # type: ignore - "qcschema_basis", - description=f"The QCSchema specification to which this model conforms. Explicitly fixed as qcschema_basis.", + schema_name: Literal["qcschema_basis_set"] = Field( + "qcschema_basis_set", description=(f"The QCSchema specification to which this model conforms.") ) schema_version: Literal[2] = Field( # type: ignore 2, @@ -246,6 +248,22 @@ def _calculate_nbf(cls, atom_map, center_data) -> int: return ret - @field_validator("schema_version", mode="before") - def _version_stamp(cls, v): - return 2 + def convert_v( + self, target_version: int, / + ) -> Union["qcelemental.models.v1.BasisSet", "qcelemental.models.v2.BasisSet"]: + """Convert to instance of particular QCSchema version.""" + import qcelemental as qcel + + if check_convertible_version(target_version, error="BasisSet") == "self": + return self + + dself = self.model_dump() + if target_version == 1: + dself.pop("schema_name") # changes in v1 + dself.pop("schema_version") # changes in v1 + + self_vN = qcel.models.v1.BasisSet(**dself) + else: + assert False, target_version + + return self_vN diff --git a/qcelemental/models/v2/common_models.py b/qcelemental/models/v2/common_models.py index 81863171..94f4b513 100644 --- a/qcelemental/models/v2/common_models.py +++ b/qcelemental/models/v2/common_models.py @@ -5,7 +5,7 @@ from pydantic import Field, field_validator from .basemodels import ProtoModel, qcschema_draft -from .basis import BasisSet +from .basis_set import BasisSet if TYPE_CHECKING: ReprArgs = Sequence[Tuple[Optional[str], Any]] @@ -63,111 +63,3 @@ def derivative_int(self): return 0 else: return egh.index(self) - - -class ComputeError(ProtoModel): - """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 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. This is often the backtrace, but it can contain additional " - "information as well.", - ) - extras: Optional[Dict[str, Any]] = Field( # type: ignore - None, - description="Additional information to bundle with the error.", - ) - - model_config = ProtoModel._merge_config_with(repr_style=["error_type", "error_message"]) - - def __repr_args__(self) -> "ReprArgs": - return [("error_type", self.error_type), ("error_message", self.error_message)] - - -class FailedOperation(ProtoModel): - """ - Record indicating that a given operation (program, procedure, etc.) has failed - and containing the reason and input data which generated the failure. - """ - - schema_name: Literal["qcschema_failed_operation"] = Field( - "qcschema_failed_operation", - description=( - f"The QCSchema specification this model conforms to. Explicitly fixed as qcschema_failed_operation." - ), - ) - schema_version: Literal[2] = Field( - 2, - description="The version number of :attr:`~qcelemental.models.FailedOperation.schema_name` to which this model conforms.", - ) - id: Optional[str] = Field( # type: ignore - None, - description="A unique identifier which links this FailedOperation, often of the same Id of the operation " - "should it have been successful. This will often be set programmatically by a database such as " - "Fractal.", - ) - input_data: Any = Field( # type: ignore - None, - description="The input data which was passed in that generated this failure. This should be the complete " - "input which when attempted to be run, caused the operation to fail.", - ) - success: Literal[False] = Field( # type: ignore - False, - description="A boolean indicator that the operation failed consistent with the model of successful operations. " - "Should always be False. Allows programmatic assessment of all operations regardless of if they failed or " - "succeeded", - ) - error: ComputeError = Field( # type: ignore - ..., - description="A container which has details of the error that failed this operation. See the " - ":class:`ComputeError` for more details.", - ) - extras: Optional[Dict[str, Any]] = Field( # type: ignore - {}, - 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.", - ) - - def __repr_args__(self) -> "ReprArgs": - return [("error", self.error)] - - @field_validator("schema_version", mode="before") - def _version_stamp(cls, v): - return 2 - - def convert_v( - self, target_version: int, / - ) -> Union["qcelemental.models.v1.FailedOperation", "qcelemental.models.v2.FailedOperation"]: - """Convert to instance of particular QCSchema version.""" - import qcelemental as qcel - - if check_convertible_version(target_version, error="FailedOperation") == "self": - return self - - dself = self.model_dump() - if target_version == 1: - dself.pop("schema_name") - dself.pop("schema_version") - - self_vN = qcel.models.v1.FailedOperation(**dself) - else: - assert False, target_version - - return self_vN - - -def check_convertible_version(ver: int, error: str): - if ver == 1: - return True - elif ver == 2: - return "self" - else: - raise ValueError(f"QCSchema {error} version={version} does not exist for conversion.") - - -qcschema_molecule_default = "qcschema_molecule" diff --git a/qcelemental/models/v2/failed_operation.py b/qcelemental/models/v2/failed_operation.py new file mode 100644 index 00000000..c02adfcb --- /dev/null +++ b/qcelemental/models/v2/failed_operation.py @@ -0,0 +1,102 @@ +from typing import TYPE_CHECKING, Any, Dict, Literal, Optional, Sequence, Tuple, Union + +from pydantic import Field, field_validator + +from .basemodels import ProtoModel, check_convertible_version + +if TYPE_CHECKING: + import qcelemental + + ReprArgs = Sequence[Tuple[Optional[str], Any]] + + +class ComputeError(ProtoModel): + """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 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. This is often the backtrace, but it can contain additional " + "information as well.", + ) + extras: Optional[Dict[str, Any]] = Field( # type: ignore + None, + description="Additional information to bundle with the error.", + ) + + model_config = ProtoModel._merge_config_with(repr_style=["error_type", "error_message"]) + + def __repr_args__(self) -> "ReprArgs": + return [("error_type", self.error_type), ("error_message", self.error_message)] + + +class FailedOperation(ProtoModel): + """ + Record indicating that a given operation (program, procedure, etc.) has failed + and containing the reason and input data which generated the failure. + """ + + schema_name: Literal["qcschema_failed_operation"] = Field( + "qcschema_failed_operation", + description=( + f"The QCSchema specification this model conforms to. Explicitly fixed as qcschema_failed_operation." + ), + ) + schema_version: Literal[2] = Field( + 2, + description="The version number of :attr:`~qcelemental.models.FailedOperation.schema_name` to which this model conforms.", + ) + id: Optional[str] = Field( # type: ignore + None, + description="A unique identifier which links this FailedOperation, often of the same Id of the operation " + "should it have been successful. This will often be set programmatically by a database such as " + "Fractal.", + ) + input_data: Any = Field( # type: ignore + None, + description="The input data which was passed in that generated this failure. This should be the complete " + "input which when attempted to be run, caused the operation to fail.", + ) + success: Literal[False] = Field( # type: ignore + False, + description="A boolean indicator that the operation failed consistent with the model of successful operations. " + "Should always be False. Allows programmatic assessment of all operations regardless of if they failed or " + "succeeded", + ) + error: ComputeError = Field( # type: ignore + ..., + description="A container which has details of the error that failed this operation. See the " + ":class:`ComputeError` for more details.", + ) + extras: Optional[Dict[str, Any]] = Field( # type: ignore + {}, + 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.", + ) + + def __repr_args__(self) -> "ReprArgs": + return [("error", self.error)] + + def convert_v( + self, target_version: int, / + ) -> Union["qcelemental.models.v1.FailedOperation", "qcelemental.models.v2.FailedOperation"]: + """Convert to instance of particular QCSchema version.""" + import qcelemental as qcel + + if check_convertible_version(target_version, error="FailedOperation") == "self": + return self + + dself = self.model_dump() + if target_version == 1: + dself.pop("schema_name") + dself.pop("schema_version") + + self_vN = qcel.models.v1.FailedOperation(**dself) + else: + assert False, target_version + + return self_vN diff --git a/qcelemental/models/v2/molecule.py b/qcelemental/models/v2/molecule.py index e2fe6f18..88ea2edb 100644 --- a/qcelemental/models/v2/molecule.py +++ b/qcelemental/models/v2/molecule.py @@ -8,7 +8,7 @@ import warnings from functools import partial from pathlib import Path -from typing import TYPE_CHECKING, Any, Dict, Iterable, List, Optional, Tuple, Union, cast +from typing import TYPE_CHECKING, Any, Dict, Iterable, List, Literal, Optional, Tuple, Union, cast import numpy as np from pydantic import Field, constr, field_validator, model_serializer @@ -30,11 +30,13 @@ 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, qcschema_draft -from .common_models import Provenance, qcschema_molecule_default +from .basemodels import ProtoModel, check_convertible_version, qcschema_draft +from .common_models import Provenance from .types import Array if TYPE_CHECKING: + import qcelemental + from .common_models import ReprArgs # Rounding quantities for hashing @@ -120,14 +122,11 @@ class Molecule(ProtoModel): """ - schema_name: constr(strip_whitespace=True, pattern="^(qcschema_molecule)$") = Field( # type: ignore - qcschema_molecule_default, - description=( - f"The QCSchema specification to which this model conforms. Explicitly fixed as {qcschema_molecule_default}." - ), + schema_name: Literal["qcschema_molecule"] = Field( + "qcschema_molecule", description=(f"The QCSchema specification to which this model conforms.") ) - schema_version: int = Field( # type: ignore - 2, # TODO Turn to Literal[3] = Field(3) + schema_version: Literal[3] = Field( + 3, description="The version number of :attr:`~qcelemental.models.Molecule.schema_name` to which this model conforms.", ) validated: bool = Field( # type: ignore @@ -370,7 +369,7 @@ def __init__(self, orient: bool = False, validate: Optional[bool] = None, **kwar if validate: kwargs["schema_name"] = kwargs.pop("schema_name", "qcschema_molecule") - kwargs["schema_version"] = kwargs.pop("schema_version", 2) + kwargs["schema_version"] = kwargs.pop("schema_version", 3) # original_keys = set(kwargs.keys()) # revive when ready to revisit sparsity nonphysical = kwargs.pop("nonphysical", False) @@ -403,12 +402,6 @@ def __init__(self, orient: bool = False, validate: Optional[bool] = None, **kwar elif validate or geometry_prep: values["geometry"] = float_prep(values["geometry"], geometry_noise) - @field_validator("schema_version", mode="before") - def _version_stamp(cls, v): - # seemingly unneeded, this lets conver_v re-label the model w/o discarding model and - # submodel version fields first. - return 2 # TODO 3 - @field_validator("geometry") @classmethod def _must_be_3n(cls, v, info): @@ -442,9 +435,7 @@ def _must_be_n_frag(cls, v, info): if "fragments_" in info.data and info.data["fragments_"] is not None: n = len(info.data["fragments_"]) if len(v) != n: - raise ValueError( - "Fragment Charges must be same number of entries as Fragments" - ) + raise ValueError("Fragment Charges must be same number of entries as Fragments") return v @field_validator("fragment_multiplicities_") @@ -926,6 +917,7 @@ def get_molecular_formula(self, order: str = "alphabetical", chgmult: bool = Fal ... ''') >>> two_pentanol_radcat.get_molecular_formula(chgmult=True) 2^C5H12O+ + Notes ----- This includes all atoms in the molecule, including ghost atoms. See :py:meth:`element_composition` to exclude. @@ -997,7 +989,7 @@ def from_data( if dtype in ["string", "psi4", "xyz", "xyz+"]: mol_dict = from_string(data, dtype if dtype != "string" else None) assert isinstance(mol_dict, dict) - input_dict = to_schema(mol_dict["qm"], dtype=2, np_out=True) + input_dict = to_schema(mol_dict["qm"], dtype=3, np_out=True) input_dict = _filter_defaults(input_dict) input_dict["validated"] = True input_dict["_geometry_prep"] = True @@ -1009,7 +1001,7 @@ def from_data( "units": kwargs.pop("units", "Angstrom"), "fragment_separators": kwargs.pop("frags", []), } - input_dict = to_schema(from_arrays(**data), dtype=2, np_out=True) + input_dict = to_schema(from_arrays(**data), dtype=3, np_out=True) input_dict = _filter_defaults(input_dict) input_dict["validated"] = True input_dict["_geometry_prep"] = True @@ -1314,6 +1306,7 @@ def element_composition(self, ifr: int = None, real_only: bool = True) -> Dict[s Notes ----- This excludes ghost atoms by default whereas get_molecular_formula always includes them. + """ if real_only: symbols = [sym * int(real) for sym, real in zip(cast(Iterable[str], self.symbols), self.real)] @@ -1609,6 +1602,27 @@ def scramble( return cmol, {"rmsd": rmsd, "mill": perturbation} + def convert_v( + self, target_version: int, / + ) -> Union["qcelemental.models.v1.Molecule", "qcelemental.models.v2.Molecule"]: + """Convert to instance of particular QCSchema version.""" + import qcelemental as qcel + + if check_convertible_version(target_version, error="Molecule") == "self": + return self + + dself = self.model_dump() + if target_version == 1: + # below is assignment rather than popping so Mol() records as set and future Mol.model_dump() includes the field. + # needed for QCEngine Psi4. + dself["schema_version"] = 2 + + self_vN = qcel.models.v1.Molecule(**dself) + else: + assert False, target_version + + return self_vN + def _filter_defaults(dicary): nat = len(dicary["symbols"]) diff --git a/qcelemental/models/v2/procedures.py b/qcelemental/models/v2/optimization.py similarity index 51% rename from qcelemental/models/v2/procedures.py rename to qcelemental/models/v2/optimization.py index 979e0a6d..2c3bd3cd 100644 --- a/qcelemental/models/v2/procedures.py +++ b/qcelemental/models/v2/optimization.py @@ -1,5 +1,5 @@ from enum import Enum -from typing import TYPE_CHECKING, Any, Dict, List, Literal, Optional, Tuple, Union +from typing import TYPE_CHECKING, Any, Dict, List, Literal, Optional, Union try: from typing import Annotated @@ -7,16 +7,18 @@ # remove when minimum py39 from typing_extensions import Annotated -from pydantic import Field, conlist, constr, field_validator +from pydantic import Field, field_validator from ...util import provenance_stamp -from .basemodels import ExtendedConfigDict, ProtoModel -from .common_models import ComputeError, DriverEnum, Model, Provenance, check_convertible_version +from .atomic import AtomicProperties, AtomicResult, AtomicSpecification +from .basemodels import ExtendedConfigDict, ProtoModel, check_convertible_version +from .common_models import Provenance from .molecule import Molecule -from .results import AtomicResult, AtomicResultProperties, AtomicSpecification from .types import Array if TYPE_CHECKING: + import qcelemental + from .common_models import ReprArgs @@ -40,12 +42,33 @@ class OptimizationProtocols(ProtoModel): """ schema_name: Literal["qcschema_optimization_protocols"] = "qcschema_optimization_protocols" - trajectory: TrajectoryProtocolEnum = Field( - TrajectoryProtocolEnum.all, description=str(TrajectoryProtocolEnum.__doc__) + trajectory_results: TrajectoryProtocolEnum = Field( + TrajectoryProtocolEnum.none, description=str(TrajectoryProtocolEnum.__doc__) ) model_config = ExtendedConfigDict(force_skip_defaults=True) + def convert_v( + self, target_version: int, / + ) -> Union["qcelemental.models.v1.OptimizationProtocols", "qcelemental.models.v2.OptimizationProtocols"]: + """Convert to instance of particular QCSchema version.""" + import qcelemental as qcel + + if check_convertible_version(target_version, error="OptimizationProtocols") == "self": + return self + + dself = self.model_dump() + if target_version == 1: + # serialization is compact, so use model to assure value + dself.pop("trajectory_results", None) + dself["trajectory"] = self.trajectory_results.value + + self_vN = qcel.models.v1.OptimizationProtocols(**dself) + else: + assert False, target_version + + return self_vN + # ==== Inputs (Kw/Spec/In) ==================================================== @@ -58,7 +81,7 @@ class OptimizationProtocols(ProtoModel): ] OptSubProps = Annotated[ - Union[AtomicResultProperties], # , ManyBodyProperties], + Union[AtomicProperties], # , ManyBodyProperties], Field( discriminator="schema_name", description="An abridged single-geometry property set. Either an ordinary atomic/single-point or a many-body properties.", @@ -78,10 +101,6 @@ class OptimizationSpecification(ProtoModel): """Specification for how to run a geometry optimization.""" schema_name: Literal["qcschema_optimization_specification"] = "qcschema_optimization_specification" - # schema_version: Literal[2] = Field( - # 2, - # description="The version number of ``schema_name`` to which this model conforms.", - # ) # right default for program? program: str = Field( @@ -116,6 +135,7 @@ def convert_v( if target_version == 1: dself["procedure"] = dself.pop("program") dself["keywords"]["program"] = dself["specification"].pop("program") + dself["protocols"] = self.protocols.convert_v(target_version) loss_store["extras"] = dself.pop("extras") loss_store["specification"] = dself.pop("specification") @@ -151,10 +171,6 @@ def __repr_args__(self) -> "ReprArgs": ("molecule_hash", self.initial_molecule.get_hash()[:7]), ] - @field_validator("schema_version", mode="before") - def _version_stamp(cls, v): - return 2 - def convert_v( self, target_version: int, / ) -> Union["qcelemental.models.v1.OptimizationInput", "qcelemental.models.v2.OptimizationInput"]: @@ -166,8 +182,13 @@ def convert_v( dself = self.model_dump() if target_version == 1: + dself.pop("schema_version") + + dself["initial_molecule"] = self.initial_molecule.convert_v(target_version) + dself["extras"] = dself["specification"].pop("extras") - dself["protocols"] = dself["specification"].pop("protocols") + dself["specification"].pop("protocols") + dself["protocols"] = self.specification.protocols.convert_v(target_version) dself["keywords"] = dself["specification"].pop("keywords") dself["input_specification"] = self.specification.specification.convert_v(target_version) @@ -198,10 +219,6 @@ class OptimizationProperties(ProtoModel): "qcschema_optimization_properties", description=f"The QCSchema specification to which this model conforms.", ) - # schema_version: Literal[2] = Field( - # 2, - # description="The version number of :attr:`~qcelemental.models.OptimizationProperties.schema_name` to which this model conforms.", - # ) # ======== Calcinfo ======================================================= # ======== Canonical ====================================================== @@ -224,6 +241,8 @@ class OptimizationProperties(ProtoModel): None, description="The number of geometry iterations taken before convergence." ) + final_rms_force: Optional[float] = Field(None, description="The final RMS gradient of the molecule in Eh/Bohr.") + model_config = ProtoModel._merge_config_with(force_skip_defaults=True) @@ -233,7 +252,7 @@ class OptimizationProperties(ProtoModel): class OptimizationResult(ProtoModel): """QCSchema results model for geometry optimization.""" - schema_name: Literal["qcschema_optimization_output"] = "qcschema_optimization_output" # TODO _result? + schema_name: Literal["qcschema_optimization_result"] = "qcschema_optimization_result" schema_version: Literal[2] = Field( 2, description="The version number of ``schema_name`` to which this model conforms.", @@ -245,7 +264,7 @@ class OptimizationResult(ProtoModel): trajectory_results: List[AtomicResult] = Field( ..., description="A list of ordered Result objects for each step in the optimization." ) - trajectory_properties: List[AtomicResultProperties] = Field( + trajectory_properties: List[AtomicProperties] = Field( ..., description="A list of ordered energies and other properties for each step in the optimization." ) @@ -274,7 +293,7 @@ def _trajectory_protocol(cls, v, info): if "input_data" not in info.data: raise ValueError("Input_data was not properly formed.") - keep_enum = info.data["input_data"].specification.protocols.trajectory + keep_enum = info.data["input_data"].specification.protocols.trajectory_results if keep_enum == "all": pass elif keep_enum == "initial_and_final": @@ -290,10 +309,6 @@ def _trajectory_protocol(cls, v, info): return v - @field_validator("schema_version", mode="before") - def _version_stamp(cls, v): - return 2 - def convert_v( self, target_version: int, / ) -> Union["qcelemental.models.v1.OptimizationResult", "qcelemental.models.v2.OptimizationResult"]: @@ -305,7 +320,10 @@ def convert_v( dself = self.model_dump() if target_version == 1: - trajectory_class = self.trajectory_results[0].__class__ + try: + trajectory_class = self.trajectory_results[0].__class__ + except IndexError: + trajectory_class = None # for input_data, work from model, not dict, to use convert_v dself.pop("input_data") @@ -314,6 +332,8 @@ def convert_v( dself.pop("properties") # new in v2 dself.pop("native_files") # new in v2 + dself["final_molecule"] = self.final_molecule.convert_v(target_version) + dself["trajectory"] = [ trajectory_class(**atres).convert_v(target_version) for atres in dself["trajectory_results"] ] @@ -322,250 +342,13 @@ def convert_v( dself.pop("trajectory_properties") dself["extras"] = {**input_data.pop("extras", {}), **dself.pop("extras", {})} # merge + dself = {**input_data, **dself} + dself.pop("schema_name") # changed in v1 + dself.pop("schema_version") # changed in v1 self_vN = qcel.models.v1.OptimizationResult(**dself) else: assert False, target_version return self_vN - - -# ==== Protocols ============================================================== -# ==== Inputs (Kw/Spec/In) ==================================================== - - -class TDKeywords(ProtoModel): - """ - TorsionDriveRecord options - - Notes - ----- - * This class is still provisional and may be subject to removal and re-design. - """ - - schema_name: Literal["qcschema_torsion_drive_keywords"] = Field( - "qcschema_torsion_drive_keywords", - description=f"The QCSchema specification to which this model conforms.", - ) - - dihedrals: List[Tuple[int, int, int, int]] = Field( - ..., - description="The list of dihedrals to select for the TorsionDrive operation. Each entry is a tuple of integers " - "of for particle indices.", - ) - grid_spacing: List[int] = Field( - ..., - description="List of grid spacing for dihedral scan in degrees. Multiple values will be mapped to each " - "dihedral angle.", - ) - dihedral_ranges: Optional[List[Tuple[int, int]]] = Field( - None, - description="A list of dihedral range limits as a pair (lower, upper). " - "Each range corresponds to the dihedrals in input.", - ) - energy_decrease_thresh: Optional[float] = Field( - None, - description="The threshold of the smallest energy decrease amount to trigger activating optimizations from " - "grid point.", - ) - energy_upper_limit: Optional[float] = Field( - None, - description="The threshold if the energy of a grid point that is higher than the current global minimum, to " - "start new optimizations, in unit of a.u. I.e. if energy_upper_limit = 0.05, current global " - "minimum energy is -9.9 , then a new task starting with energy -9.8 will be skipped.", - ) - - -class TorsionDriveSpecification(ProtoModel): - """Specification for how to run a torsion drive scan.""" - - schema_name: Literal["qcschema_torsion_drive_specification"] = "qcschema_torsion_drive_specification" - # schema_version: Literal[2] = Field( - # 2, - # description="The version number of ``schema_name`` to which this model conforms.", - # ) - - program: str = Field( - "", description="Torsion Drive CMS code / QCEngine procedure with which to run the torsion scan." - ) - keywords: TDKeywords = Field(..., description="The torsion drive specific keywords to be used.") - # protocols: TorsionDriveProtocols = Field(TorsionDriveProtocols(), description=str(TorsionDriveProtocols.__doc__)) - extras: Dict[str, Any] = Field( - {}, - description="Additional information to bundle with the computation. Use for schema development and scratch space.", - ) - specification: OptimizationSpecification = Field( - ..., - description="The specification for how to run optimizations for the torsion scan (within this is spec for gradients for the optimization.", - ) - - @field_validator("program") - @classmethod - def _check_procedure(cls, v): - return v.lower() - - # Note: no convert_v() method as TDSpec doesn't have a v1 equivalent - - -class TorsionDriveInput(ProtoModel): - """Inputs for running a torsion drive.""" - - schema_name: Literal["qcschema_torsion_drive_input"] = "qcschema_torsion_drive_input" - schema_version: Literal[2] = Field( - 2, - description="The version number of ``schema_name`` to which this model conforms.", - ) - - id: Optional[str] = None - initial_molecules: conlist(item_type=Molecule, min_length=1) = Field( - ..., description="The starting molecule(s) for the torsion drive." - ) - - specification: TorsionDriveSpecification = Field(..., description=str(TorsionDriveSpecification.__doc__)) - - provenance: Provenance = Field(Provenance(**provenance_stamp(__name__)), description=str(Provenance.__doc__)) - - @field_validator("specification") - @classmethod - def _check_input_specification(cls, value, info): - driver = value.specification.specification.driver - - assert driver == DriverEnum.gradient, "driver must be set to gradient" - return value - - @field_validator("schema_version", mode="before") - def _version_stamp(cls, v): - return 2 - - def convert_v( - self, target_version: int, / - ) -> Union["qcelemental.models.v1.TorsionDriveInput", "qcelemental.models.v2.TorsionDriveInput"]: - """Convert to instance of particular QCSchema version.""" - import qcelemental as qcel - - if check_convertible_version(target_version, error="TorsionDriveInput") == "self": - return self - - dself = self.model_dump() - if target_version == 1: - dself.pop("id") # unused in v1 - dself["extras"] = dself["specification"].pop("extras") - dself["initial_molecule"] = dself.pop("initial_molecules") - dself["keywords"] = dself["specification"].pop("keywords") - dself["keywords"].pop("schema_name") # unused in v1 - - dself["optimization_spec"] = self.specification.specification.convert_v(target_version) - dself["input_specification"] = self.specification.specification.specification.convert_v(target_version) - dself["specification"].pop("specification") - dself["specification"].pop("schema_name") - - td_program = dself["specification"].pop("program") - assert not dself["specification"], dself["specification"] - dself.pop("specification") # now empty - - self_vN = qcel.models.v1.TorsionDriveInput(**dself) - else: - assert False, target_version - - return self_vN - - -# ==== Properties ============================================================= -# ======== Calcinfo ======================================================= -# ======== Canonical ====================================================== - - -# ==== Results ================================================================ - - -class TorsionDriveResult(ProtoModel): - """Results from running a torsion drive.""" - - schema_name: Literal["qcschema_torsion_drive_output"] = "qcschema_torsion_drive_output" - schema_version: Literal[2] = Field( - 2, - description="The version number of ``schema_name`` to which this model conforms.", - ) - id: Optional[str] = Field(None, description="The optional ID for the computation.") - input_data: TorsionDriveInput = Field(..., description=str(TorsionDriveInput.__doc__)) - - # final_energies, final_molecules, optimization_history I'm hoping to refactor into scan_properties and scan_results but need to talk to OpenFF folks - final_energies: Dict[str, float] = Field( - ..., description="The final energy at each angle of the TorsionDrive scan." - ) - final_molecules: Dict[str, Molecule] = Field( - ..., description="The final molecule at each angle of the TorsionDrive scan." - ) - optimization_history: Dict[str, List[OptimizationResult]] = Field( - ..., - description="The map of each angle of the TorsionDrive scan to each optimization computations.", - ) - - stdout: Optional[str] = Field(None, description="The standard output of the program.") - stderr: Optional[str] = Field(None, description="The standard error of the program.") - - # native_files placeholder for when any td programs supply extra files or need an input file. no protocol at present - native_files: Dict[str, Any] = Field({}, description="DSL files.") - - # TODO add properties if a set can be collected - # properties: TorsionDriveProperties = Field(..., description=str(TorsionDriveProperties.__doc__)) - - extras: Dict[str, Any] = Field( - {}, - description="Additional information to bundle with the computation. Use for schema development and scratch space.", - ) - - success: Literal[True] = Field( - True, description="The success of a given programs execution. If False, other fields may be blank." - ) - provenance: Provenance = Field(..., description=str(Provenance.__doc__)) - - @field_validator("schema_version", mode="before") - def _version_stamp(cls, v): - return 2 - - def convert_v( - self, target_version: int, / - ) -> Union["qcelemental.models.v1.TorsionDriveResult", "qcelemental.models.v2.TorsionDriveResult"]: - """Convert to instance of particular QCSchema version.""" - import qcelemental as qcel - - if check_convertible_version(target_version, error="TorsionDriveResult") == "self": - return self - - dself = self.model_dump() - if target_version == 1: - opthist_class = next(iter(self.optimization_history.values()))[0].__class__ - dtop = {} - - # for input_data, work from model, not dict, to use convert_v - dself.pop("input_data") - input_data = self.input_data.convert_v(target_version).model_dump() - - dtop["final_energies"] = dself.pop("final_energies") - dtop["final_molecules"] = dself.pop("final_molecules") - dtop["optimization_history"] = { - k: [opthist_class(**res).convert_v(target_version) for res in lst] - for k, lst in dself["optimization_history"].items() - } - dself.pop("optimization_history") - - dself.pop("id") # unused in v1 - dself.pop("native_files") # new in v2 - dtop["provenance"] = dself.pop("provenance") - dtop["stdout"] = dself.pop("stdout") - dtop["stderr"] = dself.pop("stderr") - dtop["success"] = dself.pop("success") - dtop["extras"] = {**input_data.pop("extras", {}), **dself.pop("extras", {})} # merge - dtop["schema_name"] = dself.pop("schema_name") # otherwise merge below uses TDIn schema_name - dself.pop("schema_version") - assert not dself, dself - - dtop = {**input_data, **dtop} - - self_vN = qcel.models.v1.TorsionDriveResult(**dtop) - else: - assert False, target_version - - return self_vN diff --git a/qcelemental/models/v2/torsion_drive.py b/qcelemental/models/v2/torsion_drive.py new file mode 100644 index 00000000..9657d3c4 --- /dev/null +++ b/qcelemental/models/v2/torsion_drive.py @@ -0,0 +1,307 @@ +from enum import Enum +from typing import TYPE_CHECKING, Any, Dict, List, Literal, Optional, Tuple, Union + +from pydantic import Field, conlist, field_validator + +from ...util import provenance_stamp +from .basemodels import ExtendedConfigDict, ProtoModel, check_convertible_version +from .common_models import DriverEnum, Provenance +from .molecule import Molecule +from .optimization import OptimizationResult, OptimizationSpecification +from .types import Array + +if TYPE_CHECKING: + import qcelemental + + from .common_models import ReprArgs + + +# ==== Protocols ============================================================== + + +class ScanResultsProtocolEnum(str, Enum): + """ + Which gradient evaluations to keep in an optimization trajectory. + """ + + all = "all" # use this if instance might be converted to v1 + lowest = "lowest" # discard any optimizations at each scan point that did not find the lowest energy + none = "none" + + +class TorsionDriveProtocols(ProtoModel): + """ + Protocols regarding the manipulation of a Torsion Drive subcalculation history. + """ + + schema_name: Literal["qcschema_torsion_drive_protocols"] = "qcschema_torsion_drive_protocols" + scan_results: ScanResultsProtocolEnum = Field( + ScanResultsProtocolEnum.none, description=str(ScanResultsProtocolEnum.__doc__) + ) + + model_config = ExtendedConfigDict(force_skip_defaults=True) + + +# ==== Inputs (Kw/Spec/In) ==================================================== + + +class TorsionDriveKeywords(ProtoModel): + """ + TorsionDriveRecord options + + Notes + ----- + * This class is still provisional and may be subject to removal and re-design. + """ + + schema_name: Literal["qcschema_torsion_drive_keywords"] = Field( + "qcschema_torsion_drive_keywords", + description=f"The QCSchema specification to which this model conforms.", + ) + + dihedrals: List[Tuple[int, int, int, int]] = Field( + ..., + description="The list of dihedrals to select for the TorsionDrive operation. Each entry is a tuple of integers " + "of for particle indices.", + ) + grid_spacing: List[int] = Field( + ..., + description="List of grid spacing for dihedral scan in degrees. Multiple values will be mapped to each " + "dihedral angle.", + ) + dihedral_ranges: Optional[List[Tuple[int, int]]] = Field( + None, + description="A list of dihedral range limits as a pair (lower, upper). " + "Each range corresponds to the dihedrals in input.", + ) + energy_decrease_thresh: Optional[float] = Field( + None, + description="The threshold of the smallest energy decrease amount to trigger activating optimizations from " + "grid point.", + ) + energy_upper_limit: Optional[float] = Field( + None, + description="The threshold if the energy of a grid point that is higher than the current global minimum, to " + "start new optimizations, in unit of a.u. I.e. if energy_upper_limit = 0.05, current global " + "minimum energy is -9.9 , then a new task starting with energy -9.8 will be skipped.", + ) + + +class TorsionDriveSpecification(ProtoModel): + """Specification for how to run a torsion drive scan.""" + + schema_name: Literal["qcschema_torsion_drive_specification"] = "qcschema_torsion_drive_specification" + + program: str = Field( + "", description="Torsion Drive CMS code / QCEngine procedure with which to run the torsion scan." + ) + keywords: TorsionDriveKeywords = Field(..., description="The torsion drive specific keywords to be used.") + protocols: TorsionDriveProtocols = Field(TorsionDriveProtocols(), description=str(TorsionDriveProtocols.__doc__)) + extras: Dict[str, Any] = Field( + {}, + description="Additional information to bundle with the computation. Use for schema development and scratch space.", + ) + specification: OptimizationSpecification = Field( + ..., + description="The specification for how to run optimizations for the torsion scan (within this is spec for gradients for the optimization.", + ) + + @field_validator("program") + @classmethod + def _check_procedure(cls, v): + return v.lower() + + # Note: no convert_v() method as TDSpec doesn't have a v1 equivalent + + +class TorsionDriveInput(ProtoModel): + """Inputs for running a torsion drive.""" + + schema_name: Literal["qcschema_torsion_drive_input"] = "qcschema_torsion_drive_input" + schema_version: Literal[2] = Field( + 2, + description="The version number of ``schema_name`` to which this model conforms.", + ) + + id: Optional[str] = None + initial_molecule: conlist(item_type=Molecule, min_length=1) = Field( + ..., description="The starting molecule(s) for the torsion drive." + ) + + specification: TorsionDriveSpecification = Field(..., description=str(TorsionDriveSpecification.__doc__)) + + provenance: Provenance = Field(Provenance(**provenance_stamp(__name__)), description=str(Provenance.__doc__)) + + @field_validator("specification") + @classmethod + def _check_input_specification(cls, value, info): + driver = value.specification.specification.driver + + assert driver == DriverEnum.gradient, "driver must be set to gradient" + return value + + def convert_v( + self, target_version: int, / + ) -> Union["qcelemental.models.v1.TorsionDriveInput", "qcelemental.models.v2.TorsionDriveInput"]: + """Convert to instance of particular QCSchema version.""" + import qcelemental as qcel + + if check_convertible_version(target_version, error="TorsionDriveInput") == "self": + return self + + dself = self.model_dump() + if target_version == 1: + dself.pop("schema_version") # changed in v1 + dself.pop("id") # unused in v1 + dself["extras"] = dself["specification"].pop("extras") + dself["initial_molecule"] = [m.convert_v(target_version) for m in self.initial_molecule] + dself["keywords"] = dself["specification"].pop("keywords") + dself["keywords"].pop("schema_name") # unused in v1 + + dself["optimization_spec"] = self.specification.specification.convert_v(target_version) + dself["input_specification"] = self.specification.specification.specification.convert_v(target_version) + dself["specification"].pop("specification") + dself["specification"].pop("schema_name") + + td_program = dself["specification"].pop("program") + dself["specification"].pop("protocols") # lost + assert not dself["specification"], dself["specification"] + dself.pop("specification") # now empty + + self_vN = qcel.models.v1.TorsionDriveInput(**dself) + else: + assert False, target_version + + return self_vN + + +# ==== Properties ============================================================= +# ======== Calcinfo ======================================================= +# ======== Canonical ====================================================== + + +# ==== Results ================================================================ + + +class TorsionDriveResult(ProtoModel): + """Results from running a torsion drive.""" + + schema_name: Literal["qcschema_torsion_drive_result"] = "qcschema_torsion_drive_result" + schema_version: Literal[2] = Field( + 2, + description="The version number of ``schema_name`` to which this model conforms.", + ) + id: Optional[str] = Field(None, description="The optional ID for the computation.") + input_data: TorsionDriveInput = Field(..., description=str(TorsionDriveInput.__doc__)) + + # final_energies, final_molecules, optimization_history I'm hoping to refactor into scan_properties and scan_results but need to talk to OpenFF folks + final_energies: Dict[str, float] = Field( + ..., description="The final energy at each angle of the TorsionDrive scan." + ) + final_molecules: Dict[str, Molecule] = Field( + ..., description="The final molecule at each angle of the TorsionDrive scan." + ) + scan_results: Dict[str, List[OptimizationResult]] = Field( + ..., + description="The map of each angle of the TorsionDrive scan to each optimization computations.", + ) + + stdout: Optional[str] = Field(None, description="The standard output of the program.") + stderr: Optional[str] = Field(None, description="The standard error of the program.") + + # native_files placeholder for when any td programs supply extra files or need an input file. no protocol at present + native_files: Dict[str, Any] = Field({}, description="DSL files.") + + # TODO add properties if a set can be collected + # properties: TorsionDriveProperties = Field(..., description=str(TorsionDriveProperties.__doc__)) + + extras: Dict[str, Any] = Field( + {}, + description="Additional information to bundle with the computation. Use for schema development and scratch space.", + ) + + success: Literal[True] = Field( + True, description="The success of a given programs execution. If False, other fields may be blank." + ) + provenance: Provenance = Field(..., description=str(Provenance.__doc__)) + + @field_validator("scan_results") + @classmethod + def _scan_protocol(cls, v, info): + # Do not propogate validation errors + if "input_data" not in info.data: + raise ValueError("Input_data was not properly formed.") + + keep_enum = info.data["input_data"].specification.protocols.scan_results + if keep_enum == "all": + pass + elif keep_enum == "lowest": + if not all(len(vv) == 1 for vv in v.values()): + v_trunc = {} + for scan_pt, optres_list in v.items(): + final_energies = [optres.properties.return_energy for optres in optres_list] + lowest_energy_idx = final_energies.index(min(final_energies)) + v_trunc[scan_pt] = [optres_list[lowest_energy_idx]] + v = v_trunc + elif keep_enum == "none": + v = {} + else: + raise ValueError(f"Protocol `scan_results:{keep_enum}` is not understood.") + + return v + + def convert_v( + self, target_version: int, / + ) -> Union["qcelemental.models.v1.TorsionDriveResult", "qcelemental.models.v2.TorsionDriveResult"]: + """Convert to instance of particular QCSchema version. + + Notes + ----- + * Use TorsionDriveProtocols.scan_results=all for full conversion to v1. + + """ + import qcelemental as qcel + + if check_convertible_version(target_version, error="TorsionDriveResult") == "self": + return self + + dself = self.model_dump() + if target_version == 1: + try: + opthist_class = next(iter(self.scan_results.values()))[0].__class__ + except StopIteration: + opthist_class = None + dtop = {} + + # for input_data, work from model, not dict, to use convert_v + dself.pop("input_data") + input_data = self.input_data.convert_v(target_version).model_dump() + input_data.pop("schema_name") # prevent inheriting + + dtop["final_energies"] = dself.pop("final_energies") + dself.pop("final_molecules") + dtop["final_molecules"] = {k: m.convert_v(target_version) for k, m in self.final_molecules.items()} + dtop["optimization_history"] = { + k: [opthist_class(**res).convert_v(target_version) for res in lst] + for k, lst in dself["scan_results"].items() + } + dself.pop("scan_results") + + dself.pop("id") # unused in v1 + dself.pop("native_files") # new in v2 + dtop["provenance"] = dself.pop("provenance") + dtop["stdout"] = dself.pop("stdout") + dtop["stderr"] = dself.pop("stderr") + dtop["success"] = dself.pop("success") + dtop["extras"] = {**input_data.pop("extras", {}), **dself.pop("extras", {})} # merge + dself.pop("schema_name") # otherwise merge below uses TDIn schema_name + dself.pop("schema_version") + assert not dself, dself + + dtop = {**input_data, **dtop} + + self_vN = qcel.models.v1.TorsionDriveResult(**dtop) + else: + assert False, target_version + + return self_vN diff --git a/qcelemental/molparse/from_schema.py b/qcelemental/molparse/from_schema.py index d1775b4d..565593fd 100644 --- a/qcelemental/molparse/from_schema.py +++ b/qcelemental/molparse/from_schema.py @@ -33,6 +33,8 @@ def from_schema(molschema: Dict, *, nonphysical: bool = False, verbose: int = 1) ms = molschema["molecule"] elif molschema.get("schema_name", "").startswith("qcschema_molecule") and molschema.get("schema_version", "") == 2: ms = molschema + elif molschema.get("schema_name", "") == "qcschema_molecule" and molschema.get("schema_version", "") == 3: + ms = molschema else: raise ValidationError( """Schema not recognized, schema_name/schema_version: {}/{} """.format( diff --git a/qcelemental/molparse/to_schema.py b/qcelemental/molparse/to_schema.py index 15ba8dc7..4f1a97db 100644 --- a/qcelemental/molparse/to_schema.py +++ b/qcelemental/molparse/to_schema.py @@ -19,10 +19,11 @@ def to_schema( molrec Psi4 json Molecule spec. dtype - {'psi4', 1, 2} + {'psi4', 1, 2, 3} Molecule schema format. ``1`` is https://molssi-qc-schema.readthedocs.io/en/latest/auto_topology.html V1 + #44 + #53 ``2`` is ``1`` with internal schema_name/version (https://github.com/MolSSI/QCSchema/pull/60) + ``3`` is ``2`` with no change (future: additional field for unit box parameters) units {'Bohr', 'Angstrom'} Units in which to write string. There is not an option to write in @@ -63,7 +64,7 @@ def to_schema( qcschema["units"] = units qcschema["name"] = name - elif dtype in [1, 2]: + elif dtype in [1, 2, 3]: if units != "Bohr": raise ValidationError("""QCSchema {} allows only 'Bohr' coordinates, not {}.""".format(dtype, units)) @@ -98,6 +99,9 @@ def to_schema( elif dtype == 2: qcschema = molecule qcschema.update({"schema_name": "qcschema_molecule", "schema_version": 2}) + elif dtype == 3: + qcschema = molecule + qcschema.update({"schema_name": "qcschema_molecule", "schema_version": 3}) else: raise ValidationError( diff --git a/qcelemental/tests/qcschema_instances/AtomicProperties/dummy b/qcelemental/tests/qcschema_instances/AtomicProperties/dummy new file mode 100644 index 00000000..e69de29b diff --git a/qcelemental/tests/test_model_general.py b/qcelemental/tests/test_model_general.py index 9cfb013f..c9ed3be3 100644 --- a/qcelemental/tests/test_model_general.py +++ b/qcelemental/tests/test_model_general.py @@ -4,7 +4,9 @@ def test_result_properties_default_skip(request, schema_versions): - AtomicResultProperties = schema_versions.AtomicResultProperties + AtomicResultProperties = ( + schema_versions.AtomicProperties if ("v2" in request.node.name) else schema_versions.AtomicResultProperties + ) obj = AtomicResultProperties(scf_one_electron_energy="-5.0") drop_qcsk(obj, request.node.name) @@ -14,8 +16,10 @@ def test_result_properties_default_skip(request, schema_versions): assert obj.dict().keys() == {"scf_one_electron_energy"} -def test_result_properties_default_repr(schema_versions): - AtomicResultProperties = schema_versions.AtomicResultProperties +def test_result_properties_default_repr(request, schema_versions): + AtomicResultProperties = ( + schema_versions.AtomicProperties if ("v2" in request.node.name) else schema_versions.AtomicResultProperties + ) obj = AtomicResultProperties(scf_one_electron_energy="-5.0") assert "none" not in str(obj).lower() diff --git a/qcelemental/tests/test_model_results.py b/qcelemental/tests/test_model_results.py index 5e2c08df..b13d3941 100644 --- a/qcelemental/tests/test_model_results.py +++ b/qcelemental/tests/test_model_results.py @@ -129,7 +129,7 @@ def result_data_fixture(schema_versions, request): @pytest.fixture(scope="function") def wavefunction_data_fixture(result_data_fixture, schema_versions, request): - BasisSet = schema_versions.basis.BasisSet + BasisSet = schema_versions.basis_set.BasisSet if "v2" in request.node.name else schema_versions.basis.BasisSet bas = BasisSet(name="custom_basis", center_data=center_data, atom_map=["bs_sto3g_o", "bs_sto3g_h", "bs_sto3g_h"]) c_matrix = np.random.rand(bas.nbf, bas.nbf) @@ -254,7 +254,7 @@ def torsiondrive_data_fixture(ethane_data_fixture, optimization_data_fixture, re if "v2" in request.node.name: input_data = { - "initial_molecules": [ethane] * 2, + "initial_molecule": [ethane] * 2, "specification": { "keywords": {"dihedrals": [(2, 0, 1, 5)], "grid_spacing": [180]}, "specification": { @@ -294,7 +294,7 @@ def torsiondrive_data_fixture(ethane_data_fixture, optimization_data_fixture, re "provenance": {"creator": "qcel"}, "final_energies": {"180": -2.3, "0": -4.5}, "final_molecules": {"180": ethane, "0": ethane}, - "optimization_history": {"180": [optres, optres], "0": [optres]}, + "scan_results": {"180": [optres, optres], "0": [optres]}, } else: ret = { @@ -350,13 +350,13 @@ def manybody_data_fixture(): @pytest.mark.parametrize("center_name", center_data.keys()) def test_basis_shell_centers(center_name, schema_versions): - BasisCenter = schema_versions.basis.BasisCenter + BasisCenter = schema_versions.BasisCenter assert BasisCenter(**center_data[center_name]) def test_basis_set_build(request, schema_versions): - BasisSet = schema_versions.basis.BasisSet + BasisSet = schema_versions.BasisSet bas = BasisSet( name="custom_basis", @@ -378,8 +378,12 @@ def test_basis_set_build(request, schema_versions): assert es[0].coefficients == [[0.15432899, 0.53532814, 0.44463454]] -def test_basis_electron_center_raises(schema_versions): - ElectronShell = schema_versions.basis.ElectronShell +def test_basis_electron_center_raises(schema_versions, request): + # define both ways just to check imports + ElectronShell = ( + schema_versions.basis_set.ElectronShell if "v2" in request.node.name else schema_versions.basis.ElectronShell + ) + ElectronShell = schema_versions.ElectronShell data = center_data["bs_sto3g_h"]["electron_shells"][0].copy() @@ -400,8 +404,8 @@ def test_basis_electron_center_raises(schema_versions): assert "fused shell" in str(e.value) -def test_basis_ecp_center_raises(schema_versions): - basis = schema_versions.basis +def test_basis_ecp_center_raises(schema_versions, request): + basis = schema_versions.basis_set if "v2" in request.node.name else schema_versions.basis # Check coefficients data = center_data["bs_def2tzvp_zr"]["ecp_potentials"][0].copy() @@ -419,7 +423,7 @@ def test_basis_ecp_center_raises(schema_versions): def test_basis_map_raises(schema_versions): - BasisSet = schema_versions.basis.BasisSet + BasisSet = schema_versions.BasisSet with pytest.raises(ValueError) as e: assert BasisSet(name="custom_basis", center_data=center_data, atom_map=["something_odd"]) @@ -596,9 +600,11 @@ def test_optimization_trajectory_protocol(keep, indices, optimization_data_fixtu if keep is not None: if "v2" in request.node.name: - optimization_data_fixture["input_data"]["specification"]["protocols"] = {"trajectory": keep} + optimization_data_fixture["input_data"]["specification"]["protocols"] = {"trajectory_results": keep} else: optimization_data_fixture["protocols"] = {"trajectory": keep} + if "v2" in request.node.name and keep is None: + indices = [] # default has changed in v2 opt = OptimizationResult(**optimization_data_fixture) trajs_target = opt.trajectory_results if "v2" in request.node.name else opt.trajectory @@ -633,8 +639,12 @@ def test_error_correction_protocol( assert base.protocols.error_correction.policies == defined_result -def test_error_correction_logic(schema_versions): - ErrorCorrectionProtocol = schema_versions.results.ErrorCorrectionProtocol +def test_error_correction_logic(schema_versions, request): + ErrorCorrectionProtocol = ( + schema_versions.atomic.ErrorCorrectionProtocol + if ("v2" in request.node.name) + else schema_versions.results.ErrorCorrectionProtocol + ) # Make sure we are permissive by default correction_policy = ErrorCorrectionProtocol() @@ -706,7 +716,9 @@ def test_failed_operation(result_data_fixture, request, schema_versions): def test_result_properties_array(request, schema_versions): - AtomicResultProperties = schema_versions.AtomicResultProperties + AtomicResultProperties = ( + schema_versions.AtomicProperties if ("v2" in request.node.name) else schema_versions.AtomicResultProperties + ) lquad = [1, 2, 3, 2, 4, 5, 3, 5, 6] @@ -728,7 +740,9 @@ def test_result_properties_array(request, schema_versions): def test_result_derivatives_array(request, schema_versions): - AtomicResultProperties = schema_versions.AtomicResultProperties + AtomicResultProperties = ( + schema_versions.AtomicProperties if ("v2" in request.node.name) else schema_versions.AtomicResultProperties + ) nat = 4 lgrad = list(range(nat * 3)) @@ -767,7 +781,7 @@ def every_model_fixture(request): } datas[smodel] = data - smodel = "AtomicInput" + smodel = "AtomicInput-A" data = request.getfixturevalue("result_data_fixture") if "v2" in request.node.name: data = data["input_data"] @@ -775,26 +789,51 @@ def every_model_fixture(request): data = {k: data[k] for k in ["molecule", "model", "driver"]} datas[smodel] = data - smodel = "QCInputSpecification" # TODO "AtomicSpecification" + smodel = "AtomicInput-B" + data = copy.deepcopy(request.getfixturevalue("result_data_fixture")) + basB = {"name": "custom_basis", "center_data": center_data, "atom_map": ["bs_sto3g_o", "bs_sto3g_h", "bs_sto3g_h"]} + if "v2" in request.node.name: + data = data["input_data"] + data["specification"]["model"]["basis"] = basB + else: + data = {k: data[k] for k in ["molecule", "model", "driver"]} + data["model"]["basis"] = basB + datas[smodel] = data + + smodel = "QCInputSpecification" data = {"driver": "hessian", "model": {"basis": "def2-svp", "method": "CC"}} datas[smodel] = data smodel = "AtomicSpecification" datas[smodel] = data - smodel = "AtomicResultProtocols" # TODO "AtomicProtocols" + smodel = "AtomicResultProtocols" data = {"wavefunction": "occupations_and_eigenvalues"} datas[smodel] = data + datas["AtomicProtocols"] = copy.deepcopy(data) - smodel = "AtomicResult" - data = request.getfixturevalue("result_data_fixture") + smodel = "AtomicResult-A" + data = copy.deepcopy(request.getfixturevalue("result_data_fixture")) datas[smodel] = data - smodel = "AtomicResultProperties" # TODO "AtomicProperties" + smodel = "AtomicResult-B" + data = copy.deepcopy(request.getfixturevalue("result_data_fixture")) + if "v2" in request.node.name: + data["input_data"]["specification"]["model"]["basis"] = basB + else: + data["model"]["basis"] = basB + datas[smodel] = data + + smodel = "AtomicResult-C" + data = copy.deepcopy(request.getfixturevalue("wavefunction_data_fixture")) + datas[smodel] = data + + smodel = "AtomicResultProperties" data = {"scf_one_electron_energy": "-5.0", "scf_dipole_moment": [1, 2, 3], "ccsd_dipole_moment": None} datas[smodel] = data + datas["AtomicProperties"] = copy.deepcopy(data) smodel = "WavefunctionProperties" - data = request.getfixturevalue("wavefunction_data_fixture") + data = copy.deepcopy(request.getfixturevalue("wavefunction_data_fixture")) data = data["wavefunction"] datas[smodel] = data @@ -815,7 +854,10 @@ def every_model_fixture(request): datas[smodel] = data smodel = "OptimizationProtocols" - data = {"trajectory": "initial_and_final"} + if "v2" in request.node.name: + data = {"trajectory_results": "initial_and_final"} + else: + data = {"trajectory": "initial_and_final"} datas[smodel] = data smodel = "OptimizationResult" @@ -842,11 +884,14 @@ def every_model_fixture(request): data = {} # DNE datas[smodel] = data - smodel = "TDKeywords" # TODO "TorsionDriveKeywords" + smodel = "TDKeywords" data = {"dihedrals": [(2, 0, 1, 5)], "grid_spacing": [180]} datas[smodel] = data + datas["TorsionDriveKeywords"] = copy.deepcopy(data) - # smodel = "TorsionDriveProtocols" # DNE + smodel = "TorsionDriveProtocols" + data = {"scan_results": "lowest"} + datas[smodel] = data smodel = "TorsionDriveResult" data = request.getfixturevalue("torsiondrive_data_fixture") @@ -891,11 +936,14 @@ def every_model_fixture(request): pytest.param("Molecule-B", "Molecule-B", id="Mol-B"), pytest.param("BasisSet", "BasisSet", id="BasisSet"), pytest.param("FailedOperation", "FailedOperation", id="FailedOp"), - pytest.param("AtomicInput", "AtomicInput", id="AtIn"), + pytest.param("AtomicInput-A", "AtomicInput-A", id="AtIn-A"), # str basis + pytest.param("AtomicInput-B", "AtomicInput-B", id="AtIn-B"), # model basis pytest.param("QCInputSpecification", "AtomicSpecification", id="AtSpec"), - pytest.param("AtomicResultProtocols", "AtomicResultProtocols", id="AtPtcl"), # TODO AtomicProtocols - pytest.param("AtomicResult", "AtomicResult", id="AtRes"), - pytest.param("AtomicResultProperties", "AtomicResultProperties", id="AtProp"), # TODO AtomicProperties + pytest.param("AtomicResultProtocols", "AtomicProtocols", id="AtPtcl"), + pytest.param("AtomicResult-A", "AtomicResult-A", id="AtRes-A"), # str basis + pytest.param("AtomicResult-B", "AtomicResult-B", id="AtRes-B"), # model basis + pytest.param("AtomicResult-C", "AtomicResult-C", id="AtRes-C"), # wfn + pytest.param("AtomicResultProperties", "AtomicProperties", id="AtProp"), pytest.param("WavefunctionProperties", "WavefunctionProperties", id="WfnProp"), pytest.param("OptimizationInput", "OptimizationInput", id="OptIn"), pytest.param("OptimizationSpecification", "OptimizationSpecification", id="OptSpec"), @@ -904,8 +952,8 @@ def every_model_fixture(request): pytest.param(None, "OptimizationProperties", id="OptProp"), pytest.param("TorsionDriveInput", "TorsionDriveInput", id="TDIn"), pytest.param(None, "TorsionDriveSpecification", id="TDSpec"), - pytest.param("TDKeywords", "TDKeywords", id="TDKw"), # TODO TorsionDriveKeywords - # pytest.param(None, "TorsionDriveProtocols", id="TDPtcl"), + pytest.param("TDKeywords", "TorsionDriveKeywords", id="TDKw"), + pytest.param(None, "TorsionDriveProtocols", id="TDPtcl"), pytest.param("TorsionDriveResult", "TorsionDriveResult", id="TDRes"), # pytest.param(None, "TorsionDriveProperties", id="TDProp"), pytest.param("ManyBodyInput", None, id="MBIn", marks=using_qcmb), @@ -927,10 +975,13 @@ def test_model_survey_success(smodel1, smodel2, every_model_fixture, request, sc "v1-Mol-B" : None, "v2-Mol-B" : None, "v1-BasisSet" : None, "v2-BasisSet" : None, "v1-FailedOp" : False, "v2-FailedOp" : False, - "v1-AtIn" : None, "v2-AtIn" : None, + "v1-AtIn-A" : None, "v2-AtIn-A" : None, + "v1-AtIn-B" : None, "v2-AtIn-B" : None, "v1-AtSpec" : None, "v2-AtSpec" : None, "v1-AtPtcl" : None, "v2-AtPtcl" : None, - "v1-AtRes" : True, "v2-AtRes" : True, + "v1-AtRes-A" : True, "v2-AtRes-A" : True, + "v1-AtRes-B" : True, "v2-AtRes-B" : True, + "v1-AtRes-C" : True, "v2-AtRes-C" : True, "v1-AtProp" : None, "v2-AtProp" : None, "v1-WfnProp" : None, "v2-WfnProp" : None, "v1-OptIn" : None, "v2-OptIn" : None, @@ -1010,24 +1061,27 @@ def test_model_survey_schema_version(smodel1, smodel2, every_model_fixture, requ # fmt: off ans = { # v2: In/Res + Mol/BasisSet/FailedOp, yes! Kw/Ptcl, no. Prop/Spec uncertain. - "v1-Mol-A" : 2, "v2-Mol-A" : 2, # TODO 3 - "v1-Mol-B" : 2, "v2-Mol-B" : 2, # TODO 3 - "v1-BasisSet" : 1, "v2-BasisSet" : 2, # TODO change for v2? + "v1-Mol-A" : 2, "v2-Mol-A" : 3, + "v1-Mol-B" : 2, "v2-Mol-B" : 3, + "v1-BasisSet" : 1, "v2-BasisSet" : 2, "v1-FailedOp" : None, "v2-FailedOp" : 2, - "v1-AtIn" : 1, "v2-AtIn" : 2, - "v1-AtSpec" : 1, "v2-AtSpec" : None, # WAS 1, # TODO 2 + "v1-AtIn-A" : 1, "v2-AtIn-A" : 2, + "v1-AtIn-B" : 1, "v2-AtIn-B" : 2, + "v1-AtSpec" : 1, "v2-AtSpec" : None, "v1-AtPtcl" : None, "v2-AtPtcl" : None, - "v1-AtRes" : 1, "v2-AtRes" : 2, - "v1-AtProp" : None, "v2-AtProp" : None, # WAS 2, - "v1-WfnProp" : None, "v2-WfnProp" : None, # TODO 2 + "v1-AtRes-A" : 1, "v2-AtRes-A" : 2, + "v1-AtRes-B" : 1, "v2-AtRes-B" : 2, + "v1-AtRes-C" : 1, "v2-AtRes-C" : 2, + "v1-AtProp" : None, "v2-AtProp" : None, + "v1-WfnProp" : None, "v2-WfnProp" : None, "v1-OptIn" : 1, "v2-OptIn" : 2, - "v1-OptSpec" : 1, "v2-OptSpec" : None, # WAS 1, # TODO 2 + "v1-OptSpec" : 1, "v2-OptSpec" : None, "v1-OptPtcl" : None, "v2-OptPtcl" : None, "v1-OptRes" : 1, "v2-OptRes" : 2, - "v1-OptProp" : None, "v2-OptProp" : None, # WAS 2, # v1 DNE + "v1-OptProp" : None, "v2-OptProp" : None, # v1 DNE "v1-TDIn" : 1, "v2-TDIn" : 2, "v1-TDSpec" : None, "v2-TDSpec" : None, # v1 DNE - "v1-TDKw" : None, "v2-TDKw" : None, # TODO 2 + "v1-TDKw" : None, "v2-TDKw" : None, "v1-TDPtcl" : None, "v2-TDPtcl" : None, # v1 DNE "v1-TDRes" : 1, "v2-TDRes" : 2, "v1-TDProp" : None, "v2-TDProp" : None, # v1 DNE @@ -1063,14 +1117,24 @@ def test_model_survey_schema_version(smodel1, smodel2, every_model_fixture, requ # check version override if ans is not None: data["schema_version"] = 7 - if "Molecule-B" in smodel: - # TODO fix mol validated pathway when upgrade Mol - with pytest.raises(qcel.ValidationError) as e: - instance = model(**data) - else: + # qcel.ValidationError is for Molecule-B since molparse catches the error differently + with pytest.raises((pydantic.ValidationError, pydantic.v1.ValidationError, qcel.ValidationError)) as e: instance = model(**data) - # "v1" used to be changeable, but now the version is a stamp, not a signal - assert (cptd := getattr(instance, fld, "not found!")) == ans, f"[b] field {fld} = {cptd} != {ans}" + + # Note: this block *can* override the wrong 7 with the correct 1/2 and pass the following assert + # if the models have a validator like the below. This is handy for early conver_v developement + # when fundamental models are unchanged and their constructor can do all the work besides the version change. + + # instance = model(**data) + ## "v1" used to be changeable, but now the version is a stamp, not a signal + # assert (cptd := getattr(instance, fld, "not found!")) == ans, f"[b] field {fld} = {cptd} != {ans}" + + # v1: @validator("schema_version", pre=True) + # v2: @field_validator("schema_version", mode="before") + # def _version_stamp(cls, v): + # # seemingly unneeded, this lets conver_v re-label the model w/o discarding model and + # # submodel version fields first. + # return 1 @pytest.mark.parametrize("smodel1,smodel2", _model_classes_struct) @@ -1078,15 +1142,18 @@ def test_model_survey_extras(smodel1, smodel2, every_model_fixture, request, sch anskey = request.node.callspec.id.replace("None", "v1") # fmt: off ans = { - # v2: In/Ptcl/Prop/Kw + BasisSet, no! others, yes. is questionable. + # v2: In/Ptcl/Prop/Kw + BasisSet, no! others (Spec/Res + Mol), yes. is questionable. "v1-Mol-A" : {}, "v2-Mol-A" : {}, "v1-Mol-B" : {}, "v2-Mol-B" : {}, "v1-BasisSet" : None, "v2-BasisSet" : None, "v1-FailedOp" : {}, "v2-FailedOp" : {}, - "v1-AtIn" : {}, "v2-AtIn" : None, + "v1-AtIn-A" : {}, "v2-AtIn-A" : None, + "v1-AtIn-B" : {}, "v2-AtIn-B" : None, "v1-AtSpec" : {}, "v2-AtSpec" : {}, "v1-AtPtcl" : None, "v2-AtPtcl" : None, - "v1-AtRes" : {}, "v2-AtRes" : {}, + "v1-AtRes-A" : {}, "v2-AtRes-A" : {}, + "v1-AtRes-B" : {}, "v2-AtRes-B" : {}, + "v1-AtRes-C" : {}, "v2-AtRes-C" : {}, "v1-AtProp" : None, "v2-AtProp" : None, "v1-WfnProp" : None, "v2-WfnProp" : None, "v1-OptIn" : {}, "v2-OptIn" : None, @@ -1097,7 +1164,7 @@ def test_model_survey_extras(smodel1, smodel2, every_model_fixture, request, sch "v1-TDIn" : {}, "v2-TDIn" : None, "v1-TDSpec" : None, "v2-TDSpec" : {}, # v1 DNE "v1-TDKw" : None, "v2-TDKw" : None, - "v1-TDPtcl" : None, "v2-TDPtcl" : None, # v1/v2 DNE + "v1-TDPtcl" : None, "v2-TDPtcl" : None, # v1 DNE "v1-TDRes" : {}, "v2-TDRes" : {}, "v1-TDProp" : None, "v2-TDProp" : None, # v1/v2 DNE "v1-MBIn" : {}, "v2-MBIn" : None, # v2 DNE @@ -1177,30 +1244,33 @@ def test_model_survey_dictable(smodel1, smodel2, every_model_fixture, request, s @pytest.mark.parametrize("smodel1,smodel2", _model_classes_struct) -def test_model_survey_convertable(smodel1, smodel2, every_model_fixture, request, schema_versions): +def test_model_survey_convertible(smodel1, smodel2, every_model_fixture, request, schema_versions): anskey = request.node.callspec.id.replace("None", "v1") # fmt: off ans = { # convert_v() for user-facing fns. uncomment lines if this expands - # "v1-Mol-A" , "v2-Mol-A" , # TODO - # "v1-Mol-B" , "v2-Mol-B" , # TODO - # "v1-BasisSet" , "v2-BasisSet", # TODO + "v1-Mol-A" , "v2-Mol-A" , + "v1-Mol-B" , "v2-Mol-B" , + "v1-BasisSet" , "v2-BasisSet", "v1-FailedOp" , "v2-FailedOp", - "v1-AtIn" , "v2-AtIn" , + "v1-AtIn-A" , "v2-AtIn-A" , + "v1-AtIn-B" , "v2-AtIn-B" , "v1-AtSpec" , "v2-AtSpec" , # "v1-AtPtcl" , "v2-AtPtcl" , - "v1-AtRes" , "v2-AtRes" , + "v1-AtRes-A" , "v2-AtRes-A" , + "v1-AtRes-B" , "v2-AtRes-B" , + "v1-AtRes-C" , "v2-AtRes-C" , # "v1-AtProp" , "v2-AtProp" , - # "v1-WfnProp" , "v2-WfnProp" , + "v1-WfnProp" , "v2-WfnProp" , "v1-OptIn" , "v2-OptIn" , "v1-OptSpec" , "v2-OptSpec" , - # "v1-OptPtcl" , "v2-OptPtcl" , + "v1-OptPtcl" , "v2-OptPtcl" , "v1-OptRes" , "v2-OptRes" , - # "v1-OptProp" , "v2-OptProp" , + "v1-OptProp" , "v2-OptProp" , "v1-TDIn" , "v2-TDIn" , "v1-TDSpec" , "v2-TDSpec" , # "v1-TDKw" , "v2-TDKw" , - # "v1-TDPtcl" , "v2-TDPtcl" , + "v1-TDPtcl" , "v2-TDPtcl" , "v1-TDRes" , "v2-TDRes" , # "v1-TDProp" , "v2-TDProp" , # "v1-MBIn" , "v2-MBIn" , @@ -1250,24 +1320,27 @@ def test_model_survey_schema_name(smodel1, smodel2, every_model_fixture, request # note output not result "v1-Mol-A" : "qcschema_molecule", "v2-Mol-A" : "qcschema_molecule", "v1-Mol-B" : "qcschema_molecule", "v2-Mol-B" : "qcschema_molecule", - "v1-BasisSet" : "qcschema_basis", "v2-BasisSet" : "qcschema_basis", # TODO qcschema_basis_set? + "v1-BasisSet" : "qcschema_basis", "v2-BasisSet" : "qcschema_basis_set", "v1-FailedOp" : None, "v2-FailedOp" : "qcschema_failed_operation", - "v1-AtIn" : "qcschema_input", "v2-AtIn" : "qcschema_atomic_input", # TODO standardize! + "v1-AtIn-A" : "qcschema_input", "v2-AtIn-A" : "qcschema_atomic_input", + "v1-AtIn-B" : "qcschema_input", "v2-AtIn-B" : "qcschema_atomic_input", "v1-AtSpec" : "qcschema_input", "v2-AtSpec" : "qcschema_atomic_specification", "v1-AtPtcl" : None, "v2-AtPtcl" : "qcschema_atomic_protocols", - "v1-AtRes" : "qcschema_output", "v2-AtRes" : "qcschema_atomic_output", # TODO standardize! _result? + "v1-AtRes-A" : "qcschema_output", "v2-AtRes-A" : "qcschema_atomic_result", + "v1-AtRes-B" : "qcschema_output", "v2-AtRes-B" : "qcschema_atomic_result", + "v1-AtRes-C" : "qcschema_output", "v2-AtRes-C" : "qcschema_atomic_result", "v1-AtProp" : None, "v2-AtProp" : "qcschema_atomic_properties", "v1-WfnProp" : None, "v2-WfnProp" : "qcschema_wavefunction_properties", "v1-OptIn" : "qcschema_optimization_input", "v2-OptIn" : "qcschema_optimization_input", "v1-OptSpec" : "qcschema_optimization_specification", "v2-OptSpec" : "qcschema_optimization_specification", "v1-OptPtcl" : None, "v2-OptPtcl" : "qcschema_optimization_protocols", - "v1-OptRes" : "qcschema_optimization_output", "v2-OptRes" : "qcschema_optimization_output", # TODO change to _result? + "v1-OptRes" : "qcschema_optimization_output", "v2-OptRes" : "qcschema_optimization_result", "v1-OptProp" : None, "v2-OptProp" : "qcschema_optimization_properties", # v1 DNE "v1-TDIn" : "qcschema_torsion_drive_input", "v2-TDIn" : "qcschema_torsion_drive_input", "v1-TDSpec" : None, "v2-TDSpec" : "qcschema_torsion_drive_specification", # v1 DNE "v1-TDKw" : None, "v2-TDKw" : "qcschema_torsion_drive_keywords", - "v1-TDPtcl" : None, "v2-TDPtcl" : None, # v1 DNE, v2 DNE - "v1-TDRes" : "qcschema_torsion_drive_output", "v2-TDRes" : "qcschema_torsion_drive_output", # TODO change to _result? + "v1-TDPtcl" : None, "v2-TDPtcl" : "qcschema_torsion_drive_protocols", # v1 DNE + "v1-TDRes" : "qcschema_torsion_drive_output", "v2-TDRes" : "qcschema_torsion_drive_result", "v1-TDProp" : None, "v2-TDProp" : None, # v1 DNE, v2 DNE "v1-MBIn" : "qcschema_manybodyinput", "v2-MBIn" : "qcschema_many_body_input", # v2 DNE "v1-MBSpec" : "qcschema_manybodyspecification", "v2-MBSpec" : "qcschema_many_body_specification", # v2 DNE @@ -1379,12 +1452,12 @@ def test_return_result_types(result_data_fixture, retres, atprop, rettyp, jsntyp assert isinstance(jatres["return_result"], jsntyp) -@pytest.mark.parametrize("smodel", ["AtomicResult", "OptimizationResult", "TorsionDriveResult"]) +@pytest.mark.parametrize("smodel", ["AtomicResult-A", "OptimizationResult", "TorsionDriveResult"]) def test_error_field_passthrough_v1(request, schema_versions, every_model_fixture, smodel): if "v2" in request.node.name: pytest.skip("test not appropriate for v2") - model = getattr(qcel.models.v1, smodel) + model = getattr(qcel.models.v1, smodel.split("-")[0]) data = every_model_fixture[smodel] instance = model(**data) assert instance.success is True diff --git a/qcelemental/tests/test_molecule.py b/qcelemental/tests/test_molecule.py index a30c063b..c8295f8d 100644 --- a/qcelemental/tests/test_molecule.py +++ b/qcelemental/tests/test_molecule.py @@ -57,7 +57,7 @@ def test_molecule_data_constructor_numpy(Molecule, water_dimer_minima_data): assert water_psi.get_molecular_formula(order="hill") == "H4O2" -def test_molecule_data_constructor_dict(Molecule, water_dimer_minima_data): +def test_molecule_data_constructor_dict(Molecule, water_dimer_minima_data, request): water_dimer_minima = Molecule.from_data(**water_dimer_minima_data) water_psi = water_dimer_minima.model_copy() @@ -73,7 +73,7 @@ def test_molecule_data_constructor_dict(Molecule, water_dimer_minima_data): assert ( water_psi.get_hash() == "3c4b98f515d64d1adc1648fe1fe1d6789e978d34" # pragma: allowlist secret ) # copied from schema_version=1 - assert water_psi.schema_version == 2 + assert water_psi.schema_version == 3 if "v2" in request.node.name else 2 assert water_psi.schema_name == "qcschema_molecule" diff --git a/qcelemental/tests/test_utils.py b/qcelemental/tests/test_utils.py index 9e16762a..c4a2a685 100644 --- a/qcelemental/tests/test_utils.py +++ b/qcelemental/tests/test_utils.py @@ -385,7 +385,8 @@ def atomic_result_data(request): "success": True, } if "v2" in request.node.name: - data["schema_name"] = "qcschema_atomic_output" + data["schema_name"] = "qcschema_atomic_result" + data["schema_version"] = 2 data["input_data"] = { "molecule": data["molecule"], "specification": { @@ -395,6 +396,7 @@ def atomic_result_data(request): "protocols": data.pop("protocols"), }, } + data["input_data"]["molecule"]["schema_version"] = 3 return data