From 30f0059bc03452433dea6e18470d01b9430c9cb5 Mon Sep 17 00:00:00 2001 From: IAlibay Date: Tue, 15 Jul 2025 15:21:15 +0100 Subject: [PATCH 001/105] drop pydantic v1 imports --- gufe/settings/models.py | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/gufe/settings/models.py b/gufe/settings/models.py index d18054283..3d883f13b 100644 --- a/gufe/settings/models.py +++ b/gufe/settings/models.py @@ -13,16 +13,7 @@ from gufe.vendor.openff.models.models import DefaultModel from gufe.vendor.openff.models.types import FloatQuantity -try: - from pydantic.v1 import Extra, Field, PositiveFloat, PrivateAttr, validator -except ImportError: - from pydantic import ( - Extra, - Field, - PositiveFloat, - PrivateAttr, - validator, - ) +from pydantic import Extra, Field, PositiveFloat, PrivateAttr, validator import pydantic From c314f7513f1d8b29e206715333706f68812302f1 Mon Sep 17 00:00:00 2001 From: IAlibay Date: Wed, 16 Jul 2025 07:21:32 +0100 Subject: [PATCH 002/105] drop v1 import from models --- gufe/vendor/openff/models/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gufe/vendor/openff/models/models.py b/gufe/vendor/openff/models/models.py index 67f2e1590..46d3fcef3 100644 --- a/gufe/vendor/openff/models/models.py +++ b/gufe/vendor/openff/models/models.py @@ -2,7 +2,7 @@ from typing import Any from openff.units import Quantity -from pydantic.v1 import BaseModel +from pydantic import BaseModel from .types import custom_quantity_encoder, json_loader From 65f15f0076d9a47c3341e8772504aaaa526cefd3 Mon Sep 17 00:00:00 2001 From: IAlibay Date: Wed, 16 Jul 2025 07:38:56 +0100 Subject: [PATCH 003/105] drop some v1 things --- gufe/settings/models.py | 1 - gufe/vendor/openff/models/models.py | 3 +-- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/gufe/settings/models.py b/gufe/settings/models.py index 3d883f13b..c9089d8d7 100644 --- a/gufe/settings/models.py +++ b/gufe/settings/models.py @@ -30,7 +30,6 @@ class Config: extra = "forbid" arbitrary_types_allowed = False - smart_union = True def _ipython_display_(self): pprint.pprint(self.dict()) diff --git a/gufe/vendor/openff/models/models.py b/gufe/vendor/openff/models/models.py index 46d3fcef3..c5845abc6 100644 --- a/gufe/vendor/openff/models/models.py +++ b/gufe/vendor/openff/models/models.py @@ -4,7 +4,7 @@ from openff.units import Quantity from pydantic import BaseModel -from .types import custom_quantity_encoder, json_loader +from .types import custom_quantity_encoder class DefaultModel(BaseModel): @@ -16,6 +16,5 @@ class Config: json_encoders: dict[Any, Callable] = { Quantity: custom_quantity_encoder, } - json_loads: Callable = json_loader validate_assignment: bool = True arbitrary_types_allowed: bool = True From 36c8366dca55d6e3b0e7c2986ca59d7faacf21f4 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 16 Jul 2025 06:59:06 +0000 Subject: [PATCH 004/105] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- gufe/settings/models.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/gufe/settings/models.py b/gufe/settings/models.py index c9089d8d7..06ad7d04a 100644 --- a/gufe/settings/models.py +++ b/gufe/settings/models.py @@ -8,15 +8,13 @@ import pprint from typing import Optional, Union +import pydantic from openff.units import unit +from pydantic import Extra, Field, PositiveFloat, PrivateAttr, validator from gufe.vendor.openff.models.models import DefaultModel from gufe.vendor.openff.models.types import FloatQuantity -from pydantic import Extra, Field, PositiveFloat, PrivateAttr, validator - -import pydantic - class SettingsBaseModel(DefaultModel): """Settings and modifications we want for all settings classes.""" From 9d9d52131e08ceaf58da1f714dd93fdbcb4159ea Mon Sep 17 00:00:00 2001 From: Alyssa Travitz Date: Wed, 16 Jul 2025 09:08:19 -0700 Subject: [PATCH 005/105] pin pydantic version --- docs/environment.yaml | 2 +- environment.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/environment.yaml b/docs/environment.yaml index 6c61989f5..2a943fe85 100644 --- a/docs/environment.yaml +++ b/docs/environment.yaml @@ -3,7 +3,7 @@ channels: - https://conda.anaconda.org/jaimergp/label/unsupported-cudatoolkit-shim - https://conda.anaconda.org/conda-forge dependencies: -- autodoc-pydantic +- autodoc-pydantic >=2.0 - openff-units - python=3.10 - sphinx diff --git a/environment.yml b/environment.yml index 896975cdf..d608d9afd 100644 --- a/environment.yml +++ b/environment.yml @@ -13,7 +13,7 @@ dependencies: - pint - pip - pooch - - pydantic >1 + - pydantic >=2.0 - pytest - pytest-cov - pytest-xdist From 16006cf2c4f907d6dda86627216e5ab9620a5bd4 Mon Sep 17 00:00:00 2001 From: Alyssa Travitz Date: Wed, 16 Jul 2025 09:44:44 -0700 Subject: [PATCH 006/105] adding type hint for nonbonded_method --- gufe/settings/models.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/gufe/settings/models.py b/gufe/settings/models.py index 06ad7d04a..32ccce005 100644 --- a/gufe/settings/models.py +++ b/gufe/settings/models.py @@ -6,11 +6,11 @@ import abc import pprint -from typing import Optional, Union +from typing import Literal import pydantic from openff.units import unit -from pydantic import Extra, Field, PositiveFloat, PrivateAttr, validator +from pydantic import Field, PositiveFloat, PrivateAttr, validator from gufe.vendor.openff.models.models import DefaultModel from gufe.vendor.openff.models.types import FloatQuantity @@ -150,7 +150,7 @@ class OpenMMSystemGeneratorFFSettings(BaseForceFieldSettings): small_molecule_forcefield: str = "openff-2.1.1" # other default ideas 'openff-2.0.0', 'gaff-2.11', 'espaloma-0.2.0' """Name of the force field to be used for :class:`SmallMoleculeComponent` """ - nonbonded_method: str = "PME" + nonbonded_method: Literal["PME", "NoCutoff"] = "PME" """ Method for treating nonbonded interactions, currently only PME and NoCutoff are allowed. Default PME. From 9dde3ab8200497ca008fb44631fde5bfbfbca8aa Mon Sep 17 00:00:00 2001 From: Alyssa Travitz Date: Wed, 16 Jul 2025 13:17:46 -0700 Subject: [PATCH 007/105] bump to autodoc_pydantic 2.0 --- environment.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/environment.yml b/environment.yml index d608d9afd..6c3eb34f0 100644 --- a/environment.yml +++ b/environment.yml @@ -25,4 +25,4 @@ dependencies: - sphinx-jsonschema==1.15 - sphinx <7.1.2 - pip: - - autodoc_pydantic<2.0.0 + - autodoc_pydantic>=2.0.0 From 5a03216be3bcfedbd7c4328b47739cb9556ae9be Mon Sep 17 00:00:00 2001 From: Alyssa Travitz Date: Wed, 16 Jul 2025 14:19:06 -0700 Subject: [PATCH 008/105] remove autodoc_pydantic from base env --- environment.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/environment.yml b/environment.yml index 6c3eb34f0..e8c87c6e4 100644 --- a/environment.yml +++ b/environment.yml @@ -24,5 +24,3 @@ dependencies: - pydata-sphinx-theme - sphinx-jsonschema==1.15 - sphinx <7.1.2 - - pip: - - autodoc_pydantic>=2.0.0 From 8b7ec9c134767adee8c159541370a9dfaee85a93 Mon Sep 17 00:00:00 2001 From: Alyssa Travitz <31974495+atravitz@users.noreply.github.com> Date: Wed, 16 Jul 2025 14:30:08 -0700 Subject: [PATCH 009/105] Update gufe/settings/models.py Co-authored-by: Irfan Alibay --- gufe/settings/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gufe/settings/models.py b/gufe/settings/models.py index 32ccce005..d626b2a17 100644 --- a/gufe/settings/models.py +++ b/gufe/settings/models.py @@ -150,7 +150,7 @@ class OpenMMSystemGeneratorFFSettings(BaseForceFieldSettings): small_molecule_forcefield: str = "openff-2.1.1" # other default ideas 'openff-2.0.0', 'gaff-2.11', 'espaloma-0.2.0' """Name of the force field to be used for :class:`SmallMoleculeComponent` """ - nonbonded_method: Literal["PME", "NoCutoff"] = "PME" + nonbonded_method: Literal["CutoffNonPeriodic", "CutoffPeriodic", "Ewald", "LJPME", "NoCutoff", "PME"] = "PME" """ Method for treating nonbonded interactions, currently only PME and NoCutoff are allowed. Default PME. From 403a862bb93a2ff730b42ebae7c66fec227c82d5 Mon Sep 17 00:00:00 2001 From: Alyssa Travitz Date: Mon, 21 Jul 2025 12:49:44 -0700 Subject: [PATCH 010/105] notes --- gufe/settings/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gufe/settings/models.py b/gufe/settings/models.py index d626b2a17..630f66598 100644 --- a/gufe/settings/models.py +++ b/gufe/settings/models.py @@ -13,7 +13,7 @@ from pydantic import Field, PositiveFloat, PrivateAttr, validator from gufe.vendor.openff.models.models import DefaultModel -from gufe.vendor.openff.models.types import FloatQuantity +from gufe.vendor.openff.models.types import FloatQuantity # replace with _Quantity from interchange class SettingsBaseModel(DefaultModel): From e903f4866b7863d0fcc6a8276c35e6618a5296e6 Mon Sep 17 00:00:00 2001 From: Alyssa Travitz Date: Mon, 21 Jul 2025 13:29:29 -0700 Subject: [PATCH 011/105] vendor interchange basemodel and annotations --- .../vendor/openff/interchange/_annotations.py | 256 ++++++++++++++++++ gufe/vendor/openff/interchange/pydantic.py | 20 ++ 2 files changed, 276 insertions(+) create mode 100644 gufe/vendor/openff/interchange/_annotations.py create mode 100644 gufe/vendor/openff/interchange/pydantic.py diff --git a/gufe/vendor/openff/interchange/_annotations.py b/gufe/vendor/openff/interchange/_annotations.py new file mode 100644 index 000000000..c251933c6 --- /dev/null +++ b/gufe/vendor/openff/interchange/_annotations.py @@ -0,0 +1,256 @@ +import functools +from collections.abc import Callable +from typing import Annotated, Any + +import numpy +from annotated_types import Gt +from openff.toolkit import Quantity +from pydantic import ( + AfterValidator, + BeforeValidator, + ValidationInfo, + ValidatorFunctionWrapHandler, + WrapSerializer, + WrapValidator, +) + +PositiveFloat = Annotated[float, Gt(0)] + + +def _has_compatible_dimensionality( + quantity: Quantity, + unit: str, + convert: bool, +) -> Quantity: + """Check if a Quantity has the same dimensionality as a given unit and optionally convert.""" + if quantity.is_compatible_with(unit): + if convert: + return quantity.to(unit) + else: + return quantity + else: + raise ValueError( + f"Dimensionality of {quantity=} is not compatible with {unit=}", + ) + + +def _dimensionality_validator_factory(unit: str) -> Callable: + """Return a function, meant to be passed to a validator, that checks for a specific unit.""" + return functools.partial(_has_compatible_dimensionality, unit=unit, convert=False) + + +def _unit_validator_factory(unit: str) -> Callable: + """Return a function, meant to be passed to a validator, that checks for a specific unit.""" + return functools.partial(_has_compatible_dimensionality, unit=unit, convert=True) + + +( + _is_distance, + _is_velocity, + _is_mass, + _is_temperature, +) = ( + _dimensionality_validator_factory(unit=_unit) + for _unit in [ + "nanometer", + "nanometer / picosecond", + "unified_atomic_mass_unit", + "kelvin", + ] +) + +( + _is_dimensionless, + _is_kj_mol, + _is_nanometer, + _is_degree, + _is_elementary_charge, +) = ( + _unit_validator_factory(unit=_unit) + for _unit in [ + "dimensionless", + "kilojoule / mole", + "nanometer", + "degree", + "elementary_charge", + ] +) + + +def quantity_validator( + value: str | Quantity | dict, + handler: ValidatorFunctionWrapHandler, + info: ValidationInfo, +) -> Quantity: + """Take Quantity-like objects and convert them to Quantity objects.""" + if info.mode == "json": + assert isinstance(value, dict), "Quantity must be in dict form here." + + # this is coupled to how a Quantity looks in JSON + return Quantity(value["val"], value["unit"]) + + # some more work may be needed to work with arrays, lists, tuples, etc. + + assert info.mode == "python" + + if isinstance(value, Quantity): + return value + elif isinstance(value, str): + return Quantity(value) + elif isinstance(value, dict): + return Quantity(value["val"], value["unit"]) + if "openmm" in str(type(value)): + from openff.units.openmm import from_openmm + + return from_openmm(value) + else: + raise ValueError(f"Invalid type {type(value)} for Quantity") + + +def quantity_json_serializer( + quantity: Quantity, + nxt, +) -> dict: + """Serialize a Quantity to a JSON-compatible dictionary.""" + magnitude = quantity.m + + if isinstance(magnitude, numpy.ndarray): + # This could be something fancier, list a bytestring + magnitude = magnitude.tolist() + + return { + "val": magnitude, + "unit": str(quantity.units), + } + + +# Pydantic v2 likes to marry validators and serializers to types with Annotated +# https://docs.pydantic.dev/latest/concepts/validators/#annotated-validators +_Quantity = Annotated[ + Quantity, + WrapValidator(quantity_validator), + WrapSerializer(quantity_json_serializer), +] + +_DimensionlessQuantity = Annotated[ + Quantity, + WrapValidator(quantity_validator), + AfterValidator(_is_dimensionless), + WrapSerializer(quantity_json_serializer), +] + +_DistanceQuantity = Annotated[ + Quantity, + WrapValidator(quantity_validator), + AfterValidator(_is_distance), + WrapSerializer(quantity_json_serializer), +] + +_LengthQuantity = _DistanceQuantity + +_VelocityQuantity = Annotated[ + Quantity, + WrapValidator(quantity_validator), + AfterValidator(_is_velocity), + WrapSerializer(quantity_json_serializer), +] + +_MassQuantity = Annotated[ + Quantity, + WrapValidator(quantity_validator), + AfterValidator(_is_mass), + WrapSerializer(quantity_json_serializer), +] + +_TemperatureQuantity = Annotated[ + Quantity, + WrapValidator(quantity_validator), + AfterValidator(_is_temperature), + WrapSerializer(quantity_json_serializer), +] + +_DegreeQuantity = Annotated[ + Quantity, + WrapValidator(quantity_validator), + AfterValidator(_is_degree), + WrapSerializer(quantity_json_serializer), +] + +_ElementaryChargeQuantity = Annotated[ + Quantity, + WrapValidator(quantity_validator), + AfterValidator(_is_elementary_charge), + WrapSerializer(quantity_json_serializer), +] + +_kJMolQuantity = Annotated[ + Quantity, + WrapValidator(quantity_validator), + AfterValidator(_is_kj_mol), + WrapSerializer(quantity_json_serializer), +] + + +def _is_positions_shape(quantity: Quantity) -> Quantity: + if quantity.m.shape[1] == 3: + return quantity + else: + raise ValueError( + f"Quantity {quantity} of wrong shape ({quantity.shape}) to be positions.", + ) + + +def _duck_to_nanometer(value: Any): + """Cast list or ndarray without units to Quantity[ndarray] of nanometer.""" + if isinstance(value, (list, numpy.ndarray)): + return Quantity(value, "nanometer") + else: + return value + + +_PositionsQuantity = Annotated[ + Quantity, + WrapValidator(quantity_validator), + AfterValidator(_is_nanometer), + AfterValidator(_is_positions_shape), + BeforeValidator(_duck_to_nanometer), + WrapSerializer(quantity_json_serializer), +] + + +def _is_box_shape(quantity) -> Quantity: + if quantity.m.shape == (3, 3): + return quantity + elif quantity.m.shape == (3,): + return numpy.eye(3) * quantity + else: + raise ValueError(f"Quantity {quantity} is not a box.") + + +def _unwrap_list_of_openmm_quantities(value: Any): + """Unwrap a list of OpenMM quantities to a single Quantity.""" + if isinstance(value, list): + if any(["openmm" in str(type(element)) for element in value]): + from openff.units.openmm import from_openmm + + if len({element.unit for element in value}) != 1: + raise ValueError("All units must be the same.") + + return from_openmm(value) + + else: + return value + + else: + return value + + +_BoxQuantity = Annotated[ + Quantity, + WrapValidator(quantity_validator), + AfterValidator(_is_distance), + AfterValidator(_is_box_shape), + BeforeValidator(_duck_to_nanometer), + BeforeValidator(_unwrap_list_of_openmm_quantities), + WrapSerializer(quantity_json_serializer), +] diff --git a/gufe/vendor/openff/interchange/pydantic.py b/gufe/vendor/openff/interchange/pydantic.py new file mode 100644 index 000000000..d81ebf7bd --- /dev/null +++ b/gufe/vendor/openff/interchange/pydantic.py @@ -0,0 +1,20 @@ +"""Pydantic base model with custom settings.""" + +from typing import Any + +from pydantic import BaseModel, ConfigDict + + +class _BaseModel(BaseModel): + """A custom Pydantic model used by other components.""" + + model_config = ConfigDict( + validate_assignment=True, + arbitrary_types_allowed=True, + ) + + def model_dump(self, **kwargs) -> dict[str, Any]: + return super().model_dump(serialize_as_any=True, **kwargs) + + def model_dump_json(self, **kwargs) -> str: + return super().model_dump_json(serialize_as_any=True, **kwargs) From 48a92c4f5bcf8b8a9ba42e57cd22a6855c9f1482 Mon Sep 17 00:00:00 2001 From: Alyssa Travitz Date: Mon, 21 Jul 2025 14:16:45 -0700 Subject: [PATCH 012/105] dont need config class --- gufe/settings/models.py | 25 ++++++++++--------------- gufe/vendor/openff/models/models.py | 20 -------------------- 2 files changed, 10 insertions(+), 35 deletions(-) delete mode 100644 gufe/vendor/openff/models/models.py diff --git a/gufe/settings/models.py b/gufe/settings/models.py index 630f66598..3a08dc066 100644 --- a/gufe/settings/models.py +++ b/gufe/settings/models.py @@ -9,25 +9,20 @@ from typing import Literal import pydantic +from pydantic import AfterValidator from openff.units import unit -from pydantic import Field, PositiveFloat, PrivateAttr, validator +from pydantic import Field, PositiveFloat, PrivateAttr, ConfigDict, validator -from gufe.vendor.openff.models.models import DefaultModel +from gufe.vendor.openff.interchange.pydantic import _BaseModel +from gufe.vendor.openff.interchange._annotations import _Quantity from gufe.vendor.openff.models.types import FloatQuantity # replace with _Quantity from interchange -class SettingsBaseModel(DefaultModel): +class SettingsBaseModel(_BaseModel): """Settings and modifications we want for all settings classes.""" _is_frozen: bool = PrivateAttr(default_factory=lambda: False) - - class Config: - """ - :noindex: - """ - - extra = "forbid" - arbitrary_types_allowed = False + model_config = ConfigDict(extra='forbid', arbitrary_types_allowed=False) def _ipython_display_(self): pprint.pprint(self.dict()) @@ -97,10 +92,10 @@ class ThermoSettings(SettingsBaseModel): possible. """ - temperature: FloatQuantity["kelvin"] = Field(None, description="Simulation temperature, default units kelvin") - pressure: FloatQuantity["standard_atmosphere"] = Field( - None, description="Simulation pressure, default units standard atmosphere (atm)" - ) + temperature: _Quantity = None # FloatQuantity["kelvin"] = Field(None, description="Simulation temperature, default units kelvin") + pressure: _Quantity = None # FloatQuantity["standard_atmosphere"] = Field( + # None, description="Simulation pressure, default units standard atmosphere (atm)" + # ) ph: PositiveFloat | None = Field(None, description="Simulation pH") redox_potential: float | None = Field(None, description="Simulation redox potential") diff --git a/gufe/vendor/openff/models/models.py b/gufe/vendor/openff/models/models.py deleted file mode 100644 index c5845abc6..000000000 --- a/gufe/vendor/openff/models/models.py +++ /dev/null @@ -1,20 +0,0 @@ -from collections.abc import Callable -from typing import Any - -from openff.units import Quantity -from pydantic import BaseModel - -from .types import custom_quantity_encoder - - -class DefaultModel(BaseModel): - """A custom Pydantic model used by other components.""" - - class Config: - """Custom Pydantic configuration.""" - - json_encoders: dict[Any, Callable] = { - Quantity: custom_quantity_encoder, - } - validate_assignment: bool = True - arbitrary_types_allowed: bool = True From 0db3cf9b834eb2a08ad6ea5f0b9a17bfb0d395a4 Mon Sep 17 00:00:00 2001 From: Alyssa Travitz Date: Mon, 21 Jul 2025 14:44:32 -0700 Subject: [PATCH 013/105] use _Quantity --- gufe/settings/models.py | 17 +++++++---------- gufe/vendor/openff/interchange/_annotations.py | 1 + 2 files changed, 8 insertions(+), 10 deletions(-) diff --git a/gufe/settings/models.py b/gufe/settings/models.py index 3a08dc066..11d2bd006 100644 --- a/gufe/settings/models.py +++ b/gufe/settings/models.py @@ -8,21 +8,20 @@ import pprint from typing import Literal -import pydantic from pydantic import AfterValidator from openff.units import unit from pydantic import Field, PositiveFloat, PrivateAttr, ConfigDict, validator from gufe.vendor.openff.interchange.pydantic import _BaseModel -from gufe.vendor.openff.interchange._annotations import _Quantity -from gufe.vendor.openff.models.types import FloatQuantity # replace with _Quantity from interchange - +from gufe.vendor.openff.interchange._annotations import _Quantity, _TemperatureQuantity class SettingsBaseModel(_BaseModel): """Settings and modifications we want for all settings classes.""" _is_frozen: bool = PrivateAttr(default_factory=lambda: False) - model_config = ConfigDict(extra='forbid', arbitrary_types_allowed=False) + model_config = ConfigDict(extra='forbid', + arbitrary_types_allowed=True # TODO: this was historically False, try to change back + ) def _ipython_display_(self): pprint.pprint(self.dict()) @@ -92,10 +91,8 @@ class ThermoSettings(SettingsBaseModel): possible. """ - temperature: _Quantity = None # FloatQuantity["kelvin"] = Field(None, description="Simulation temperature, default units kelvin") - pressure: _Quantity = None # FloatQuantity["standard_atmosphere"] = Field( - # None, description="Simulation pressure, default units standard atmosphere (atm)" - # ) + temperature: _TemperatureQuantity = Field(None, description="Simulation temperature, default units kelvin") # TODO: make type equiv of FloatQuantity["kelvin"] = + pressure: _Quantity = Field(None, description="Simulation pressure, default units standard atmosphere (atm)") # TODO: make type equiv FloatQuantity["standard_atmosphere"] ph: PositiveFloat | None = Field(None, description="Simulation pH") redox_potential: float | None = Field(None, description="Simulation redox potential") @@ -150,7 +147,7 @@ class OpenMMSystemGeneratorFFSettings(BaseForceFieldSettings): Method for treating nonbonded interactions, currently only PME and NoCutoff are allowed. Default PME. """ - nonbonded_cutoff: FloatQuantity["nanometer"] = 1.0 * unit.nanometer + nonbonded_cutoff: _Quantity=1.0 * unit.nanometer # FloatQuantity["nanometer"] = 1.0 * unit.nanometer """ Cutoff value for short range nonbonded interactions. Default 1.0 * unit.nanometer. diff --git a/gufe/vendor/openff/interchange/_annotations.py b/gufe/vendor/openff/interchange/_annotations.py index c251933c6..ff0da8302 100644 --- a/gufe/vendor/openff/interchange/_annotations.py +++ b/gufe/vendor/openff/interchange/_annotations.py @@ -112,6 +112,7 @@ def quantity_json_serializer( nxt, ) -> dict: """Serialize a Quantity to a JSON-compatible dictionary.""" + import pdb;pdb.set_trace() magnitude = quantity.m if isinstance(magnitude, numpy.ndarray): From 824ca3aa7779d5a068c0f12f68e73c48e815130a Mon Sep 17 00:00:00 2001 From: Alyssa Travitz Date: Mon, 21 Jul 2025 14:56:23 -0700 Subject: [PATCH 014/105] temporary None workaround --- gufe/vendor/openff/interchange/_annotations.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/gufe/vendor/openff/interchange/_annotations.py b/gufe/vendor/openff/interchange/_annotations.py index ff0da8302..912c62de0 100644 --- a/gufe/vendor/openff/interchange/_annotations.py +++ b/gufe/vendor/openff/interchange/_annotations.py @@ -112,16 +112,20 @@ def quantity_json_serializer( nxt, ) -> dict: """Serialize a Quantity to a JSON-compatible dictionary.""" - import pdb;pdb.set_trace() - magnitude = quantity.m - + # TODO: this None handling is a temporary workaround and should be fixed! + if quantity is None: + magnitude = quantity + unit = "" + else: + magnitude = quantity.m + unit = str(quantity.units) if isinstance(magnitude, numpy.ndarray): # This could be something fancier, list a bytestring magnitude = magnitude.tolist() return { "val": magnitude, - "unit": str(quantity.units), + "unit": unit, } From 72c70304cbb0f0bb7da41da59bff83e471dda005 Mon Sep 17 00:00:00 2001 From: Alyssa Travitz Date: Tue, 22 Jul 2025 09:33:24 -0700 Subject: [PATCH 015/105] address pydantic v2 deprecation warnings --- gufe/tests/test_serialization_migration.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/gufe/tests/test_serialization_migration.py b/gufe/tests/test_serialization_migration.py index 11991a8e6..0275e26a2 100644 --- a/gufe/tests/test_serialization_migration.py +++ b/gufe/tests/test_serialization_migration.py @@ -233,11 +233,11 @@ def __init__(self, settings: GrandparentSettings): self.settings = settings def _to_dict(self): - return {"settings": self.settings.dict()} + return {"settings": self.settings.model_dump()} @classmethod def _from_dict(cls, dct): - settings = GrandparentSettings.parse_obj(dct["settings"]) + settings = GrandparentSettings.model_validate(dct["settings"]) return cls(settings=settings) @classmethod From 3cc7f453b5cb0406ab73cc8c3e50720ad2ff34bc Mon Sep 17 00:00:00 2001 From: Alyssa Travitz Date: Tue, 22 Jul 2025 09:48:45 -0700 Subject: [PATCH 016/105] addressing more pydantic v2 deprecation warnings --- gufe/settings/models.py | 22 ++++++++-------------- 1 file changed, 8 insertions(+), 14 deletions(-) diff --git a/gufe/settings/models.py b/gufe/settings/models.py index 11d2bd006..24ec4e935 100644 --- a/gufe/settings/models.py +++ b/gufe/settings/models.py @@ -20,7 +20,7 @@ class SettingsBaseModel(_BaseModel): _is_frozen: bool = PrivateAttr(default_factory=lambda: False) model_config = ConfigDict(extra='forbid', - arbitrary_types_allowed=True # TODO: this was historically False, try to change back + # arbitrary_types_allowed=True # TODO: this was previously False, try to change back ) def _ipython_display_(self): @@ -32,11 +32,11 @@ def frozen_copy(self): This is intended to be used by Protocols to make their stored Settings read-only """ - copied = self.copy(deep=True) + copied = self.model_copy(deep=True) def freeze_model(model): submodels = ( - mod for field in model.__fields__ if isinstance(mod := getattr(model, field), SettingsBaseModel) + mod for field in model.model_fields if isinstance(mod := getattr(model, field), SettingsBaseModel) ) for mod in submodels: freeze_model(mod) @@ -53,11 +53,11 @@ def unfrozen_copy(self): Settings objects become frozen when within a Protocol. If you *really* need to reverse this, this method is how. """ - copied = self.copy(deep=True) + copied = self.model_copy(deep=True) def unfreeze_model(model): submodels = ( - mod for field in model.__fields__ if isinstance(mod := getattr(model, field), SettingsBaseModel) + mod for field in model.model_fields if isinstance(mod := getattr(model, field), SettingsBaseModel) ) for mod in submodels: unfreeze_model(mod) @@ -92,19 +92,13 @@ class ThermoSettings(SettingsBaseModel): """ temperature: _TemperatureQuantity = Field(None, description="Simulation temperature, default units kelvin") # TODO: make type equiv of FloatQuantity["kelvin"] = - pressure: _Quantity = Field(None, description="Simulation pressure, default units standard atmosphere (atm)") # TODO: make type equiv FloatQuantity["standard_atmosphere"] - ph: PositiveFloat | None = Field(None, description="Simulation pH") - redox_potential: float | None = Field(None, description="Simulation redox potential") + # pressure: _Quantity = Field(None, description="Simulation pressure, default units standard atmosphere (atm)") # TODO: make type equiv FloatQuantity["standard_atmosphere"] + # ph: PositiveFloat | None = Field(None, description="Simulation pH") + # redox_potential: float | None = Field(None, description="Simulation redox potential") class BaseForceFieldSettings(SettingsBaseModel, abc.ABC): """Base class for ForceFieldSettings objects""" - - class Config: - """:noindex:""" - - pass - ... From 3ee2e27d7b61493787fa19baff33ab81bbc91d7a Mon Sep 17 00:00:00 2001 From: Alyssa Travitz Date: Tue, 22 Jul 2025 09:55:47 -0700 Subject: [PATCH 017/105] addressing more pydantic v2 deprecation warnings --- gufe/tests/test_models.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/gufe/tests/test_models.py b/gufe/tests/test_models.py index 4fcf81a2b..2c8d11d42 100644 --- a/gufe/tests/test_models.py +++ b/gufe/tests/test_models.py @@ -65,8 +65,8 @@ def test_settings_schema(): def test_default_settings(): my_settings = Settings.get_defaults() my_settings.thermo_settings.temperature = 298 * unit.kelvin - my_settings.json() - my_settings.schema_json(indent=2) + my_settings.model_dump_json() + json.dumps(my_settings.model_json_schema(), indent=2) class TestSettingsValidation: From 4a302104796dd7d0df798710258fead851e3255b Mon Sep 17 00:00:00 2001 From: Alyssa Travitz Date: Tue, 22 Jul 2025 12:14:48 -0700 Subject: [PATCH 018/105] add mode serialization to model json schema --- gufe/tests/test_models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gufe/tests/test_models.py b/gufe/tests/test_models.py index 2c8d11d42..746377cca 100644 --- a/gufe/tests/test_models.py +++ b/gufe/tests/test_models.py @@ -66,7 +66,7 @@ def test_default_settings(): my_settings = Settings.get_defaults() my_settings.thermo_settings.temperature = 298 * unit.kelvin my_settings.model_dump_json() - json.dumps(my_settings.model_json_schema(), indent=2) + json.dumps(my_settings.model_json_schema(mode='serialization'), indent=2) class TestSettingsValidation: From 27b6181cdc7d27edbd7ac73fe331beaffe790173 Mon Sep 17 00:00:00 2001 From: Alyssa Travitz Date: Tue, 22 Jul 2025 13:43:58 -0700 Subject: [PATCH 019/105] no longer vendor openff models --- gufe/settings/models.py | 12 +- gufe/vendor/openff/models/README.md | 16 -- gufe/vendor/openff/models/__init__.py | 0 gufe/vendor/openff/models/exceptions.py | 16 -- gufe/vendor/openff/models/types.py | 231 ------------------------ 5 files changed, 6 insertions(+), 269 deletions(-) delete mode 100644 gufe/vendor/openff/models/README.md delete mode 100644 gufe/vendor/openff/models/__init__.py delete mode 100644 gufe/vendor/openff/models/exceptions.py delete mode 100644 gufe/vendor/openff/models/types.py diff --git a/gufe/settings/models.py b/gufe/settings/models.py index 24ec4e935..08d56b245 100644 --- a/gufe/settings/models.py +++ b/gufe/settings/models.py @@ -8,12 +8,12 @@ import pprint from typing import Literal -from pydantic import AfterValidator from openff.units import unit -from pydantic import Field, PositiveFloat, PrivateAttr, ConfigDict, validator +from pydantic import AfterValidator, ConfigDict, Field, PositiveFloat, PrivateAttr, validator -from gufe.vendor.openff.interchange.pydantic import _BaseModel from gufe.vendor.openff.interchange._annotations import _Quantity, _TemperatureQuantity +from gufe.vendor.openff.interchange.pydantic import _BaseModel + class SettingsBaseModel(_BaseModel): """Settings and modifications we want for all settings classes.""" @@ -92,9 +92,9 @@ class ThermoSettings(SettingsBaseModel): """ temperature: _TemperatureQuantity = Field(None, description="Simulation temperature, default units kelvin") # TODO: make type equiv of FloatQuantity["kelvin"] = - # pressure: _Quantity = Field(None, description="Simulation pressure, default units standard atmosphere (atm)") # TODO: make type equiv FloatQuantity["standard_atmosphere"] - # ph: PositiveFloat | None = Field(None, description="Simulation pH") - # redox_potential: float | None = Field(None, description="Simulation redox potential") + pressure: _Quantity = Field(None, description="Simulation pressure, default units standard atmosphere (atm)") # TODO: make type equiv FloatQuantity["standard_atmosphere"] + ph: PositiveFloat | None = Field(None, description="Simulation pH") + redox_potential: float | None = Field(None, description="Simulation redox potential") class BaseForceFieldSettings(SettingsBaseModel, abc.ABC): diff --git a/gufe/vendor/openff/models/README.md b/gufe/vendor/openff/models/README.md deleted file mode 100644 index cdda80525..000000000 --- a/gufe/vendor/openff/models/README.md +++ /dev/null @@ -1,16 +0,0 @@ -So I just yanked what we needed from from https://github.com/openforcefield/openff-models/tree/077ed7b - -Some changes: - -* Instead of using: -```python -try: - from pydantic.v1 import BaseModel -except ImportError: - from pydantic import BaseModel # type: ignore[assignment] -``` -We are going to just use from `pydantic.v1 import BaseModel` directly since we depend on the pydantic 1.x version where that was added. - -Then for `types.py`, `models.py`, and `exceptions.py` I ran our formatting hooks + pyupgrade --py310-plus. - -I've included the LICENSE from the openff repo for good measure. diff --git a/gufe/vendor/openff/models/__init__.py b/gufe/vendor/openff/models/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/gufe/vendor/openff/models/exceptions.py b/gufe/vendor/openff/models/exceptions.py deleted file mode 100644 index 332823711..000000000 --- a/gufe/vendor/openff/models/exceptions.py +++ /dev/null @@ -1,16 +0,0 @@ -class MissingUnitError(ValueError): - """ - Exception for data missing a unit tag. - """ - - -class UnitValidationError(ValueError): - """ - Exception for bad behavior when validating unit-tagged data. - """ - - -class UnsupportedExportError(BaseException): - """ - Exception for attempting to write to an unsupported file format. - """ diff --git a/gufe/vendor/openff/models/types.py b/gufe/vendor/openff/models/types.py deleted file mode 100644 index 670fc05df..000000000 --- a/gufe/vendor/openff/models/types.py +++ /dev/null @@ -1,231 +0,0 @@ -"""Custom models for dealing with unit-bearing quantities in a Pydantic-compatible manner.""" - -import json -from typing import TYPE_CHECKING, Any - -import numpy -from openff.units import Quantity, Unit, unit -from openff.utilities import has_package, requires_package - -from .exceptions import ( - MissingUnitError, - UnitValidationError, - UnsupportedExportError, -) - -if TYPE_CHECKING: - import openmm.unit - - -class _FloatQuantityMeta(type): - def __getitem__(self, t): - return type("FloatQuantity", (FloatQuantity,), {"__unit__": t}) - - -if TYPE_CHECKING: - FloatQuantity = unit.Quantity -else: - - class FloatQuantity(float, metaclass=_FloatQuantityMeta): - """A model for unit-bearing floats.""" - - @classmethod - def __get_validators__(cls): - yield cls.validate_type - - @classmethod - def validate_type(cls, val): - """Process a value tagged with units into one tagged with "OpenFF" style units.""" - unit_ = getattr(cls, "__unit__", Any) - if unit_ is Any: - if isinstance(val, (float, int)): - # TODO: Can this exception be raised with knowledge of the field it's in? - raise MissingUnitError(f"Value {val} needs to be tagged with a unit") - elif isinstance(val, Quantity): - return Quantity(val) - elif _is_openmm_quantity(val): - return _from_omm_quantity(val) - else: - raise UnitValidationError(f"Could not validate data of type {type(val)}") - else: - unit_ = Unit(unit_) - if isinstance(val, Quantity): - # some custom behavior could go here - assert unit_.dimensionality == val.dimensionality - # return through converting to some intended default units (taken from the class) - val._magnitude = float(val.m) - return val.to(unit_) - - if _is_openmm_quantity(val): - return _from_omm_quantity(val).to(unit_) - if isinstance(val, int) and not isinstance(val, bool): - # coerce ints into floats for a FloatQuantity - return float(val) * unit_ - if isinstance(val, float): - return val * unit_ - if isinstance(val, str): - # could do custom deserialization here? - val = Quantity(val).to(unit_) - val._magnitude = float(val._magnitude) - return val - if "unyt" in str(val.__class__): - if val.value.shape == (): - # this is a scalar force into an array by unyt's design - if "float" in str(val.value.dtype): - return float(val.value) * unit_ - elif "int" in str(val.value.dtype): - return int(val.value) * unit_ - - raise UnitValidationError(f"Could not validate data of type {type(val)}") - - -def _is_openmm_quantity(obj: object) -> bool: - if has_package("openmm"): - import openmm.unit - - return isinstance(obj, openmm.unit.Quantity) - - else: - return "openmm.unit.quantity.Quantity" in str(type(object)) - - -@requires_package("openmm.unit") -def _from_omm_quantity(val: "openmm.unit.Quantity") -> Quantity: - """ - Convert float or array quantities tagged with SimTK/OpenMM units to a Pint-compatible quantity. - """ - unit_: openmm.unit.Unit = val.unit - val_ = val.value_in_unit(unit_) - if type(val_) in {float, int}: - unit_ = val.unit - return float(val_) * Unit(str(unit_)) - # Here is where the toolkit's ValidatedList could go, if present in the environment - elif (type(val_) in {tuple, list, numpy.ndarray}) or (type(val_).__module__ == "openmm.vec3"): - array = numpy.asarray(val_) - return array * Unit(str(unit_)) - elif isinstance(val_, (float, int)) and type(val_).__module__ == "numpy": - return val_ * Unit(str(unit_)) - else: - raise UnitValidationError( - "Found a openmm.unit.Unit wrapped around something other than a float-like " - f"or numpy.ndarray-like. Found a unit wrapped around type {type(val_)}." - ) - - -class QuantityEncoder(json.JSONEncoder): - """ - JSON encoder for unit-wrapped floats and NumPy arrays. - - This is intended to operate on FloatQuantity and ArrayQuantity objects. - """ - - def default(self, obj): - if isinstance(obj, Quantity): - if isinstance(obj.magnitude, (float, int)): - data = obj.magnitude - elif isinstance(obj.magnitude, numpy.ndarray): - data = obj.magnitude.tolist() - else: - # This shouldn't ever be hit if our object models - # behave in ways we expect? - raise UnsupportedExportError(f"trying to serialize unsupported type {type(obj.magnitude)}") - return { - "val": data, - "unit": str(obj.units), - } - - -def custom_quantity_encoder(v): - """Wrap json.dump to use QuantityEncoder.""" - return json.dumps(v, cls=QuantityEncoder) - - -def json_loader(data: str) -> dict: - """Load JSON containing custom unit-tagged quantities.""" - # TODO: recursively call this function for nested models - out: dict = json.loads(data) - for key, val in out.items(): - try: - # Directly look for an encoded FloatQuantity/ArrayQuantity, - # which is itself a dict - v = json.loads(val) - except (json.JSONDecodeError, TypeError): - # Handles some cases of the val being a primitive type - continue - # TODO: More gracefully parse non-FloatQuantity/ArrayQuantity dicts - unit_ = Unit(v["unit"]) - val = v["val"] - out[key] = unit_ * val - return out - - -class _ArrayQuantityMeta(type): - def __getitem__(self, t): - return type("ArrayQuantity", (ArrayQuantity,), {"__unit__": t}) - - -if TYPE_CHECKING: - ArrayQuantity = unit.Quantity -else: - - class ArrayQuantity(float, metaclass=_ArrayQuantityMeta): - """A model for unit-bearing arrays.""" - - @classmethod - def __get_validators__(cls): - yield cls.validate_type - - @classmethod - def validate_type(cls, val): - """Process an array tagged with units into one tagged with "OpenFF" style units.""" - unit_ = getattr(cls, "__unit__", Any) - if unit_ is Any: - if isinstance(val, (list, numpy.ndarray)): - # Work around a special case in which val might be list[openmm.unit.Quantity] - if isinstance(val, list) and {type(element).__module__ for element in val} == { - "openmm.unit.quantity" - }: - unit_ = _from_omm_quantity(val[-1]).units - return Quantity( - [_from_omm_quantity(element).m for element in val], - units=unit_, - ) - - # TODO: Can this exception be raised with knowledge of the field it's in? - raise MissingUnitError(f"Value {val} needs to be tagged with a unit") - - elif isinstance(val, Quantity): - # TODO: This might be a redundant cast causing wasted CPU time. - # But maybe it handles pint vs openff.units.unit? - return Quantity(val) - elif _is_openmm_quantity(val): - return _from_omm_quantity(val) - else: - raise UnitValidationError(f"Could not validate data of type {type(val)}") - else: - unit_ = Unit(unit_) - if isinstance(val, Quantity): - assert unit_.dimensionality == val.dimensionality - return val.to(unit_) - if _is_openmm_quantity(val): - return _from_omm_quantity(val).to(unit_) - if isinstance(val, (numpy.ndarray, list)): - if "unyt" in str(val.__class__): - val = val.to_ndarray() - try: - return val * unit_ - except RuntimeError as error: - # unyt subclasses ndarray but doesn't __mult__ with - # pint.Unit objects - if val.__class__.__module__.startswith("unyt"): - return val.to_ndarray() * unit_ - else: - raise error - if isinstance(val, bytes): - # Define outside loop - dt = numpy.dtype(int).newbyteorder("<") - return numpy.frombuffer(val, dtype=dt) * unit_ - if isinstance(val, str): - # could do custom deserialization here? - raise NotImplementedError - raise UnitValidationError(f"Could not validate data of type {type(val)}") From a8474fc0d4851ccb4aceb9beceeb7c0dcb8275ce Mon Sep 17 00:00:00 2001 From: Alyssa Travitz Date: Tue, 22 Jul 2025 14:04:55 -0700 Subject: [PATCH 020/105] adding more quantity types --- gufe/settings/models.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/gufe/settings/models.py b/gufe/settings/models.py index 08d56b245..213ad56f7 100644 --- a/gufe/settings/models.py +++ b/gufe/settings/models.py @@ -11,7 +11,7 @@ from openff.units import unit from pydantic import AfterValidator, ConfigDict, Field, PositiveFloat, PrivateAttr, validator -from gufe.vendor.openff.interchange._annotations import _Quantity, _TemperatureQuantity +from gufe.vendor.openff.interchange._annotations import _Quantity, _DistanceQuantity, _TemperatureQuantity from gufe.vendor.openff.interchange.pydantic import _BaseModel @@ -93,7 +93,7 @@ class ThermoSettings(SettingsBaseModel): temperature: _TemperatureQuantity = Field(None, description="Simulation temperature, default units kelvin") # TODO: make type equiv of FloatQuantity["kelvin"] = pressure: _Quantity = Field(None, description="Simulation pressure, default units standard atmosphere (atm)") # TODO: make type equiv FloatQuantity["standard_atmosphere"] - ph: PositiveFloat | None = Field(None, description="Simulation pH") + ph: PositiveFloat = Field(None, description="Simulation pH") redox_potential: float | None = Field(None, description="Simulation redox potential") @@ -141,7 +141,7 @@ class OpenMMSystemGeneratorFFSettings(BaseForceFieldSettings): Method for treating nonbonded interactions, currently only PME and NoCutoff are allowed. Default PME. """ - nonbonded_cutoff: _Quantity=1.0 * unit.nanometer # FloatQuantity["nanometer"] = 1.0 * unit.nanometer + nonbonded_cutoff: _DistanceQuantity=1.0 * unit.nanometer # FloatQuantity["nanometer"] = 1.0 * unit.nanometer """ Cutoff value for short range nonbonded interactions. Default 1.0 * unit.nanometer. From d82d3614caa0fecda74eddfbeb47e5bb0371ff6c Mon Sep 17 00:00:00 2001 From: Alyssa Travitz Date: Fri, 25 Jul 2025 14:16:34 -0700 Subject: [PATCH 021/105] updating nonbonded_cutoff parameter behavior --- gufe/settings/models.py | 2 +- gufe/tests/test_models.py | 8 ++++---- news/pydantic_changes.rst | 23 +++++++++++++++++++++++ 3 files changed, 28 insertions(+), 5 deletions(-) create mode 100644 news/pydantic_changes.rst diff --git a/gufe/settings/models.py b/gufe/settings/models.py index 213ad56f7..edd46d35e 100644 --- a/gufe/settings/models.py +++ b/gufe/settings/models.py @@ -11,7 +11,7 @@ from openff.units import unit from pydantic import AfterValidator, ConfigDict, Field, PositiveFloat, PrivateAttr, validator -from gufe.vendor.openff.interchange._annotations import _Quantity, _DistanceQuantity, _TemperatureQuantity +from gufe.vendor.openff.interchange._annotations import _DistanceQuantity, _Quantity, _TemperatureQuantity from gufe.vendor.openff.interchange.pydantic import _BaseModel diff --git a/gufe/tests/test_models.py b/gufe/tests/test_models.py index 746377cca..16a8e05ce 100644 --- a/gufe/tests/test_models.py +++ b/gufe/tests/test_models.py @@ -93,12 +93,12 @@ def test_openmmff_constraints(self, value, valid, expected): "value,valid,expected", [ (1.0 * unit.nanometer, True, 1.0 * unit.nanometer), - (1.0, True, 1.0 * unit.nanometer), # should cast float to nanometer + (0 * unit.nanometer, True, 0 * unit.nanometer), + (1.0, False, None), # requires a length unit. ("1.1 nm", True, 1.1 * unit.nanometer), - ("1.1 ", False, None), - (0, True, 0 * unit.nanometer), + ("1.1", False, None), (-1.0 * unit.nanometer, False, None), - # (1.0 * unit.angstrom, True, 0.100 * unit.nanometer), # TODO: why does this not work? + (1.0 * unit.angstrom, True, 1.0 * unit.angstrom), # TODO: should we convert this to nm? (300 * unit.kelvin, False, None), (True, False, None), (None, False, None), diff --git a/news/pydantic_changes.rst b/news/pydantic_changes.rst new file mode 100644 index 000000000..940909481 --- /dev/null +++ b/news/pydantic_changes.rst @@ -0,0 +1,23 @@ +**Added:** + +* + +**Changed:** + +* system generator setting ``nonbonded_cutoff`` no longer attempts to coerce ambiguous inputs to ``unit.nanometer``. Instead, a length unit is required, e.g. ``2.2 * unit.nanometer`` or ``"2.2 nm"``. + +**Deprecated:** + +* + +**Removed:** + +* + +**Fixed:** + +* + +**Security:** + +* From 32105303210212a22914c42cda6290d9278c6fb0 Mon Sep 17 00:00:00 2001 From: Alyssa Travitz Date: Fri, 25 Jul 2025 15:22:46 -0700 Subject: [PATCH 022/105] more strict about nonbonded cutoff methods --- gufe/settings/models.py | 23 ++++++----------------- gufe/tests/test_models.py | 6 +++--- news/pydantic_changes.rst | 1 + 3 files changed, 10 insertions(+), 20 deletions(-) diff --git a/gufe/settings/models.py b/gufe/settings/models.py index edd46d35e..e68fdd394 100644 --- a/gufe/settings/models.py +++ b/gufe/settings/models.py @@ -9,7 +9,7 @@ from typing import Literal from openff.units import unit -from pydantic import AfterValidator, ConfigDict, Field, PositiveFloat, PrivateAttr, validator +from pydantic import AfterValidator, ConfigDict, Field, PositiveFloat, PrivateAttr, field_validator, validator from gufe.vendor.openff.interchange._annotations import _DistanceQuantity, _Quantity, _TemperatureQuantity from gufe.vendor.openff.interchange.pydantic import _BaseModel @@ -138,8 +138,9 @@ class OpenMMSystemGeneratorFFSettings(BaseForceFieldSettings): nonbonded_method: Literal["CutoffNonPeriodic", "CutoffPeriodic", "Ewald", "LJPME", "NoCutoff", "PME"] = "PME" """ - Method for treating nonbonded interactions, currently only PME and - NoCutoff are allowed. Default PME. + Method for treating nonbonded interactions, options are currently + "CutoffNonPeriodic", "CutoffPeriodic", "Ewald", "LJPME", "NoCutoff", "PME". + Default PME. """ nonbonded_cutoff: _DistanceQuantity=1.0 * unit.nanometer # FloatQuantity["nanometer"] = 1.0 * unit.nanometer """ @@ -147,21 +148,9 @@ class OpenMMSystemGeneratorFFSettings(BaseForceFieldSettings): Default 1.0 * unit.nanometer. """ - @validator("nonbonded_method") - def allowed_nonbonded(cls, v): - if v.lower() not in ["pme", "nocutoff"]: - errmsg = "Only PME and NoCutoff are allowed nonbonded_methods" - raise ValueError(errmsg) - return v - - @validator("nonbonded_cutoff") + @field_validator("nonbonded_cutoff", mode='after') def is_positive_distance(cls, v): - # these are time units, not simulation steps - if not v.is_compatible_with( - unit.nanometer - ): # TODO: invalid units get caught earlier and so this code is never executed - raise ValueError("nonbonded_cutoff must be in distance units (i.e. nanometers)") - if v < 0: + if v < 0: # TODO: make this an Annotated type with a helpful error message. errmsg = "nonbonded_cutoff must be a positive value" raise ValueError(errmsg) return v diff --git a/gufe/tests/test_models.py b/gufe/tests/test_models.py index 16a8e05ce..bb447602f 100644 --- a/gufe/tests/test_models.py +++ b/gufe/tests/test_models.py @@ -116,8 +116,8 @@ def test_openmmff_nonbonded_cutoff(self, value, valid, expected): @pytest.mark.parametrize( "value,valid,expected", [ - ("pme", True, "pme"), - ("NOCUTOFF", True, "NOCUTOFF"), + ("NoCutoff", True, "NoCutoff"), + ("NOCUTOFF", False, "NOCUTOFF"), ("no cutoff", False, None), (1.0, False, None), ], @@ -127,7 +127,7 @@ def test_openmmff_nonbonded_method(self, value, valid, expected): s = OpenMMSystemGeneratorFFSettings(nonbonded_method=value) assert s.nonbonded_method == expected else: - with pytest.raises(ValueError, match="Only PME and NoCutoff are allowed"): + with pytest.raises(ValueError): _ = OpenMMSystemGeneratorFFSettings(nonbonded_method=value) @pytest.mark.parametrize( diff --git a/news/pydantic_changes.rst b/news/pydantic_changes.rst index 940909481..09df1304a 100644 --- a/news/pydantic_changes.rst +++ b/news/pydantic_changes.rst @@ -5,6 +5,7 @@ **Changed:** * system generator setting ``nonbonded_cutoff`` no longer attempts to coerce ambiguous inputs to ``unit.nanometer``. Instead, a length unit is required, e.g. ``2.2 * unit.nanometer`` or ``"2.2 nm"``. +* system generator setting ``nonbonded_method`` now is case sensitive and must be one of ``"CutoffNonPeriodic", "CutoffPeriodic", "Ewald", "LJPME", "NoCutoff", "PME"``. **Deprecated:** From 1c74c0317d87124aed72e9d24f14537b9ead4afa Mon Sep 17 00:00:00 2001 From: Alyssa Travitz Date: Fri, 25 Jul 2025 15:30:41 -0700 Subject: [PATCH 023/105] require units for temperature --- gufe/tests/test_models.py | 3 +-- news/pydantic_changes.rst | 1 + 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/gufe/tests/test_models.py b/gufe/tests/test_models.py index bb447602f..197ccb113 100644 --- a/gufe/tests/test_models.py +++ b/gufe/tests/test_models.py @@ -134,9 +134,8 @@ def test_openmmff_nonbonded_method(self, value, valid, expected): "value,valid,expected", [ (298 * unit.kelvin, True, 298 * unit.kelvin), - (298, True, 298 * unit.kelvin), - (298.0, True, 298 * unit.kelvin), ("298 kelvin", True, 298 * unit.kelvin), + (298, False, None), # requires units ("298", False, None), (298 * unit.angstrom, False, None), ], diff --git a/news/pydantic_changes.rst b/news/pydantic_changes.rst index 09df1304a..a31ebfee9 100644 --- a/news/pydantic_changes.rst +++ b/news/pydantic_changes.rst @@ -5,6 +5,7 @@ **Changed:** * system generator setting ``nonbonded_cutoff`` no longer attempts to coerce ambiguous inputs to ``unit.nanometer``. Instead, a length unit is required, e.g. ``2.2 * unit.nanometer`` or ``"2.2 nm"``. +* ``ThermoSettings`` parameter ``temperature`` no longer attempts to coerce ambiguous inputs to ``unit.kelvin``. Instead, the units must be passed explicitly, e.g. ``300 * unit.kelvin`` or ``"300 kelvin"``. * system generator setting ``nonbonded_method`` now is case sensitive and must be one of ``"CutoffNonPeriodic", "CutoffPeriodic", "Ewald", "LJPME", "NoCutoff", "PME"``. **Deprecated:** From 19ffdb7e0aed2f83ed47e39449bbe54e8b9c2dec Mon Sep 17 00:00:00 2001 From: Alyssa Travitz Date: Fri, 25 Jul 2025 15:37:16 -0700 Subject: [PATCH 024/105] presure requires units --- gufe/settings/models.py | 4 ++-- gufe/tests/test_models.py | 2 +- gufe/vendor/openff/interchange/_annotations.py | 9 +++++++++ news/pydantic_changes.rst | 2 +- 4 files changed, 13 insertions(+), 4 deletions(-) diff --git a/gufe/settings/models.py b/gufe/settings/models.py index e68fdd394..bda830bd7 100644 --- a/gufe/settings/models.py +++ b/gufe/settings/models.py @@ -11,7 +11,7 @@ from openff.units import unit from pydantic import AfterValidator, ConfigDict, Field, PositiveFloat, PrivateAttr, field_validator, validator -from gufe.vendor.openff.interchange._annotations import _DistanceQuantity, _Quantity, _TemperatureQuantity +from gufe.vendor.openff.interchange._annotations import _DistanceQuantity, _PressureQuantity, _TemperatureQuantity from gufe.vendor.openff.interchange.pydantic import _BaseModel @@ -92,7 +92,7 @@ class ThermoSettings(SettingsBaseModel): """ temperature: _TemperatureQuantity = Field(None, description="Simulation temperature, default units kelvin") # TODO: make type equiv of FloatQuantity["kelvin"] = - pressure: _Quantity = Field(None, description="Simulation pressure, default units standard atmosphere (atm)") # TODO: make type equiv FloatQuantity["standard_atmosphere"] + pressure: _PressureQuantity = Field(None, description="Simulation pressure, default units standard atmosphere (atm)") # TODO: make type equiv FloatQuantity["standard_atmosphere"] ph: PositiveFloat = Field(None, description="Simulation pH") redox_potential: float | None = Field(None, description="Simulation redox potential") diff --git a/gufe/tests/test_models.py b/gufe/tests/test_models.py index 197ccb113..d5fe691a0 100644 --- a/gufe/tests/test_models.py +++ b/gufe/tests/test_models.py @@ -152,7 +152,7 @@ def test_thermo_temperature(self, value, valid, expected): "value,valid,expected", [ (1.0 * unit.atm, True, 1.0 * unit.atm), - (1.0, True, 1.0 * unit.atm), + (1.0, False, None), # require units ("1 atm", True, 1.0 * unit.atm), ("1.0", False, None), ], diff --git a/gufe/vendor/openff/interchange/_annotations.py b/gufe/vendor/openff/interchange/_annotations.py index 912c62de0..281006c3a 100644 --- a/gufe/vendor/openff/interchange/_annotations.py +++ b/gufe/vendor/openff/interchange/_annotations.py @@ -49,6 +49,7 @@ def _unit_validator_factory(unit: str) -> Callable: _is_velocity, _is_mass, _is_temperature, + _is_pressure, ) = ( _dimensionality_validator_factory(unit=_unit) for _unit in [ @@ -56,6 +57,7 @@ def _unit_validator_factory(unit: str) -> Callable: "nanometer / picosecond", "unified_atomic_mass_unit", "kelvin", + "atm" ] ) @@ -174,6 +176,13 @@ def quantity_json_serializer( WrapSerializer(quantity_json_serializer), ] +_PressureQuantity = Annotated[ + Quantity, + WrapValidator(quantity_validator), + AfterValidator(_is_pressure), + WrapSerializer(quantity_json_serializer), +] + _DegreeQuantity = Annotated[ Quantity, WrapValidator(quantity_validator), diff --git a/news/pydantic_changes.rst b/news/pydantic_changes.rst index a31ebfee9..b76cc6cfc 100644 --- a/news/pydantic_changes.rst +++ b/news/pydantic_changes.rst @@ -5,7 +5,7 @@ **Changed:** * system generator setting ``nonbonded_cutoff`` no longer attempts to coerce ambiguous inputs to ``unit.nanometer``. Instead, a length unit is required, e.g. ``2.2 * unit.nanometer`` or ``"2.2 nm"``. -* ``ThermoSettings`` parameter ``temperature`` no longer attempts to coerce ambiguous inputs to ``unit.kelvin``. Instead, the units must be passed explicitly, e.g. ``300 * unit.kelvin`` or ``"300 kelvin"``. +* ``ThermoSettings`` parameters ``pressure`` and ``temperature`` no longer attempt to coerce ambiguous inputs to unts. Instead, the units must be passed explicitly, e.g. ``1.0 * units.atm`` or ``"1 atm"`` for pressure, and ``300 * unit.kelvin`` or ``"300 kelvin"`` for temperature. * system generator setting ``nonbonded_method`` now is case sensitive and must be one of ``"CutoffNonPeriodic", "CutoffPeriodic", "Ewald", "LJPME", "NoCutoff", "PME"``. **Deprecated:** From d2db96fd8e9a88a5088c5f6e80badc3f4e6101b8 Mon Sep 17 00:00:00 2001 From: Alyssa Travitz Date: Fri, 25 Jul 2025 15:40:44 -0700 Subject: [PATCH 025/105] schema -> model_json_schema --- gufe/tests/test_models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gufe/tests/test_models.py b/gufe/tests/test_models.py index d5fe691a0..e0f5aed5d 100644 --- a/gufe/tests/test_models.py +++ b/gufe/tests/test_models.py @@ -58,7 +58,7 @@ def test_settings_schema(): }, }, } - schema = Settings.schema() + schema = Settings.model_json_schema(mode='serialization') assert schema == expected_schema From f59972e650ea5dfceb7fe6edc45f3006311d7a12 Mon Sep 17 00:00:00 2001 From: Alyssa Travitz Date: Fri, 25 Jul 2025 15:41:40 -0700 Subject: [PATCH 026/105] validator -> field_validator --- gufe/settings/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gufe/settings/models.py b/gufe/settings/models.py index bda830bd7..30ceab31a 100644 --- a/gufe/settings/models.py +++ b/gufe/settings/models.py @@ -155,7 +155,7 @@ def is_positive_distance(cls, v): raise ValueError(errmsg) return v - @validator("constraints") + @field_validator("constraints") def constraint_check(cls, v): allowed = {"hbonds", "hangles", "allbonds"} From c0e4bb92e213dd98f2b401fdb33a003bbf4a4d99 Mon Sep 17 00:00:00 2001 From: Alyssa Travitz Date: Fri, 25 Jul 2025 15:42:00 -0700 Subject: [PATCH 027/105] Revert "validator -> field_validator" This reverts commit f59972e650ea5dfceb7fe6edc45f3006311d7a12. --- gufe/settings/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gufe/settings/models.py b/gufe/settings/models.py index 30ceab31a..bda830bd7 100644 --- a/gufe/settings/models.py +++ b/gufe/settings/models.py @@ -155,7 +155,7 @@ def is_positive_distance(cls, v): raise ValueError(errmsg) return v - @field_validator("constraints") + @validator("constraints") def constraint_check(cls, v): allowed = {"hbonds", "hangles", "allbonds"} From 7a56f3664577292f46c5264a6042389699506f03 Mon Sep 17 00:00:00 2001 From: Alyssa Travitz Date: Fri, 25 Jul 2025 15:53:43 -0700 Subject: [PATCH 028/105] add TODO --- gufe/settings/models.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/gufe/settings/models.py b/gufe/settings/models.py index bda830bd7..33dad6c1d 100644 --- a/gufe/settings/models.py +++ b/gufe/settings/models.py @@ -90,10 +90,10 @@ class ThermoSettings(SettingsBaseModel): No checking is done to ensure a valid thermodynamic ensemble is possible. """ - - temperature: _TemperatureQuantity = Field(None, description="Simulation temperature, default units kelvin") # TODO: make type equiv of FloatQuantity["kelvin"] = - pressure: _PressureQuantity = Field(None, description="Simulation pressure, default units standard atmosphere (atm)") # TODO: make type equiv FloatQuantity["standard_atmosphere"] - ph: PositiveFloat = Field(None, description="Simulation pH") + # TODO: do we actually want None to be valid here? + temperature: _TemperatureQuantity | None = Field(None, description="Simulation temperature, default units kelvin") # TODO: make type equiv of FloatQuantity["kelvin"] = + pressure: _PressureQuantity | None = Field(None, description="Simulation pressure, default units standard atmosphere (atm)") # TODO: make type equiv FloatQuantity["standard_atmosphere"] + ph: PositiveFloat | None = Field(None, description="Simulation pH") redox_potential: float | None = Field(None, description="Simulation redox potential") From 65e3b167da336df38357387f244e1323ae070f7a Mon Sep 17 00:00:00 2001 From: Alyssa Travitz Date: Mon, 28 Jul 2025 08:40:50 -0700 Subject: [PATCH 029/105] addressing deprecations --- gufe/serialization/json.py | 2 +- gufe/serialization/msgpack.py | 2 +- gufe/settings/models.py | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/gufe/serialization/json.py b/gufe/serialization/json.py index adcf25258..fae95400c 100644 --- a/gufe/serialization/json.py +++ b/gufe/serialization/json.py @@ -320,7 +320,7 @@ def is_legacy_path_dict(dct: dict) -> bool: SETTINGS_CODEC = JSONCodec( cls=SettingsBaseModel, - to_dict=lambda obj: {field: getattr(obj, field) for field in obj.__fields__}, + to_dict=lambda obj: {field: getattr(obj, field) for field in obj.model_fields}, from_dict=default_from_dict, is_my_dict=functools.partial(inherited_is_my_dict, cls=SettingsBaseModel), ) diff --git a/gufe/serialization/msgpack.py b/gufe/serialization/msgpack.py index 9a59bb063..6d7cf57d8 100644 --- a/gufe/serialization/msgpack.py +++ b/gufe/serialization/msgpack.py @@ -68,7 +68,7 @@ def pack_default(obj) -> msgpack.ExtType: return msgpack.ExtType(MPEXT.NPGENERIC, npg_payload) case SettingsBaseModel(): - settings_data = {field: getattr(obj, field) for field in obj.__fields__} + settings_data = {field: getattr(obj, field) for field in obj.model_fields} settings_data.update({"__class__": obj.__class__.__qualname__, "__module__": obj.__class__.__module__}) settings_payload: bytes = msgpack.packb(settings_data, default=pack_default) return msgpack.ExtType(MPEXT.SETTINGS, settings_payload) diff --git a/gufe/settings/models.py b/gufe/settings/models.py index 33dad6c1d..62f596499 100644 --- a/gufe/settings/models.py +++ b/gufe/settings/models.py @@ -9,7 +9,7 @@ from typing import Literal from openff.units import unit -from pydantic import AfterValidator, ConfigDict, Field, PositiveFloat, PrivateAttr, field_validator, validator +from pydantic import AfterValidator, ConfigDict, Field, PositiveFloat, PrivateAttr, field_validator from gufe.vendor.openff.interchange._annotations import _DistanceQuantity, _PressureQuantity, _TemperatureQuantity from gufe.vendor.openff.interchange.pydantic import _BaseModel @@ -155,7 +155,7 @@ def is_positive_distance(cls, v): raise ValueError(errmsg) return v - @validator("constraints") + @field_validator("constraints") def constraint_check(cls, v): allowed = {"hbonds", "hangles", "allbonds"} From d413189dc8699a12fa36071e3a74fb213c318615 Mon Sep 17 00:00:00 2001 From: Alyssa Travitz Date: Mon, 28 Jul 2025 08:47:24 -0700 Subject: [PATCH 030/105] update schema for v2 --- gufe/tests/test_models.py | 56 ++++++++++++++++++++++----------------- 1 file changed, 32 insertions(+), 24 deletions(-) diff --git a/gufe/tests/test_models.py b/gufe/tests/test_models.py index e0f5aed5d..132b48ac5 100644 --- a/gufe/tests/test_models.py +++ b/gufe/tests/test_models.py @@ -15,50 +15,58 @@ def test_settings_schema(): """Settings schema should be stable""" expected_schema = { - "title": "Settings", - "description": "Container for all settings needed by a protocol\n\nThis represents the minimal surface that all settings objects will have.\n\nProtocols can subclass this to extend this to cater for their additional settings.", - "type": "object", - "properties": { - "forcefield_settings": {"$ref": "#/definitions/BaseForceFieldSettings"}, - "thermo_settings": {"$ref": "#/definitions/ThermoSettings"}, - }, - "required": ["forcefield_settings", "thermo_settings"], - "additionalProperties": False, - "definitions": { + "$defs": { "BaseForceFieldSettings": { - "title": "BaseForceFieldSettings", + "additionalProperties": False, "description": "Base class for ForceFieldSettings objects", - "type": "object", "properties": {}, - "additionalProperties": False, + "title": "BaseForceFieldSettings", + "type": "object", }, "ThermoSettings": { - "title": "ThermoSettings", + "additionalProperties": False, "description": "Settings for thermodynamic parameters.\n\n.. note::\n No checking is done to ensure a valid thermodynamic ensemble is\n possible.", - "type": "object", "properties": { "temperature": { - "title": "Temperature", + "anyOf": [{"additionalProperties": True, "type": "object"}, {"type": "null"}], + "default": None, "description": "Simulation temperature, default units kelvin", - "type": "number", + "title": "Temperature", }, "pressure": { - "title": "Pressure", + "anyOf": [{"additionalProperties": True, "type": "object"}, {"type": "null"}], + "default": None, "description": "Simulation pressure, default units standard atmosphere (atm)", - "type": "number", + "title": "Pressure", + }, + "ph": { + "anyOf": [{"exclusiveMinimum": 0, "type": "number"}, {"type": "null"}], + "default": None, + "description": "Simulation pH", + "title": "Ph", }, - "ph": {"title": "Ph", "description": "Simulation pH", "exclusiveMinimum": 0, "type": "number"}, "redox_potential": { - "title": "Redox Potential", + "anyOf": [{"type": "number"}, {"type": "null"}], + "default": None, "description": "Simulation redox potential", - "type": "number", + "title": "Redox Potential", }, }, - "additionalProperties": False, + "title": "ThermoSettings", + "type": "object", }, }, + "additionalProperties": False, + "description": "Container for all settings needed by a protocol\n\nThis represents the minimal surface that all settings objects will have.\n\nProtocols can subclass this to extend this to cater for their additional settings.", + "properties": { + "forcefield_settings": {"$ref": "#/$defs/BaseForceFieldSettings"}, + "thermo_settings": {"$ref": "#/$defs/ThermoSettings"}, + }, + "required": ["forcefield_settings", "thermo_settings"], + "title": "Settings", + "type": "object", } - schema = Settings.model_json_schema(mode='serialization') + schema = Settings.model_json_schema(mode="serialization") assert schema == expected_schema From 4db6d6f05fcc18ba53c3b1763b1a56c39c73f371 Mon Sep 17 00:00:00 2001 From: Alyssa Travitz Date: Mon, 28 Jul 2025 09:28:57 -0700 Subject: [PATCH 031/105] add TODO --- gufe/tests/test_models.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/gufe/tests/test_models.py b/gufe/tests/test_models.py index 132b48ac5..3719da4bb 100644 --- a/gufe/tests/test_models.py +++ b/gufe/tests/test_models.py @@ -243,10 +243,11 @@ def test_frozen_equality(self): # the frozen-ness of Settings doesn't alter its contents # therefore a frozen/unfrozen Settings which are otherwise identical # should be considered equal - s = Settings.get_defaults() - s2 = s.frozen_copy() + s1 = Settings.get_defaults() + s2 = s1.frozen_copy() - assert s == s2 + # TODO: equality checks have changed in v2 such that this is no longer true + assert s1 == s2 def test_set_subsection(self): # check that attempting to set a subsection of settings still respects From 3f5f0c56735082e852120a3b47f253ec5fef4fcf Mon Sep 17 00:00:00 2001 From: Alyssa Travitz Date: Mon, 28 Jul 2025 09:36:58 -0700 Subject: [PATCH 032/105] reproduce v1 equality check --- gufe/vendor/openff/interchange/pydantic.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/gufe/vendor/openff/interchange/pydantic.py b/gufe/vendor/openff/interchange/pydantic.py index d81ebf7bd..6a1197c83 100644 --- a/gufe/vendor/openff/interchange/pydantic.py +++ b/gufe/vendor/openff/interchange/pydantic.py @@ -18,3 +18,12 @@ def model_dump(self, **kwargs) -> dict[str, Any]: def model_dump_json(self, **kwargs) -> str: return super().model_dump_json(serialize_as_any=True, **kwargs) + + def __eq__(self, other: Any) -> bool: + # reproduces pydantic v1 equality, since v2 checks for private attr equality, + # which results in frozen/unfrozen objects not being equal + # https://github.com/pydantic/pydantic/blob/2486e068e85c51728c9f2d344cfee2f7e11d555c/pydantic/v1/main.py#L911 + if isinstance(other, BaseModel): + return self.model_dump() == other.model_dump() + else: + return self.model_dump() == other From 096b537b2d06f3816c3c722a60203b02a472e0c8 Mon Sep 17 00:00:00 2001 From: Alyssa Travitz Date: Mon, 28 Jul 2025 10:07:07 -0700 Subject: [PATCH 033/105] updating gufe token (need to doublecheck validity) --- docs/concepts/tokenizables.rst | 12 ++++++------ gufe/tests/test_transformation.py | 4 ++-- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/docs/concepts/tokenizables.rst b/docs/concepts/tokenizables.rst index 1adb8c3a7..6046b88db 100644 --- a/docs/concepts/tokenizables.rst +++ b/docs/concepts/tokenizables.rst @@ -238,9 +238,9 @@ Any nested ``GufeTokenizable``\s are left as-is. ChemicalSystem(name=phenol-solvent, components={'ligand': SmallMoleculeComponent(name=phenol), 'solvent': SolventComponent(name=O, K+, Cl-)}) ], 'edges': [ - Transformation(stateA=ChemicalSystem(name=benzene-solvent, components={'ligand': SmallMoleculeComponent(name=benzene), 'solvent': SolventComponent(name=O, K+, Cl-)}), stateB=ChemicalSystem(name=toluene-solvent, components={'ligand': SmallMoleculeComponent(name=toluene), 'solvent': SolventComponent(name=O, K+, Cl-)}), protocol=, name=None), - Transformation(stateA=ChemicalSystem(name=benzene-solvent, components={'ligand': SmallMoleculeComponent(name=benzene), 'solvent': SolventComponent(name=O, K+, Cl-)}), stateB=ChemicalSystem(name=styrene-solvent, components={'ligand': SmallMoleculeComponent(name=styrene), 'solvent': SolventComponent(name=O, K+, Cl-)}), protocol=, name=None), - Transformation(stateA=ChemicalSystem(name=benzene-solvent, components={'ligand': SmallMoleculeComponent(name=benzene), 'solvent': SolventComponent(name=O, K+, Cl-)}), stateB=ChemicalSystem(name=phenol-solvent, components={'ligand': SmallMoleculeComponent(name=phenol), 'solvent': SolventComponent(name=O, K+, Cl-)}), protocol=, name=None) + Transformation(stateA=ChemicalSystem(name=benzene-solvent, components={'ligand': SmallMoleculeComponent(name=benzene), 'solvent': SolventComponent(name=O, K+, Cl-)}), stateB=ChemicalSystem(name=toluene-solvent, components={'ligand': SmallMoleculeComponent(name=toluene), 'solvent': SolventComponent(name=O, K+, Cl-)}), protocol=, name=None), + Transformation(stateA=ChemicalSystem(name=benzene-solvent, components={'ligand': SmallMoleculeComponent(name=benzene), 'solvent': SolventComponent(name=O, K+, Cl-)}), stateB=ChemicalSystem(name=styrene-solvent, components={'ligand': SmallMoleculeComponent(name=styrene), 'solvent': SolventComponent(name=O, K+, Cl-)}), protocol=, name=None), + Transformation(stateA=ChemicalSystem(name=benzene-solvent, components={'ligand': SmallMoleculeComponent(name=benzene), 'solvent': SolventComponent(name=O, K+, Cl-)}), stateB=ChemicalSystem(name=phenol-solvent, components={'ligand': SmallMoleculeComponent(name=phenol), 'solvent': SolventComponent(name=O, K+, Cl-)}), protocol=, name=None) ], 'name': None, '__qualname__': 'AlchemicalNetwork', @@ -310,7 +310,7 @@ To show the structure of a keyed chain, below we have redacted all information e ('SmallMoleculeComponent-3b51f5f92521c712049da092ab061930', {...}), ('SmallMoleculeComponent-ec3c7a92771f8872dab1a9fc4911c795', {...}), ('SmallMoleculeComponent-8225dfb11f2e8157a3fcdcd673d3d40e', {...}), - ('Protocol-d01baed9cf2500c393bd6ddb35ee38aa', {...}), + ('Protocol-489fb1395a32c5183bcc1d43fa521960', {...}), ('ChemicalSystem-ba83a53f18700b3738680da051ff35f3', { 'components': { 'ligand': {':gufe-key:': 'SmallMoleculeComponent-3b51f5f92521c712049da092ab061930'}, @@ -332,12 +332,12 @@ To show the structure of a keyed chain, below we have redacted all information e ('Transformation-e8d1ccf53116e210d1ccbc3870007271', { 'stateA': {':gufe-key:': 'ChemicalSystem-3c648332ff8dccc03a1e1a3d44bc9755'}, 'stateB': {':gufe-key:': 'ChemicalSystem-ba83a53f18700b3738680da051ff35f3'}, - 'protocol': {':gufe-key:': 'DummyProtocol-d01baed9cf2500c393bd6ddb35ee38aa'}, + 'protocol': {':gufe-key:': 'DummyProtocol-489fb1395a32c5183bcc1d43fa521960'}, ...}), ('Transformation-4d0f802817071c8d14b37efd35187318', { 'stateA': {':gufe-key:': 'ChemicalSystem-3c648332ff8dccc03a1e1a3d44bc9755'}, 'stateB': {':gufe-key:': 'ChemicalSystem-655f4d0008a537fe811b11a2dc4a029e'}, - 'protocol': {':gufe-key:': 'DummyProtocol-d01baed9cf2500c393bd6ddb35ee38aa'}, + 'protocol': {':gufe-key:': 'DummyProtocol-489fb1395a32c5183bcc1d43fa521960'}, ...}), ('AlchemicalNetwork-f8bfd63bc848672aa52b081b4d68fadf', { 'nodes': [ diff --git a/gufe/tests/test_transformation.py b/gufe/tests/test_transformation.py index c70bdac3f..7b79a0297 100644 --- a/gufe/tests/test_transformation.py +++ b/gufe/tests/test_transformation.py @@ -35,7 +35,7 @@ def complex_equilibrium(solvated_complex): class TestTransformation(GufeTokenizableTestsMixin): cls = Transformation - repr = "Transformation(stateA=ChemicalSystem(name=, components={'ligand': SmallMoleculeComponent(name=toluene), 'solvent': SolventComponent(name=O, K+, Cl-)}), stateB=ChemicalSystem(name=, components={'protein': ProteinComponent(name=), 'solvent': SolventComponent(name=O, K+, Cl-), 'ligand': SmallMoleculeComponent(name=toluene)}), protocol=, name=None)" + repr = "Transformation(stateA=ChemicalSystem(name=, components={'ligand': SmallMoleculeComponent(name=toluene), 'solvent': SolventComponent(name=O, K+, Cl-)}), stateB=ChemicalSystem(name=, components={'protein': ProteinComponent(name=), 'solvent': SolventComponent(name=O, K+, Cl-), 'ligand': SmallMoleculeComponent(name=toluene)}), protocol=, name=None)" @pytest.fixture def instance(self, absolute_transformation): @@ -174,7 +174,7 @@ def test_deprecation_warning_on_dict_mapping(self, solvated_ligand, solvated_com class TestNonTransformation(GufeTokenizableTestsMixin): cls = NonTransformation - repr = "NonTransformation(system=ChemicalSystem(name=, components={'protein': ProteinComponent(name=), 'solvent': SolventComponent(name=O, K+, Cl-), 'ligand': SmallMoleculeComponent(name=toluene)}), protocol=, name=None)" + repr = "NonTransformation(system=ChemicalSystem(name=, components={'protein': ProteinComponent(name=), 'solvent': SolventComponent(name=O, K+, Cl-), 'ligand': SmallMoleculeComponent(name=toluene)}), protocol=, name=None)" @pytest.fixture def instance(self, complex_equilibrium): From ea51c469864a070c63a96be65b4828008cdb3886 Mon Sep 17 00:00:00 2001 From: Alyssa Travitz Date: Mon, 28 Jul 2025 14:07:06 -0700 Subject: [PATCH 034/105] adding docs --- gufe/vendor/openff/interchange/_annotations.py | 18 ++++++++++++++++++ gufe/vendor/openff/interchange/pydantic.py | 1 + 2 files changed, 19 insertions(+) diff --git a/gufe/vendor/openff/interchange/_annotations.py b/gufe/vendor/openff/interchange/_annotations.py index 281006c3a..f4b4cda30 100644 --- a/gufe/vendor/openff/interchange/_annotations.py +++ b/gufe/vendor/openff/interchange/_annotations.py @@ -1,3 +1,6 @@ +# Vendored from https://github.com/openforcefield/openff-interchange/blob/main/openff/interchange/_annotations.py +# (with some adjustments and additions) + import functools from collections.abc import Callable from typing import Annotated, Any @@ -65,6 +68,7 @@ def _unit_validator_factory(unit: str) -> Callable: _is_dimensionless, _is_kj_mol, _is_nanometer, + _is_angstrom, _is_degree, _is_elementary_charge, ) = ( @@ -73,6 +77,7 @@ def _unit_validator_factory(unit: str) -> Callable: "dimensionless", "kilojoule / mole", "nanometer", + "angstrom", "degree", "elementary_charge", ] @@ -155,6 +160,19 @@ def quantity_json_serializer( _LengthQuantity = _DistanceQuantity +_NanometerQuantity = Annotated[ + Quantity, + WrapValidator(quantity_validator), + AfterValidator(_is_nanometer), + WrapSerializer(quantity_json_serializer), +] + +_AngstromQuantity = Annotated[ + Quantity, + WrapValidator(quantity_validator), + AfterValidator(_is_angstrom), + WrapSerializer(quantity_json_serializer), +] _VelocityQuantity = Annotated[ Quantity, WrapValidator(quantity_validator), diff --git a/gufe/vendor/openff/interchange/pydantic.py b/gufe/vendor/openff/interchange/pydantic.py index 6a1197c83..aa7b2ea31 100644 --- a/gufe/vendor/openff/interchange/pydantic.py +++ b/gufe/vendor/openff/interchange/pydantic.py @@ -1,3 +1,4 @@ +# Vendored from https://github.com/openforcefield/openff-interchange/blob/main/openff/interchange/pydantic.py """Pydantic base model with custom settings.""" from typing import Any From 6ce52fee16847819a1cb3c10f6adb6c4498d7c84 Mon Sep 17 00:00:00 2001 From: Alyssa Travitz Date: Mon, 28 Jul 2025 14:29:29 -0700 Subject: [PATCH 035/105] add nanometer quantity --- gufe/settings/models.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/gufe/settings/models.py b/gufe/settings/models.py index 62f596499..620443bfc 100644 --- a/gufe/settings/models.py +++ b/gufe/settings/models.py @@ -9,9 +9,9 @@ from typing import Literal from openff.units import unit -from pydantic import AfterValidator, ConfigDict, Field, PositiveFloat, PrivateAttr, field_validator +from pydantic import AfterValidator, ConfigDict, Field, PositiveFloat, PrivateAttr, field_validator, -from gufe.vendor.openff.interchange._annotations import _DistanceQuantity, _PressureQuantity, _TemperatureQuantity +from gufe.vendor.openff.interchange._annotations import _NanometerQuantity, _PressureQuantity, _TemperatureQuantity from gufe.vendor.openff.interchange.pydantic import _BaseModel @@ -20,7 +20,8 @@ class SettingsBaseModel(_BaseModel): _is_frozen: bool = PrivateAttr(default_factory=lambda: False) model_config = ConfigDict(extra='forbid', - # arbitrary_types_allowed=True # TODO: this was previously False, try to change back + # TODO: needs to be True for current pydantic v2 implementation, try to change back + # arbitrary_types_allowed=False ) def _ipython_display_(self): @@ -142,7 +143,7 @@ class OpenMMSystemGeneratorFFSettings(BaseForceFieldSettings): "CutoffNonPeriodic", "CutoffPeriodic", "Ewald", "LJPME", "NoCutoff", "PME". Default PME. """ - nonbonded_cutoff: _DistanceQuantity=1.0 * unit.nanometer # FloatQuantity["nanometer"] = 1.0 * unit.nanometer + nonbonded_cutoff: _NanometerQuantity=1.0 * unit.nanometer # FloatQuantity["nanometer"] = 1.0 * unit.nanometer """ Cutoff value for short range nonbonded interactions. Default 1.0 * unit.nanometer. From 812b45d7d44ee3b3966baa6bbac647cc7ad4c2b1 Mon Sep 17 00:00:00 2001 From: Alyssa Travitz Date: Tue, 29 Jul 2025 14:48:36 -0700 Subject: [PATCH 036/105] update todo --- gufe/settings/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gufe/settings/models.py b/gufe/settings/models.py index 620443bfc..7d216bfad 100644 --- a/gufe/settings/models.py +++ b/gufe/settings/models.py @@ -182,5 +182,5 @@ class Settings(SettingsBaseModel): def get_defaults(cls): return Settings( forcefield_settings=OpenMMSystemGeneratorFFSettings(), - thermo_settings=ThermoSettings(temperature=300 * unit.kelvin), + thermo_settings=ThermoSettings(temperature=300 * unit.kelvin), # TODO: use InstanceOf for validation here? ) From 11e7b4042335e0dc5d4067f8a85946caa518bcc5 Mon Sep 17 00:00:00 2001 From: Alyssa Travitz Date: Tue, 29 Jul 2025 15:31:35 -0700 Subject: [PATCH 037/105] keep vendored code clean --- .../vendor/openff/interchange/_annotations.py | 37 ++----------------- gufe/vendor/openff/interchange/pydantic.py | 9 ----- 2 files changed, 3 insertions(+), 43 deletions(-) diff --git a/gufe/vendor/openff/interchange/_annotations.py b/gufe/vendor/openff/interchange/_annotations.py index f4b4cda30..f50dc3f1a 100644 --- a/gufe/vendor/openff/interchange/_annotations.py +++ b/gufe/vendor/openff/interchange/_annotations.py @@ -1,6 +1,4 @@ # Vendored from https://github.com/openforcefield/openff-interchange/blob/main/openff/interchange/_annotations.py -# (with some adjustments and additions) - import functools from collections.abc import Callable from typing import Annotated, Any @@ -52,7 +50,6 @@ def _unit_validator_factory(unit: str) -> Callable: _is_velocity, _is_mass, _is_temperature, - _is_pressure, ) = ( _dimensionality_validator_factory(unit=_unit) for _unit in [ @@ -60,7 +57,6 @@ def _unit_validator_factory(unit: str) -> Callable: "nanometer / picosecond", "unified_atomic_mass_unit", "kelvin", - "atm" ] ) @@ -68,7 +64,6 @@ def _unit_validator_factory(unit: str) -> Callable: _is_dimensionless, _is_kj_mol, _is_nanometer, - _is_angstrom, _is_degree, _is_elementary_charge, ) = ( @@ -77,7 +72,6 @@ def _unit_validator_factory(unit: str) -> Callable: "dimensionless", "kilojoule / mole", "nanometer", - "angstrom", "degree", "elementary_charge", ] @@ -119,20 +113,15 @@ def quantity_json_serializer( nxt, ) -> dict: """Serialize a Quantity to a JSON-compatible dictionary.""" - # TODO: this None handling is a temporary workaround and should be fixed! - if quantity is None: - magnitude = quantity - unit = "" - else: - magnitude = quantity.m - unit = str(quantity.units) + magnitude = quantity.m + if isinstance(magnitude, numpy.ndarray): # This could be something fancier, list a bytestring magnitude = magnitude.tolist() return { "val": magnitude, - "unit": unit, + "unit": str(quantity.units), } @@ -160,19 +149,6 @@ def quantity_json_serializer( _LengthQuantity = _DistanceQuantity -_NanometerQuantity = Annotated[ - Quantity, - WrapValidator(quantity_validator), - AfterValidator(_is_nanometer), - WrapSerializer(quantity_json_serializer), -] - -_AngstromQuantity = Annotated[ - Quantity, - WrapValidator(quantity_validator), - AfterValidator(_is_angstrom), - WrapSerializer(quantity_json_serializer), -] _VelocityQuantity = Annotated[ Quantity, WrapValidator(quantity_validator), @@ -194,13 +170,6 @@ def quantity_json_serializer( WrapSerializer(quantity_json_serializer), ] -_PressureQuantity = Annotated[ - Quantity, - WrapValidator(quantity_validator), - AfterValidator(_is_pressure), - WrapSerializer(quantity_json_serializer), -] - _DegreeQuantity = Annotated[ Quantity, WrapValidator(quantity_validator), diff --git a/gufe/vendor/openff/interchange/pydantic.py b/gufe/vendor/openff/interchange/pydantic.py index aa7b2ea31..88853fd8f 100644 --- a/gufe/vendor/openff/interchange/pydantic.py +++ b/gufe/vendor/openff/interchange/pydantic.py @@ -19,12 +19,3 @@ def model_dump(self, **kwargs) -> dict[str, Any]: def model_dump_json(self, **kwargs) -> str: return super().model_dump_json(serialize_as_any=True, **kwargs) - - def __eq__(self, other: Any) -> bool: - # reproduces pydantic v1 equality, since v2 checks for private attr equality, - # which results in frozen/unfrozen objects not being equal - # https://github.com/pydantic/pydantic/blob/2486e068e85c51728c9f2d344cfee2f7e11d555c/pydantic/v1/main.py#L911 - if isinstance(other, BaseModel): - return self.model_dump() == other.model_dump() - else: - return self.model_dump() == other From cb5a7ac122d5e06ab6e160991fae51f2051b0ede Mon Sep 17 00:00:00 2001 From: Alyssa Travitz Date: Tue, 29 Jul 2025 15:34:10 -0700 Subject: [PATCH 038/105] push equality workaround to child class --- gufe/settings/models.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/gufe/settings/models.py b/gufe/settings/models.py index 7d216bfad..82f9ad05a 100644 --- a/gufe/settings/models.py +++ b/gufe/settings/models.py @@ -83,6 +83,14 @@ def __setattr__(self, name, value): ) return super().__setattr__(name, value) + def __eq__(self, other: Any) -> bool: + # reproduces pydantic v1 equality, since v2 checks for private attr equality, + # which results in frozen/unfrozen objects not being equal + # https://github.com/pydantic/pydantic/blob/2486e068e85c51728c9f2d344cfee2f7e11d555c/pydantic/v1/main.py#L911 + if isinstance(other, BaseModel): + return self.model_dump() == other.model_dump() + else: + return self.model_dump() == other class ThermoSettings(SettingsBaseModel): """Settings for thermodynamic parameters. From 66ec81fe77753d478c5c767608330bd29ba61634 Mon Sep 17 00:00:00 2001 From: Alyssa Travitz Date: Tue, 29 Jul 2025 15:54:00 -0700 Subject: [PATCH 039/105] make custom annotations --- gufe/settings/annotations.py | 61 ++++++++++++++++++++++++++++++++++++ gufe/settings/models.py | 9 +++--- gufe/tests/test_models.py | 3 +- 3 files changed, 68 insertions(+), 5 deletions(-) create mode 100644 gufe/settings/annotations.py diff --git a/gufe/settings/annotations.py b/gufe/settings/annotations.py new file mode 100644 index 000000000..623e0c0ba --- /dev/null +++ b/gufe/settings/annotations.py @@ -0,0 +1,61 @@ +# adapted from from https://github.com/openforcefield/openff-interchange/blob/main/openff/interchange/_annotations.py + + +from typing import Annotated + +from openff.toolkit import Quantity + +from gufe.vendor.openff.interchange._annotations import ( + quantity_json_serializer, + quantity_validator, + _unit_validator_factory, +) + +from pydantic import ( + AfterValidator, + WrapSerializer, + WrapValidator, +) + + +( + _is_dimensionless, + _is_kj_mol, + _is_nanometer, + _is_atm, + _is_angstrom, + _is_degree, + _is_elementary_charge, +) = ( + _unit_validator_factory(unit=_unit) + for _unit in [ + "dimensionless", + "kilojoule / mole", + "nanometer", + "atm", + "angstrom", + "degree", + "elementary_charge", + ] +) + +_NanometerQuantity = Annotated[ + Quantity, + WrapValidator(quantity_validator), + AfterValidator(_is_nanometer), + WrapSerializer(quantity_json_serializer), +] + +_PressureQuantity = Annotated[ + Quantity, + WrapValidator(quantity_validator), + AfterValidator(_is_atm), + WrapSerializer(quantity_json_serializer), +] + +# _AngstromQuantity = Annotated[ +# Quantity, +# WrapValidator(quantity_validator), +# AfterValidator(_is_angstrom), +# WrapSerializer(quantity_json_serializer), +# ] diff --git a/gufe/settings/models.py b/gufe/settings/models.py index 82f9ad05a..896fff559 100644 --- a/gufe/settings/models.py +++ b/gufe/settings/models.py @@ -6,12 +6,13 @@ import abc import pprint -from typing import Literal +from typing import Literal, Any from openff.units import unit -from pydantic import AfterValidator, ConfigDict, Field, PositiveFloat, PrivateAttr, field_validator, +from pydantic import AfterValidator, ConfigDict, Field, PositiveFloat, PrivateAttr, field_validator -from gufe.vendor.openff.interchange._annotations import _NanometerQuantity, _PressureQuantity, _TemperatureQuantity +from .annotations import _NanometerQuantity, _PressureQuantity +from gufe.vendor.openff.interchange._annotations import _TemperatureQuantity from gufe.vendor.openff.interchange.pydantic import _BaseModel @@ -87,7 +88,7 @@ def __eq__(self, other: Any) -> bool: # reproduces pydantic v1 equality, since v2 checks for private attr equality, # which results in frozen/unfrozen objects not being equal # https://github.com/pydantic/pydantic/blob/2486e068e85c51728c9f2d344cfee2f7e11d555c/pydantic/v1/main.py#L911 - if isinstance(other, BaseModel): + if isinstance(other, super): return self.model_dump() == other.model_dump() else: return self.model_dump() == other diff --git a/gufe/tests/test_models.py b/gufe/tests/test_models.py index 3719da4bb..112d1bd45 100644 --- a/gufe/tests/test_models.py +++ b/gufe/tests/test_models.py @@ -106,7 +106,8 @@ def test_openmmff_constraints(self, value, valid, expected): ("1.1 nm", True, 1.1 * unit.nanometer), ("1.1", False, None), (-1.0 * unit.nanometer, False, None), - (1.0 * unit.angstrom, True, 1.0 * unit.angstrom), # TODO: should we convert this to nm? + # NOTE: this is not precisely equal for smaller values due to pint unit floating point precision things. + (100.0 * unit.angstrom, True, 10.0 * unit.nanometer), (300 * unit.kelvin, False, None), (True, False, None), (None, False, None), From 8943eb3942a4988b12c07d53866244ad44804cf8 Mon Sep 17 00:00:00 2001 From: Alyssa Travitz Date: Tue, 29 Jul 2025 15:59:43 -0700 Subject: [PATCH 040/105] switch to atm quantity --- gufe/settings/annotations.py | 38 ++++++++---------------------------- gufe/settings/models.py | 6 +++--- 2 files changed, 11 insertions(+), 33 deletions(-) diff --git a/gufe/settings/annotations.py b/gufe/settings/annotations.py index 623e0c0ba..f6529a1bd 100644 --- a/gufe/settings/annotations.py +++ b/gufe/settings/annotations.py @@ -4,55 +4,33 @@ from typing import Annotated from openff.toolkit import Quantity - -from gufe.vendor.openff.interchange._annotations import ( - quantity_json_serializer, - quantity_validator, - _unit_validator_factory, -) - from pydantic import ( AfterValidator, WrapSerializer, WrapValidator, ) - -( - _is_dimensionless, - _is_kj_mol, - _is_nanometer, - _is_atm, - _is_angstrom, - _is_degree, - _is_elementary_charge, -) = ( - _unit_validator_factory(unit=_unit) - for _unit in [ - "dimensionless", - "kilojoule / mole", - "nanometer", - "atm", - "angstrom", - "degree", - "elementary_charge", - ] +from gufe.vendor.openff.interchange._annotations import ( + _unit_validator_factory, + quantity_json_serializer, + quantity_validator, ) _NanometerQuantity = Annotated[ Quantity, WrapValidator(quantity_validator), - AfterValidator(_is_nanometer), + AfterValidator(_unit_validator_factory("nanometer")), WrapSerializer(quantity_json_serializer), ] -_PressureQuantity = Annotated[ +_AtmQuantity = Annotated[ Quantity, WrapValidator(quantity_validator), - AfterValidator(_is_atm), + AfterValidator(_unit_validator_factory("atm")), WrapSerializer(quantity_json_serializer), ] +# TODO: openfe will need this. # _AngstromQuantity = Annotated[ # Quantity, # WrapValidator(quantity_validator), diff --git a/gufe/settings/models.py b/gufe/settings/models.py index 896fff559..b9949ddc0 100644 --- a/gufe/settings/models.py +++ b/gufe/settings/models.py @@ -9,9 +9,9 @@ from typing import Literal, Any from openff.units import unit -from pydantic import AfterValidator, ConfigDict, Field, PositiveFloat, PrivateAttr, field_validator +from pydantic import AfterValidator, ConfigDict, Field, InstanceOf, PositiveFloat, PrivateAttr, field_validator -from .annotations import _NanometerQuantity, _PressureQuantity +from .annotations import _NanometerQuantity, _AtmQuantity from gufe.vendor.openff.interchange._annotations import _TemperatureQuantity from gufe.vendor.openff.interchange.pydantic import _BaseModel @@ -102,7 +102,7 @@ class ThermoSettings(SettingsBaseModel): """ # TODO: do we actually want None to be valid here? temperature: _TemperatureQuantity | None = Field(None, description="Simulation temperature, default units kelvin") # TODO: make type equiv of FloatQuantity["kelvin"] = - pressure: _PressureQuantity | None = Field(None, description="Simulation pressure, default units standard atmosphere (atm)") # TODO: make type equiv FloatQuantity["standard_atmosphere"] + pressure: _AtmQuantity | None = Field(None, description="Simulation pressure, default units standard atmosphere (atm)") # TODO: make type equiv FloatQuantity["standard_atmosphere"] ph: PositiveFloat | None = Field(None, description="Simulation pH") redox_potential: float | None = Field(None, description="Simulation redox potential") From 48474de2c1878451d913f20a0e418e0e544ae9a5 Mon Sep 17 00:00:00 2001 From: Alyssa Travitz Date: Tue, 29 Jul 2025 16:00:02 -0700 Subject: [PATCH 041/105] add InstanceOf for extra validation --- gufe/settings/models.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/gufe/settings/models.py b/gufe/settings/models.py index b9949ddc0..f4c5f8521 100644 --- a/gufe/settings/models.py +++ b/gufe/settings/models.py @@ -184,8 +184,8 @@ class Settings(SettingsBaseModel): Protocols can subclass this to extend this to cater for their additional settings. """ - forcefield_settings: BaseForceFieldSettings - thermo_settings: ThermoSettings + forcefield_settings: InstanceOf[BaseForceFieldSettings] + thermo_settings: InstanceOf[ThermoSettings] @classmethod def get_defaults(cls): From 3c62f263fcc6dbb7e81205da315d0567367d51b1 Mon Sep 17 00:00:00 2001 From: Alyssa Travitz Date: Tue, 29 Jul 2025 16:00:18 -0700 Subject: [PATCH 042/105] comment out schema regression check for now since things will change --- gufe/tests/test_models.py | 106 +++++++++++++++++++------------------- 1 file changed, 53 insertions(+), 53 deletions(-) diff --git a/gufe/tests/test_models.py b/gufe/tests/test_models.py index 112d1bd45..e763d6961 100644 --- a/gufe/tests/test_models.py +++ b/gufe/tests/test_models.py @@ -14,60 +14,60 @@ def test_settings_schema(): """Settings schema should be stable""" - expected_schema = { - "$defs": { - "BaseForceFieldSettings": { - "additionalProperties": False, - "description": "Base class for ForceFieldSettings objects", - "properties": {}, - "title": "BaseForceFieldSettings", - "type": "object", - }, - "ThermoSettings": { - "additionalProperties": False, - "description": "Settings for thermodynamic parameters.\n\n.. note::\n No checking is done to ensure a valid thermodynamic ensemble is\n possible.", - "properties": { - "temperature": { - "anyOf": [{"additionalProperties": True, "type": "object"}, {"type": "null"}], - "default": None, - "description": "Simulation temperature, default units kelvin", - "title": "Temperature", - }, - "pressure": { - "anyOf": [{"additionalProperties": True, "type": "object"}, {"type": "null"}], - "default": None, - "description": "Simulation pressure, default units standard atmosphere (atm)", - "title": "Pressure", - }, - "ph": { - "anyOf": [{"exclusiveMinimum": 0, "type": "number"}, {"type": "null"}], - "default": None, - "description": "Simulation pH", - "title": "Ph", - }, - "redox_potential": { - "anyOf": [{"type": "number"}, {"type": "null"}], - "default": None, - "description": "Simulation redox potential", - "title": "Redox Potential", - }, - }, - "title": "ThermoSettings", - "type": "object", - }, - }, - "additionalProperties": False, - "description": "Container for all settings needed by a protocol\n\nThis represents the minimal surface that all settings objects will have.\n\nProtocols can subclass this to extend this to cater for their additional settings.", - "properties": { - "forcefield_settings": {"$ref": "#/$defs/BaseForceFieldSettings"}, - "thermo_settings": {"$ref": "#/$defs/ThermoSettings"}, - }, - "required": ["forcefield_settings", "thermo_settings"], - "title": "Settings", - "type": "object", - } + # expected_schema = { + # "$defs": { + # "BaseForceFieldSettings": { + # "additionalProperties": False, + # "description": "Base class for ForceFieldSettings objects", + # "properties": {}, + # "title": "BaseForceFieldSettings", + # "type": "object", + # }, + # "ThermoSettings": { + # "additionalProperties": False, + # "description": "Settings for thermodynamic parameters.\n\n.. note::\n No checking is done to ensure a valid thermodynamic ensemble is\n possible.", + # "properties": { + # "temperature": { + # "anyOf": [{"additionalProperties": True, "type": "object"}, {"type": "null"}], + # "default": None, + # "description": "Simulation temperature, default units kelvin", + # "title": "Temperature", + # }, + # "pressure": { + # "anyOf": [{"additionalProperties": True, "type": "object"}, {"type": "null"}], + # "default": None, + # "description": "Simulation pressure, default units standard atmosphere (atm)", + # "title": "Pressure", + # }, + # "ph": { + # "anyOf": [{"exclusiveMinimum": 0, "type": "number"}, {"type": "null"}], + # "default": None, + # "description": "Simulation pH", + # "title": "Ph", + # }, + # "redox_potential": { + # "anyOf": [{"type": "number"}, {"type": "null"}], + # "default": None, + # "description": "Simulation redox potential", + # "title": "Redox Potential", + # }, + # }, + # "title": "ThermoSettings", + # "type": "object", + # }, + # }, + # "additionalProperties": False, + # "description": "Container for all settings needed by a protocol\n\nThis represents the minimal surface that all settings objects will have.\n\nProtocols can subclass this to extend this to cater for their additional settings.", + # "properties": { + # "forcefield_settings": {"$ref": "#/$defs/BaseForceFieldSettings"}, + # "thermo_settings": {"$ref": "#/$defs/ThermoSettings"}, + # }, + # "required": ["forcefield_settings", "thermo_settings"], + # "title": "Settings", + # "type": "object", + # } schema = Settings.model_json_schema(mode="serialization") - assert schema == expected_schema + # assert schema == expected_schema def test_default_settings(): From 08b27b6d658a3e565a900e504428d88c2747141e Mon Sep 17 00:00:00 2001 From: Alyssa Travitz Date: Tue, 29 Jul 2025 16:05:11 -0700 Subject: [PATCH 043/105] add kelvin quantity, make public type --- gufe/settings/annotations.py | 12 +++++++++--- gufe/settings/models.py | 14 +++++++------- 2 files changed, 16 insertions(+), 10 deletions(-) diff --git a/gufe/settings/annotations.py b/gufe/settings/annotations.py index f6529a1bd..499165630 100644 --- a/gufe/settings/annotations.py +++ b/gufe/settings/annotations.py @@ -1,6 +1,5 @@ # adapted from from https://github.com/openforcefield/openff-interchange/blob/main/openff/interchange/_annotations.py - from typing import Annotated from openff.toolkit import Quantity @@ -16,20 +15,27 @@ quantity_validator, ) -_NanometerQuantity = Annotated[ +NanometerQuantity = Annotated[ Quantity, WrapValidator(quantity_validator), AfterValidator(_unit_validator_factory("nanometer")), WrapSerializer(quantity_json_serializer), ] -_AtmQuantity = Annotated[ +AtmQuantity = Annotated[ Quantity, WrapValidator(quantity_validator), AfterValidator(_unit_validator_factory("atm")), WrapSerializer(quantity_json_serializer), ] +KelvinQuantity = Annotated[ + Quantity, + WrapValidator(quantity_validator), + AfterValidator(_unit_validator_factory("kelvin")), + WrapSerializer(quantity_json_serializer), +] + # TODO: openfe will need this. # _AngstromQuantity = Annotated[ # Quantity, diff --git a/gufe/settings/models.py b/gufe/settings/models.py index f4c5f8521..86cdb506f 100644 --- a/gufe/settings/models.py +++ b/gufe/settings/models.py @@ -6,15 +6,15 @@ import abc import pprint -from typing import Literal, Any +from typing import Any, Literal from openff.units import unit -from pydantic import AfterValidator, ConfigDict, Field, InstanceOf, PositiveFloat, PrivateAttr, field_validator +from pydantic import ConfigDict, Field, InstanceOf, PositiveFloat, PrivateAttr, field_validator -from .annotations import _NanometerQuantity, _AtmQuantity -from gufe.vendor.openff.interchange._annotations import _TemperatureQuantity from gufe.vendor.openff.interchange.pydantic import _BaseModel +from .annotations import AtmQuantity, KelvinQuantity, NanometerQuantity + class SettingsBaseModel(_BaseModel): """Settings and modifications we want for all settings classes.""" @@ -101,8 +101,8 @@ class ThermoSettings(SettingsBaseModel): possible. """ # TODO: do we actually want None to be valid here? - temperature: _TemperatureQuantity | None = Field(None, description="Simulation temperature, default units kelvin") # TODO: make type equiv of FloatQuantity["kelvin"] = - pressure: _AtmQuantity | None = Field(None, description="Simulation pressure, default units standard atmosphere (atm)") # TODO: make type equiv FloatQuantity["standard_atmosphere"] + temperature: KelvinQuantity | None = Field(None, description="Simulation temperature in kelvin)") + pressure: AtmQuantity | None = Field(None, description="Simulation pressure in standard atmosphere (atm)") ph: PositiveFloat | None = Field(None, description="Simulation pH") redox_potential: float | None = Field(None, description="Simulation redox potential") @@ -152,7 +152,7 @@ class OpenMMSystemGeneratorFFSettings(BaseForceFieldSettings): "CutoffNonPeriodic", "CutoffPeriodic", "Ewald", "LJPME", "NoCutoff", "PME". Default PME. """ - nonbonded_cutoff: _NanometerQuantity=1.0 * unit.nanometer # FloatQuantity["nanometer"] = 1.0 * unit.nanometer + nonbonded_cutoff: NanometerQuantity=1.0 * unit.nanometer # FloatQuantity["nanometer"] = 1.0 * unit.nanometer """ Cutoff value for short range nonbonded interactions. Default 1.0 * unit.nanometer. From 013d8f55057bd4beb7aaea94027aae1b79b387cf Mon Sep 17 00:00:00 2001 From: Alyssa Travitz Date: Tue, 29 Jul 2025 16:23:04 -0700 Subject: [PATCH 044/105] adding case insensitive enum --- gufe/settings/models.py | 20 ++++++++------------ gufe/settings/{annotations.py => types.py} | 12 ++++++++++++ gufe/tests/test_models.py | 2 +- 3 files changed, 21 insertions(+), 13 deletions(-) rename gufe/settings/{annotations.py => types.py} (77%) diff --git a/gufe/settings/models.py b/gufe/settings/models.py index 86cdb506f..6daddd156 100644 --- a/gufe/settings/models.py +++ b/gufe/settings/models.py @@ -13,7 +13,7 @@ from gufe.vendor.openff.interchange.pydantic import _BaseModel -from .annotations import AtmQuantity, KelvinQuantity, NanometerQuantity +from .types import AtmQuantity, CaseInsensitiveStrEnum, KelvinQuantity, NanometerQuantity class SettingsBaseModel(_BaseModel): @@ -111,6 +111,12 @@ class BaseForceFieldSettings(SettingsBaseModel, abc.ABC): """Base class for ForceFieldSettings objects""" ... +class ConstraintEnum(CaseInsensitiveStrEnum): + hbonds = "hbonds" + allbonds = "allbonds" + hangles = "hangles" + + class OpenMMSystemGeneratorFFSettings(BaseForceFieldSettings): """Parameters to set up the force field with OpenMM ForceFields @@ -126,7 +132,7 @@ class OpenMMSystemGeneratorFFSettings(BaseForceFieldSettings): https://github.com/openmm/openmmforcefields#automating-force-field-management-with-systemgenerator """ - constraints: str | None = "hbonds" + constraints: ConstraintEnum | None = ConstraintEnum.hbonds """Constraints to be applied to system. One of 'hbonds', 'allbonds', 'hangles' or None, default 'hbonds'""" rigid_water: bool = True @@ -165,16 +171,6 @@ def is_positive_distance(cls, v): raise ValueError(errmsg) return v - @field_validator("constraints") - def constraint_check(cls, v): - allowed = {"hbonds", "hangles", "allbonds"} - - if not (v is None or v.lower() in allowed): - raise ValueError(f"Bad constraints value, use one of {allowed}") - - return v - - class Settings(SettingsBaseModel): """ Container for all settings needed by a protocol diff --git a/gufe/settings/annotations.py b/gufe/settings/types.py similarity index 77% rename from gufe/settings/annotations.py rename to gufe/settings/types.py index 499165630..86c5d3749 100644 --- a/gufe/settings/annotations.py +++ b/gufe/settings/types.py @@ -1,5 +1,6 @@ # adapted from from https://github.com/openforcefield/openff-interchange/blob/main/openff/interchange/_annotations.py +from enum import StrEnum from typing import Annotated from openff.toolkit import Quantity @@ -43,3 +44,14 @@ # AfterValidator(_is_angstrom), # WrapSerializer(quantity_json_serializer), # ] + + +class CaseInsensitiveStrEnum(StrEnum): + # SEE: https://docs.python.org/3/library/enum.html#enum.Enum._missing_ + @classmethod + def _missing_(cls, value): + value = value.lower() + for member in cls: + if member.value == value: + return member + return None diff --git a/gufe/tests/test_models.py b/gufe/tests/test_models.py index e763d6961..bfb1fe6b7 100644 --- a/gufe/tests/test_models.py +++ b/gufe/tests/test_models.py @@ -85,7 +85,7 @@ class TestSettingsValidation: ("hbonds", True, "hbonds"), ("hangles", True, "hangles"), ("allbonds", True, "allbonds"), # allowed options - ("HBonds", True, "HBonds"), # check case insensitivity TODO: cast this to lower? + ("HBonds", True, "hbonds"), # check case insensitivity (None, True, None), ], ) From 1e4fcb34d9ef1b120478e559094661494fd54908 Mon Sep 17 00:00:00 2001 From: Alyssa Travitz Date: Tue, 29 Jul 2025 16:48:48 -0700 Subject: [PATCH 045/105] try using annotated literal instead of enum --- gufe/settings/models.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/gufe/settings/models.py b/gufe/settings/models.py index 6daddd156..ae5023291 100644 --- a/gufe/settings/models.py +++ b/gufe/settings/models.py @@ -6,10 +6,10 @@ import abc import pprint -from typing import Any, Literal +from typing import Annotated, Any, Literal from openff.units import unit -from pydantic import ConfigDict, Field, InstanceOf, PositiveFloat, PrivateAttr, field_validator +from pydantic import BeforeValidator, ConfigDict, Field, InstanceOf, PositiveFloat, PrivateAttr, field_validator from gufe.vendor.openff.interchange.pydantic import _BaseModel @@ -116,7 +116,12 @@ class ConstraintEnum(CaseInsensitiveStrEnum): allbonds = "allbonds" hangles = "hangles" - +def _to_lowercase(value: Any): + """make any string input lowercase""" + if isinstance(value, (str)): + return value.lower() + else: + return value class OpenMMSystemGeneratorFFSettings(BaseForceFieldSettings): """Parameters to set up the force field with OpenMM ForceFields @@ -132,7 +137,8 @@ class OpenMMSystemGeneratorFFSettings(BaseForceFieldSettings): https://github.com/openmm/openmmforcefields#automating-force-field-management-with-systemgenerator """ - constraints: ConstraintEnum | None = ConstraintEnum.hbonds + # constraints: ConstraintEnum | None = ConstraintEnum.hbonds + constraints: Annotated[Literal['hbonds', 'allbonds', 'hangles'], BeforeValidator(_to_lowercase)] | None = 'hbonds' """Constraints to be applied to system. One of 'hbonds', 'allbonds', 'hangles' or None, default 'hbonds'""" rigid_water: bool = True From 115125f7b97cdda2066533424df1a4dddaf6c5d7 Mon Sep 17 00:00:00 2001 From: Alyssa Travitz Date: Tue, 29 Jul 2025 16:55:48 -0700 Subject: [PATCH 046/105] make nonbonded method case insensitive --- gufe/settings/models.py | 2 +- gufe/tests/test_models.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/gufe/settings/models.py b/gufe/settings/models.py index ae5023291..592a72a69 100644 --- a/gufe/settings/models.py +++ b/gufe/settings/models.py @@ -158,7 +158,7 @@ class OpenMMSystemGeneratorFFSettings(BaseForceFieldSettings): small_molecule_forcefield: str = "openff-2.1.1" # other default ideas 'openff-2.0.0', 'gaff-2.11', 'espaloma-0.2.0' """Name of the force field to be used for :class:`SmallMoleculeComponent` """ - nonbonded_method: Literal["CutoffNonPeriodic", "CutoffPeriodic", "Ewald", "LJPME", "NoCutoff", "PME"] = "PME" + nonbonded_method: Annotated[Literal["cutoffnonperiodic", "cutoffperiodic", "ewald", "ljpme", "nocutoff", "pme"], BeforeValidator(_to_lowercase)] = "PME" """ Method for treating nonbonded interactions, options are currently "CutoffNonPeriodic", "CutoffPeriodic", "Ewald", "LJPME", "NoCutoff", "PME". diff --git a/gufe/tests/test_models.py b/gufe/tests/test_models.py index bfb1fe6b7..c584c90d8 100644 --- a/gufe/tests/test_models.py +++ b/gufe/tests/test_models.py @@ -125,8 +125,8 @@ def test_openmmff_nonbonded_cutoff(self, value, valid, expected): @pytest.mark.parametrize( "value,valid,expected", [ - ("NoCutoff", True, "NoCutoff"), - ("NOCUTOFF", False, "NOCUTOFF"), + ("NoCutoff", True, "nocutoff"), + ("NOCUTOFF", True, "nocutoff"), ("no cutoff", False, None), (1.0, False, None), ], From fee7e11e6a155f9f863f7d0bee14ef2123078f0d Mon Sep 17 00:00:00 2001 From: Alyssa Travitz Date: Tue, 29 Jul 2025 17:00:23 -0700 Subject: [PATCH 047/105] use annotated type for nonbonded cutoff --- gufe/settings/models.py | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/gufe/settings/models.py b/gufe/settings/models.py index 592a72a69..d142e2c12 100644 --- a/gufe/settings/models.py +++ b/gufe/settings/models.py @@ -8,6 +8,7 @@ import pprint from typing import Annotated, Any, Literal +from annotated_types import Ge from openff.units import unit from pydantic import BeforeValidator, ConfigDict, Field, InstanceOf, PositiveFloat, PrivateAttr, field_validator @@ -164,19 +165,12 @@ class OpenMMSystemGeneratorFFSettings(BaseForceFieldSettings): "CutoffNonPeriodic", "CutoffPeriodic", "Ewald", "LJPME", "NoCutoff", "PME". Default PME. """ - nonbonded_cutoff: NanometerQuantity=1.0 * unit.nanometer # FloatQuantity["nanometer"] = 1.0 * unit.nanometer + nonbonded_cutoff: Annotated[NanometerQuantity, Ge(0)]=1.0 * unit.nanometer """ Cutoff value for short range nonbonded interactions. Default 1.0 * unit.nanometer. """ - @field_validator("nonbonded_cutoff", mode='after') - def is_positive_distance(cls, v): - if v < 0: # TODO: make this an Annotated type with a helpful error message. - errmsg = "nonbonded_cutoff must be a positive value" - raise ValueError(errmsg) - return v - class Settings(SettingsBaseModel): """ Container for all settings needed by a protocol From 4af2a58c10c8c87a1bdffe02b23faf0af1925917 Mon Sep 17 00:00:00 2001 From: Alyssa Travitz Date: Tue, 29 Jul 2025 17:03:32 -0700 Subject: [PATCH 048/105] remove resolved todo --- gufe/settings/models.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/gufe/settings/models.py b/gufe/settings/models.py index d142e2c12..ef98e4f12 100644 --- a/gufe/settings/models.py +++ b/gufe/settings/models.py @@ -101,7 +101,6 @@ class ThermoSettings(SettingsBaseModel): No checking is done to ensure a valid thermodynamic ensemble is possible. """ - # TODO: do we actually want None to be valid here? temperature: KelvinQuantity | None = Field(None, description="Simulation temperature in kelvin)") pressure: AtmQuantity | None = Field(None, description="Simulation pressure in standard atmosphere (atm)") ph: PositiveFloat | None = Field(None, description="Simulation pH") @@ -187,5 +186,5 @@ class Settings(SettingsBaseModel): def get_defaults(cls): return Settings( forcefield_settings=OpenMMSystemGeneratorFFSettings(), - thermo_settings=ThermoSettings(temperature=300 * unit.kelvin), # TODO: use InstanceOf for validation here? + thermo_settings=ThermoSettings(temperature=300 * unit.kelvin), ) From db5975589cf0713572c59b05ed4ae8335b7bd94c Mon Sep 17 00:00:00 2001 From: Alyssa Travitz Date: Tue, 29 Jul 2025 18:01:41 -0700 Subject: [PATCH 049/105] trying dynamic enum --- gufe/settings/models.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/gufe/settings/models.py b/gufe/settings/models.py index ef98e4f12..dda2027d6 100644 --- a/gufe/settings/models.py +++ b/gufe/settings/models.py @@ -111,10 +111,10 @@ class BaseForceFieldSettings(SettingsBaseModel, abc.ABC): """Base class for ForceFieldSettings objects""" ... -class ConstraintEnum(CaseInsensitiveStrEnum): - hbonds = "hbonds" - allbonds = "allbonds" - hangles = "hangles" +# class ConstraintEnum(CaseInsensitiveStrEnum): +# hbonds = "hbonds" +# allbonds = "allbonds" +# hangles = "hangles" def _to_lowercase(value: Any): """make any string input lowercase""" @@ -137,8 +137,8 @@ class OpenMMSystemGeneratorFFSettings(BaseForceFieldSettings): https://github.com/openmm/openmmforcefields#automating-force-field-management-with-systemgenerator """ - # constraints: ConstraintEnum | None = ConstraintEnum.hbonds - constraints: Annotated[Literal['hbonds', 'allbonds', 'hangles'], BeforeValidator(_to_lowercase)] | None = 'hbonds' + constraints: CaseInsensitiveStrEnum('Constraints', ['hbonds', 'allbonds', 'hangles']) | None = 'hbonds' + # constraints: Annotated[Literal['hbonds', 'allbonds', 'hangles'], BeforeValidator(_to_lowercase)] | None = 'hbonds' """Constraints to be applied to system. One of 'hbonds', 'allbonds', 'hangles' or None, default 'hbonds'""" rigid_water: bool = True From 519d4f0abb4594b1f7bdd9621b8dc35cb38112c2 Mon Sep 17 00:00:00 2001 From: Alyssa Travitz Date: Tue, 29 Jul 2025 18:08:51 -0700 Subject: [PATCH 050/105] Revert "make nonbonded method case insensitive" This reverts commit 115125f7b97cdda2066533424df1a4dddaf6c5d7. --- gufe/settings/models.py | 2 +- gufe/tests/test_models.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/gufe/settings/models.py b/gufe/settings/models.py index dda2027d6..c8bb850d3 100644 --- a/gufe/settings/models.py +++ b/gufe/settings/models.py @@ -158,7 +158,7 @@ class OpenMMSystemGeneratorFFSettings(BaseForceFieldSettings): small_molecule_forcefield: str = "openff-2.1.1" # other default ideas 'openff-2.0.0', 'gaff-2.11', 'espaloma-0.2.0' """Name of the force field to be used for :class:`SmallMoleculeComponent` """ - nonbonded_method: Annotated[Literal["cutoffnonperiodic", "cutoffperiodic", "ewald", "ljpme", "nocutoff", "pme"], BeforeValidator(_to_lowercase)] = "PME" + nonbonded_method: Literal["CutoffNonPeriodic", "CutoffPeriodic", "Ewald", "LJPME", "NoCutoff", "PME"] = "PME" """ Method for treating nonbonded interactions, options are currently "CutoffNonPeriodic", "CutoffPeriodic", "Ewald", "LJPME", "NoCutoff", "PME". diff --git a/gufe/tests/test_models.py b/gufe/tests/test_models.py index c584c90d8..bfb1fe6b7 100644 --- a/gufe/tests/test_models.py +++ b/gufe/tests/test_models.py @@ -125,8 +125,8 @@ def test_openmmff_nonbonded_cutoff(self, value, valid, expected): @pytest.mark.parametrize( "value,valid,expected", [ - ("NoCutoff", True, "nocutoff"), - ("NOCUTOFF", True, "nocutoff"), + ("NoCutoff", True, "NoCutoff"), + ("NOCUTOFF", False, "NOCUTOFF"), ("no cutoff", False, None), (1.0, False, None), ], From 28e11b87087745bffd5c3a1517fa73943a5abeb6 Mon Sep 17 00:00:00 2001 From: Alyssa Travitz Date: Tue, 29 Jul 2025 18:32:58 -0700 Subject: [PATCH 051/105] revert nonbonded cutoff check due to serialization headaches --- gufe/settings/models.py | 14 +++++++++++++- gufe/tests/test_models.py | 2 +- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/gufe/settings/models.py b/gufe/settings/models.py index c8bb850d3..89194b3d7 100644 --- a/gufe/settings/models.py +++ b/gufe/settings/models.py @@ -158,7 +158,10 @@ class OpenMMSystemGeneratorFFSettings(BaseForceFieldSettings): small_molecule_forcefield: str = "openff-2.1.1" # other default ideas 'openff-2.0.0', 'gaff-2.11', 'espaloma-0.2.0' """Name of the force field to be used for :class:`SmallMoleculeComponent` """ - nonbonded_method: Literal["CutoffNonPeriodic", "CutoffPeriodic", "Ewald", "LJPME", "NoCutoff", "PME"] = "PME" + nonbonded_method: str = "PME" + # TODO: using either of the following will break serialization, look into this. + # nonbonded_method: Annotated[Literal["cutoffnonperiodic", "cutoffperiodic", "ewald", "ljpme", "nocutoff", "pme"], BeforeValidator(_to_lowercase)] | None = "PME" + # nonbonded_method: CaseInsensitiveStrEnum("NonbondedMethod", ["CutoffPeriodic", "Ewald", "LJPME", "NoCutoff", "PME"]) = "PME" """ Method for treating nonbonded interactions, options are currently "CutoffNonPeriodic", "CutoffPeriodic", "Ewald", "LJPME", "NoCutoff", "PME". @@ -170,6 +173,15 @@ class OpenMMSystemGeneratorFFSettings(BaseForceFieldSettings): Default 1.0 * unit.nanometer. """ + @field_validator("nonbonded_method", mode='after') + def allowed_nonbonded(cls, v): + options = ["CutoffNonPeriodic", "CutoffPeriodic", "Ewald", "LJPME", "NoCutoff", "PME"] + if v.lower() not in [x.lower() for x in options]: + errmsg = f"Only {options} are allowed nonbonded_methods" + raise ValueError(errmsg) + return v + + class Settings(SettingsBaseModel): """ Container for all settings needed by a protocol diff --git a/gufe/tests/test_models.py b/gufe/tests/test_models.py index bfb1fe6b7..ed6b0a649 100644 --- a/gufe/tests/test_models.py +++ b/gufe/tests/test_models.py @@ -126,7 +126,7 @@ def test_openmmff_nonbonded_cutoff(self, value, valid, expected): "value,valid,expected", [ ("NoCutoff", True, "NoCutoff"), - ("NOCUTOFF", False, "NOCUTOFF"), + ("NOCUTOFF", True, "NOCUTOFF"), ("no cutoff", False, None), (1.0, False, None), ], From 6d27819e6a979ef16e9648d8835102c70bb043cf Mon Sep 17 00:00:00 2001 From: Alyssa Travitz Date: Tue, 29 Jul 2025 18:34:41 -0700 Subject: [PATCH 052/105] remove unused import --- gufe/settings/models.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/gufe/settings/models.py b/gufe/settings/models.py index 89194b3d7..a31b10e4e 100644 --- a/gufe/settings/models.py +++ b/gufe/settings/models.py @@ -10,7 +10,7 @@ from annotated_types import Ge from openff.units import unit -from pydantic import BeforeValidator, ConfigDict, Field, InstanceOf, PositiveFloat, PrivateAttr, field_validator +from pydantic import ConfigDict, Field, InstanceOf, PositiveFloat, PrivateAttr, field_validator from gufe.vendor.openff.interchange.pydantic import _BaseModel @@ -173,7 +173,7 @@ class OpenMMSystemGeneratorFFSettings(BaseForceFieldSettings): Default 1.0 * unit.nanometer. """ - @field_validator("nonbonded_method", mode='after') + @field_validator("nonbonded_method", mode="after") def allowed_nonbonded(cls, v): options = ["CutoffNonPeriodic", "CutoffPeriodic", "Ewald", "LJPME", "NoCutoff", "PME"] if v.lower() not in [x.lower() for x in options]: From 9543c4625f16c8c00f2d679b4668451be3b19d6f Mon Sep 17 00:00:00 2001 From: Alyssa Travitz Date: Tue, 29 Jul 2025 18:52:02 -0700 Subject: [PATCH 053/105] setting to make pydantic use enum values --- gufe/settings/models.py | 1 + 1 file changed, 1 insertion(+) diff --git a/gufe/settings/models.py b/gufe/settings/models.py index a31b10e4e..01d873610 100644 --- a/gufe/settings/models.py +++ b/gufe/settings/models.py @@ -24,6 +24,7 @@ class SettingsBaseModel(_BaseModel): model_config = ConfigDict(extra='forbid', # TODO: needs to be True for current pydantic v2 implementation, try to change back # arbitrary_types_allowed=False + use_enum_values=True, ) def _ipython_display_(self): From ffdb1f0360730e3ae249a4bf83490687185a4f9a Mon Sep 17 00:00:00 2001 From: Alyssa Travitz Date: Wed, 30 Jul 2025 09:32:14 -0700 Subject: [PATCH 054/105] resolve mypy errors --- gufe/settings/models.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/gufe/settings/models.py b/gufe/settings/models.py index 01d873610..2f7efc34e 100644 --- a/gufe/settings/models.py +++ b/gufe/settings/models.py @@ -10,7 +10,7 @@ from annotated_types import Ge from openff.units import unit -from pydantic import ConfigDict, Field, InstanceOf, PositiveFloat, PrivateAttr, field_validator +from pydantic import BeforeValidator, ConfigDict, Field, InstanceOf, PositiveFloat, PrivateAttr, field_validator from gufe.vendor.openff.interchange.pydantic import _BaseModel @@ -90,7 +90,7 @@ def __eq__(self, other: Any) -> bool: # reproduces pydantic v1 equality, since v2 checks for private attr equality, # which results in frozen/unfrozen objects not being equal # https://github.com/pydantic/pydantic/blob/2486e068e85c51728c9f2d344cfee2f7e11d555c/pydantic/v1/main.py#L911 - if isinstance(other, super): + if isinstance(other, _BaseModel): return self.model_dump() == other.model_dump() else: return self.model_dump() == other @@ -138,8 +138,8 @@ class OpenMMSystemGeneratorFFSettings(BaseForceFieldSettings): https://github.com/openmm/openmmforcefields#automating-force-field-management-with-systemgenerator """ - constraints: CaseInsensitiveStrEnum('Constraints', ['hbonds', 'allbonds', 'hangles']) | None = 'hbonds' - # constraints: Annotated[Literal['hbonds', 'allbonds', 'hangles'], BeforeValidator(_to_lowercase)] | None = 'hbonds' + # constraints: CaseInsensitiveStrEnum('Constraints', ['hbonds', 'allbonds', 'hangles']) | None = 'hbonds' + constraints: Annotated[Literal['hbonds', 'allbonds', 'hangles'], BeforeValidator(_to_lowercase)] | None = 'hbonds' """Constraints to be applied to system. One of 'hbonds', 'allbonds', 'hangles' or None, default 'hbonds'""" rigid_water: bool = True From 92abfcf78d1a3d64a19e2106dc4d50bcc49f9d84 Mon Sep 17 00:00:00 2001 From: Alyssa Travitz Date: Wed, 30 Jul 2025 09:36:50 -0700 Subject: [PATCH 055/105] add todo --- gufe/settings/models.py | 1 + 1 file changed, 1 insertion(+) diff --git a/gufe/settings/models.py b/gufe/settings/models.py index 2f7efc34e..eccf0a6d7 100644 --- a/gufe/settings/models.py +++ b/gufe/settings/models.py @@ -176,6 +176,7 @@ class OpenMMSystemGeneratorFFSettings(BaseForceFieldSettings): @field_validator("nonbonded_method", mode="after") def allowed_nonbonded(cls, v): + # TODO: replace this with an annotated Literal. options = ["CutoffNonPeriodic", "CutoffPeriodic", "Ewald", "LJPME", "NoCutoff", "PME"] if v.lower() not in [x.lower() for x in options]: errmsg = f"Only {options} are allowed nonbonded_methods" From fad6504031209e010899d9e1cb9e53a2e5d5e020 Mon Sep 17 00:00:00 2001 From: Alyssa Travitz Date: Wed, 30 Jul 2025 09:44:56 -0700 Subject: [PATCH 056/105] comment out unused code --- gufe/settings/models.py | 4 ++-- gufe/settings/types.py | 18 +++++++++--------- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/gufe/settings/models.py b/gufe/settings/models.py index eccf0a6d7..fd3715fcf 100644 --- a/gufe/settings/models.py +++ b/gufe/settings/models.py @@ -12,9 +12,9 @@ from openff.units import unit from pydantic import BeforeValidator, ConfigDict, Field, InstanceOf, PositiveFloat, PrivateAttr, field_validator -from gufe.vendor.openff.interchange.pydantic import _BaseModel +from ..vendor.openff.interchange.pydantic import _BaseModel -from .types import AtmQuantity, CaseInsensitiveStrEnum, KelvinQuantity, NanometerQuantity +from .types import AtmQuantity, KelvinQuantity, NanometerQuantity class SettingsBaseModel(_BaseModel): diff --git a/gufe/settings/types.py b/gufe/settings/types.py index 86c5d3749..bd895d5d5 100644 --- a/gufe/settings/types.py +++ b/gufe/settings/types.py @@ -46,12 +46,12 @@ # ] -class CaseInsensitiveStrEnum(StrEnum): - # SEE: https://docs.python.org/3/library/enum.html#enum.Enum._missing_ - @classmethod - def _missing_(cls, value): - value = value.lower() - for member in cls: - if member.value == value: - return member - return None +# class CaseInsensitiveStrEnum(StrEnum): +# # SEE: https://docs.python.org/3/library/enum.html#enum.Enum._missing_ +# @classmethod +# def _missing_(cls, value): +# value = value.lower() +# for member in cls: +# if member.value == value: +# return member +# return None From f168ca48ca2f1236fd1b4a63995ff3db4104fbcd Mon Sep 17 00:00:00 2001 From: Alyssa Travitz Date: Wed, 30 Jul 2025 10:00:02 -0700 Subject: [PATCH 057/105] add case to check for non str handling in _to_lower --- gufe/settings/models.py | 22 ++++++++++++++-------- gufe/tests/test_models.py | 15 ++++++++------- 2 files changed, 22 insertions(+), 15 deletions(-) diff --git a/gufe/settings/models.py b/gufe/settings/models.py index fd3715fcf..cb221468d 100644 --- a/gufe/settings/models.py +++ b/gufe/settings/models.py @@ -13,7 +13,6 @@ from pydantic import BeforeValidator, ConfigDict, Field, InstanceOf, PositiveFloat, PrivateAttr, field_validator from ..vendor.openff.interchange.pydantic import _BaseModel - from .types import AtmQuantity, KelvinQuantity, NanometerQuantity @@ -21,11 +20,12 @@ class SettingsBaseModel(_BaseModel): """Settings and modifications we want for all settings classes.""" _is_frozen: bool = PrivateAttr(default_factory=lambda: False) - model_config = ConfigDict(extra='forbid', - # TODO: needs to be True for current pydantic v2 implementation, try to change back - # arbitrary_types_allowed=False - use_enum_values=True, - ) + model_config = ConfigDict( + extra="forbid", + # TODO: needs to be True for current pydantic v2 implementation, try to change back + # arbitrary_types_allowed=False + # use_enum_values=True, + ) def _ipython_display_(self): pprint.pprint(self.dict()) @@ -95,6 +95,7 @@ def __eq__(self, other: Any) -> bool: else: return self.model_dump() == other + class ThermoSettings(SettingsBaseModel): """Settings for thermodynamic parameters. @@ -102,6 +103,7 @@ class ThermoSettings(SettingsBaseModel): No checking is done to ensure a valid thermodynamic ensemble is possible. """ + temperature: KelvinQuantity | None = Field(None, description="Simulation temperature in kelvin)") pressure: AtmQuantity | None = Field(None, description="Simulation pressure in standard atmosphere (atm)") ph: PositiveFloat | None = Field(None, description="Simulation pH") @@ -110,13 +112,16 @@ class ThermoSettings(SettingsBaseModel): class BaseForceFieldSettings(SettingsBaseModel, abc.ABC): """Base class for ForceFieldSettings objects""" + ... + # class ConstraintEnum(CaseInsensitiveStrEnum): # hbonds = "hbonds" # allbonds = "allbonds" # hangles = "hangles" + def _to_lowercase(value: Any): """make any string input lowercase""" if isinstance(value, (str)): @@ -124,6 +129,7 @@ def _to_lowercase(value: Any): else: return value + class OpenMMSystemGeneratorFFSettings(BaseForceFieldSettings): """Parameters to set up the force field with OpenMM ForceFields @@ -139,7 +145,7 @@ class OpenMMSystemGeneratorFFSettings(BaseForceFieldSettings): """ # constraints: CaseInsensitiveStrEnum('Constraints', ['hbonds', 'allbonds', 'hangles']) | None = 'hbonds' - constraints: Annotated[Literal['hbonds', 'allbonds', 'hangles'], BeforeValidator(_to_lowercase)] | None = 'hbonds' + constraints: Annotated[Literal["hbonds", "allbonds", "hangles"], BeforeValidator(_to_lowercase)] | None = "hbonds" """Constraints to be applied to system. One of 'hbonds', 'allbonds', 'hangles' or None, default 'hbonds'""" rigid_water: bool = True @@ -168,7 +174,7 @@ class OpenMMSystemGeneratorFFSettings(BaseForceFieldSettings): "CutoffNonPeriodic", "CutoffPeriodic", "Ewald", "LJPME", "NoCutoff", "PME". Default PME. """ - nonbonded_cutoff: Annotated[NanometerQuantity, Ge(0)]=1.0 * unit.nanometer + nonbonded_cutoff: Annotated[NanometerQuantity, Ge(0)] = 1.0 * unit.nanometer """ Cutoff value for short range nonbonded interactions. Default 1.0 * unit.nanometer. diff --git a/gufe/tests/test_models.py b/gufe/tests/test_models.py index ed6b0a649..c1798406c 100644 --- a/gufe/tests/test_models.py +++ b/gufe/tests/test_models.py @@ -74,19 +74,20 @@ def test_default_settings(): my_settings = Settings.get_defaults() my_settings.thermo_settings.temperature = 298 * unit.kelvin my_settings.model_dump_json() - json.dumps(my_settings.model_json_schema(mode='serialization'), indent=2) + json.dumps(my_settings.model_json_schema(mode="serialization"), indent=2) class TestSettingsValidation: @pytest.mark.parametrize( "value,valid,expected", [ - ("parsnips", False, None), # shouldn't be allowed - ("hbonds", True, "hbonds"), - ("hangles", True, "hangles"), - ("allbonds", True, "allbonds"), # allowed options - ("HBonds", True, "hbonds"), # check case insensitivity - (None, True, None), + ("Parsnips", False, None), # shouldn't be allowed + (1.0, False, None), # shouldn't be allowed + # ("hbonds", True, "hbonds"), + # ("hangles", True, "hangles"), + # ("allbonds", True, "allbonds"), # allowed options + # ("HBonds", True, "hbonds"), # check case insensitivity + # (None, True, None), ], ) def test_openmmff_constraints(self, value, valid, expected): From 5c6f6895eae028330674e3260991332066b76dc0 Mon Sep 17 00:00:00 2001 From: Alyssa Travitz Date: Wed, 30 Jul 2025 10:07:08 -0700 Subject: [PATCH 058/105] fix packaging --- gufe/settings/types.py | 2 +- gufe/vendor/openff/interchange/__init__.py | 0 2 files changed, 1 insertion(+), 1 deletion(-) create mode 100644 gufe/vendor/openff/interchange/__init__.py diff --git a/gufe/settings/types.py b/gufe/settings/types.py index bd895d5d5..6c03e678d 100644 --- a/gufe/settings/types.py +++ b/gufe/settings/types.py @@ -10,7 +10,7 @@ WrapValidator, ) -from gufe.vendor.openff.interchange._annotations import ( +from ..vendor.openff.interchange._annotations import ( _unit_validator_factory, quantity_json_serializer, quantity_validator, diff --git a/gufe/vendor/openff/interchange/__init__.py b/gufe/vendor/openff/interchange/__init__.py new file mode 100644 index 000000000..e69de29bb From d77d896a434688eeba14b8eae55e752ca017d81f Mon Sep 17 00:00:00 2001 From: Alyssa Travitz Date: Wed, 30 Jul 2025 13:49:12 -0700 Subject: [PATCH 059/105] fix angular quantity --- gufe/settings/types.py | 87 ++++++++++++++++++++++++++++++++++++++---- 1 file changed, 79 insertions(+), 8 deletions(-) diff --git a/gufe/settings/types.py b/gufe/settings/types.py index 6c03e678d..7ce92cb47 100644 --- a/gufe/settings/types.py +++ b/gufe/settings/types.py @@ -1,21 +1,31 @@ # adapted from from https://github.com/openforcefield/openff-interchange/blob/main/openff/interchange/_annotations.py -from enum import StrEnum +# from enum import StrEnum from typing import Annotated from openff.toolkit import Quantity from pydantic import ( AfterValidator, + BeforeValidator, WrapSerializer, WrapValidator, ) +from ..vendor.openff.interchange._annotations import _BoxQuantity as BoxQuantity from ..vendor.openff.interchange._annotations import ( _unit_validator_factory, + _unwrap_list_of_openmm_quantities, quantity_json_serializer, quantity_validator, ) +AngstromQuantity = Annotated[ + Quantity, + WrapValidator(quantity_validator), + AfterValidator(_unit_validator_factory("angstrom")), + WrapSerializer(quantity_json_serializer), +] + NanometerQuantity = Annotated[ Quantity, WrapValidator(quantity_validator), @@ -23,6 +33,34 @@ WrapSerializer(quantity_json_serializer), ] +FemtosecondQuantity = Annotated[ + Quantity, + WrapValidator(quantity_validator), + AfterValidator(_unit_validator_factory("femtosecond")), + WrapSerializer(quantity_json_serializer), +] + +NanosecondQuantity = Annotated[ + Quantity, + WrapValidator(quantity_validator), + AfterValidator(_unit_validator_factory("nanosecond")), + WrapSerializer(quantity_json_serializer), +] + +PicosecondQuantity = Annotated[ + Quantity, + WrapValidator(quantity_validator), + AfterValidator(_unit_validator_factory("picosecond")), + WrapSerializer(quantity_json_serializer), +] + +InversePicosecondQuantity = Annotated[ + Quantity, + WrapValidator(quantity_validator), + AfterValidator(_unit_validator_factory("1/picosecond")), + WrapSerializer(quantity_json_serializer), +] + AtmQuantity = Annotated[ Quantity, WrapValidator(quantity_validator), @@ -37,14 +75,47 @@ WrapSerializer(quantity_json_serializer), ] -# TODO: openfe will need this. -# _AngstromQuantity = Annotated[ -# Quantity, -# WrapValidator(quantity_validator), -# AfterValidator(_is_angstrom), -# WrapSerializer(quantity_json_serializer), -# ] +KCalPerMolQuantity = Annotated[ + Quantity, + WrapValidator(quantity_validator), + AfterValidator(_unit_validator_factory("kilocalorie_per_mole")), + WrapSerializer(quantity_json_serializer), +] + +TimestepQuantity = Annotated[ + Quantity, + WrapValidator(quantity_validator), + AfterValidator(_unit_validator_factory("timestep")), + WrapSerializer(quantity_json_serializer), +] + +SpringConstantLinearQuantity = Annotated[ + Quantity, + WrapValidator(quantity_validator), + AfterValidator(_unit_validator_factory("kilojoule_per_mole / nm ** 2")), + WrapSerializer(quantity_json_serializer), +] + +SpringConstantAngularQuantity = Annotated[ + Quantity, + WrapValidator(quantity_validator), + AfterValidator(_unit_validator_factory("kilojoule_per_mole / radians ** 2")), + WrapSerializer(quantity_json_serializer), +] +RadiansQuantity = Annotated[ + Quantity, + WrapValidator(quantity_validator), + AfterValidator(_unit_validator_factory("radians")), + WrapSerializer(quantity_json_serializer), +] + +ArrayQuantity = Annotated[ + Quantity, + WrapValidator(quantity_validator), + BeforeValidator(_unwrap_list_of_openmm_quantities), + WrapSerializer(quantity_json_serializer), +] # class CaseInsensitiveStrEnum(StrEnum): # # SEE: https://docs.python.org/3/library/enum.html#enum.Enum._missing_ From d7766c4be9a8af60700b419851aac2640f2f2e5f Mon Sep 17 00:00:00 2001 From: Alyssa Travitz Date: Wed, 30 Jul 2025 14:17:34 -0700 Subject: [PATCH 060/105] bump docs python version from 3.10 to 3.12 --- docs/environment.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/environment.yaml b/docs/environment.yaml index 6c61989f5..a82af8c9c 100644 --- a/docs/environment.yaml +++ b/docs/environment.yaml @@ -5,7 +5,7 @@ channels: dependencies: - autodoc-pydantic - openff-units -- python=3.10 +- python=3.12 - sphinx - openmm - networkx From 498d3b97a79fcad429518b6a7e02ac7d579fa127 Mon Sep 17 00:00:00 2001 From: Alyssa Travitz Date: Wed, 30 Jul 2025 14:39:54 -0700 Subject: [PATCH 061/105] Attributes -> Properties --- gufe/protocols/protocoldag.py | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/gufe/protocols/protocoldag.py b/gufe/protocols/protocoldag.py index 6dc136db6..fc7da0d33 100644 --- a/gufe/protocols/protocoldag.py +++ b/gufe/protocols/protocoldag.py @@ -55,7 +55,7 @@ def name(self) -> str | None: @property def graph(self) -> nx.DiGraph: - """DAG of `ProtocolUnit` nodes with edges denoting dependencies.""" + """DAG of ``ProtocolUnit`` nodes with edges denoting dependencies.""" return self._graph @property @@ -274,22 +274,22 @@ class ProtocolDAG(GufeTokenizable, DAGMixin): An executable directed acyclic graph (DAG) of :class:`ProtocolUnit` objects. A ``ProtocolDAG`` is composed of :class:`ProtocolUnit` objects as well as - how they depend on each other. A single `ProtocolDAG` execution should + how they depend on each other. A single ``ProtocolDAG`` execution should yield sufficient information to calculate a free energy difference (though perhaps not converged) between two `ChemicalSystem` objects. - A `ProtocolDAG` yields a `ProtocolDAGResult` when executed. + A ``ProtocolDAG`` yields a ``ProtocolDAGResult`` when executed. - Attributes + Properties ---------- name : str - Optional identifier for this `ProtocolDAGResult`. + Optional identifier for this ``ProtocolDAGResult``. protocol_units : list[ProtocolUnit] - `ProtocolUnit`s (given in DAG-dependency order) used to compute this - `ProtocolDAGResult`. Tasks are always listed after their dependencies. + ``ProtocolUnit`` s (given in DAG-dependency order) used to compute this + ``ProtocolDAGResult``. Tasks are always listed after their dependencies. graph : nx.DiGraph - Graph of `ProtocolUnit`s as nodes, with directed edges to each - `ProtocolUnit`'s dependencies. + Graph of ``ProtocolUnit`` s as nodes, with directed edges to each + ``ProtocolUnit``'s dependencies. """ def __init__( @@ -300,7 +300,7 @@ def __init__( extends_key: GufeKey | None = None, name: str | None = None, ): - """Create a new `ProtocolDAG` + """Create a new `ProtocolDAG`` Parameters ---------- From cb53964a5b9f7862a07c47be4828322c5e74e53d Mon Sep 17 00:00:00 2001 From: Alyssa Travitz Date: Wed, 30 Jul 2025 16:09:37 -0700 Subject: [PATCH 062/105] trying array type --- gufe/settings/types.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/gufe/settings/types.py b/gufe/settings/types.py index 7ce92cb47..d083bb7d7 100644 --- a/gufe/settings/types.py +++ b/gufe/settings/types.py @@ -13,6 +13,7 @@ from ..vendor.openff.interchange._annotations import _BoxQuantity as BoxQuantity from ..vendor.openff.interchange._annotations import ( + _duck_to_nanometer, _unit_validator_factory, _unwrap_list_of_openmm_quantities, quantity_json_serializer, @@ -117,6 +118,14 @@ WrapSerializer(quantity_json_serializer), ] +NanometerArrayQuantity = Annotated[ + Quantity, + WrapValidator(quantity_validator), + AfterValidator(_unit_validator_factory("nanometer")), + BeforeValidator(_duck_to_nanometer), + BeforeValidator(_unwrap_list_of_openmm_quantities), + WrapSerializer(quantity_json_serializer), +] # class CaseInsensitiveStrEnum(StrEnum): # # SEE: https://docs.python.org/3/library/enum.html#enum.Enum._missing_ # @classmethod From 10283c2403af11bfd485df48e65ae3ab09eb0861 Mon Sep 17 00:00:00 2001 From: Alyssa Travitz Date: Fri, 1 Aug 2025 12:35:25 -0700 Subject: [PATCH 063/105] pip install autodoc_pydantic --- environment.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/environment.yml b/environment.yml index e8c87c6e4..6c3eb34f0 100644 --- a/environment.yml +++ b/environment.yml @@ -24,3 +24,5 @@ dependencies: - pydata-sphinx-theme - sphinx-jsonschema==1.15 - sphinx <7.1.2 + - pip: + - autodoc_pydantic>=2.0.0 From 5103d7daf2edea91c475568c1a9c3dc74738918c Mon Sep 17 00:00:00 2001 From: Alyssa Travitz Date: Tue, 12 Aug 2025 09:41:53 -0700 Subject: [PATCH 064/105] revert env files to try to fix docs --- docs/environment.yaml | 4 ++-- environment.yml | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/environment.yaml b/docs/environment.yaml index 47444d394..6c61989f5 100644 --- a/docs/environment.yaml +++ b/docs/environment.yaml @@ -3,9 +3,9 @@ channels: - https://conda.anaconda.org/jaimergp/label/unsupported-cudatoolkit-shim - https://conda.anaconda.org/conda-forge dependencies: -- autodoc-pydantic >=2.0 +- autodoc-pydantic - openff-units -- python=3.12 +- python=3.10 - sphinx - openmm - networkx diff --git a/environment.yml b/environment.yml index 6c3eb34f0..896975cdf 100644 --- a/environment.yml +++ b/environment.yml @@ -13,7 +13,7 @@ dependencies: - pint - pip - pooch - - pydantic >=2.0 + - pydantic >1 - pytest - pytest-cov - pytest-xdist @@ -25,4 +25,4 @@ dependencies: - sphinx-jsonschema==1.15 - sphinx <7.1.2 - pip: - - autodoc_pydantic>=2.0.0 + - autodoc_pydantic<2.0.0 From 70a1a9a45d1c95be5e5acc9b61ac1340139202c0 Mon Sep 17 00:00:00 2001 From: Alyssa Travitz Date: Tue, 12 Aug 2025 09:45:52 -0700 Subject: [PATCH 065/105] Revert "revert env files to try to fix docs" This reverts commit 5103d7daf2edea91c475568c1a9c3dc74738918c. --- docs/environment.yaml | 4 ++-- environment.yml | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/environment.yaml b/docs/environment.yaml index 6c61989f5..47444d394 100644 --- a/docs/environment.yaml +++ b/docs/environment.yaml @@ -3,9 +3,9 @@ channels: - https://conda.anaconda.org/jaimergp/label/unsupported-cudatoolkit-shim - https://conda.anaconda.org/conda-forge dependencies: -- autodoc-pydantic +- autodoc-pydantic >=2.0 - openff-units -- python=3.10 +- python=3.12 - sphinx - openmm - networkx diff --git a/environment.yml b/environment.yml index 896975cdf..6c3eb34f0 100644 --- a/environment.yml +++ b/environment.yml @@ -13,7 +13,7 @@ dependencies: - pint - pip - pooch - - pydantic >1 + - pydantic >=2.0 - pytest - pytest-cov - pytest-xdist @@ -25,4 +25,4 @@ dependencies: - sphinx-jsonschema==1.15 - sphinx <7.1.2 - pip: - - autodoc_pydantic<2.0.0 + - autodoc_pydantic>=2.0.0 From 949c723432e5e6183a93c4088873803cf44e0d21 Mon Sep 17 00:00:00 2001 From: Alyssa Travitz Date: Tue, 12 Aug 2025 12:12:26 -0700 Subject: [PATCH 066/105] uncomment some test cases --- gufe/settings/types.py | 1 + gufe/tests/test_models.py | 14 +++++++------- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/gufe/settings/types.py b/gufe/settings/types.py index d083bb7d7..46a32906d 100644 --- a/gufe/settings/types.py +++ b/gufe/settings/types.py @@ -126,6 +126,7 @@ BeforeValidator(_unwrap_list_of_openmm_quantities), WrapSerializer(quantity_json_serializer), ] + # class CaseInsensitiveStrEnum(StrEnum): # # SEE: https://docs.python.org/3/library/enum.html#enum.Enum._missing_ # @classmethod diff --git a/gufe/tests/test_models.py b/gufe/tests/test_models.py index c1798406c..fdd496db5 100644 --- a/gufe/tests/test_models.py +++ b/gufe/tests/test_models.py @@ -83,11 +83,11 @@ class TestSettingsValidation: [ ("Parsnips", False, None), # shouldn't be allowed (1.0, False, None), # shouldn't be allowed - # ("hbonds", True, "hbonds"), - # ("hangles", True, "hangles"), - # ("allbonds", True, "allbonds"), # allowed options - # ("HBonds", True, "hbonds"), # check case insensitivity - # (None, True, None), + ("hbonds", True, "hbonds"), + ("hangles", True, "hangles"), + ("allbonds", True, "allbonds"), # allowed options + ("HBonds", True, "hbonds"), # check case insensitivity + (None, True, None), ], ) def test_openmmff_constraints(self, value, valid, expected): @@ -152,8 +152,8 @@ def test_openmmff_nonbonded_method(self, value, valid, expected): ) def test_thermo_temperature(self, value, valid, expected): if valid: - s = ThermoSettings(temperature=value) - assert s.temperature == expected + settings = ThermoSettings(temperature=value) + assert settings.temperature == expected else: with pytest.raises(ValueError): _ = ThermoSettings(temperature=value) From 4bf3cc301a50537c7e3976b0bec605997ddd3cb9 Mon Sep 17 00:00:00 2001 From: Alyssa Travitz Date: Tue, 12 Aug 2025 18:24:59 -0700 Subject: [PATCH 067/105] add openff-toolkit to docs env --- docs/environment.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/environment.yaml b/docs/environment.yaml index 47444d394..e8b561306 100644 --- a/docs/environment.yaml +++ b/docs/environment.yaml @@ -5,6 +5,7 @@ channels: dependencies: - autodoc-pydantic >=2.0 - openff-units +- openff-toolkit - python=3.12 - sphinx - openmm From cbe1e39011936f3fa53ed1f1d896d004a0f22591 Mon Sep 17 00:00:00 2001 From: Alyssa Travitz Date: Tue, 12 Aug 2025 19:06:54 -0700 Subject: [PATCH 068/105] turn off some autodoc pydantic things for now --- docs/conf.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/docs/conf.py b/docs/conf.py index 3ae6eecf8..7917d840e 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -68,6 +68,15 @@ "rdkit", ] +# autodoc_pydantic settings +autodoc_pydantic_show_config = False +autodoc_pydantic_model_show_config = False +autodoc_pydantic_model_show_config_summary = False +autodoc_pydantic_show_validators = False +autodoc_pydantic_model_show_validators = False +autodoc_pydantic_field_show_alias = False +autodoc_pydantic_model_show_json = False + # -- Options for HTML output ------------------------------------------------- From 577791c2c845923607c1e53916c3dbf5e40f8f89 Mon Sep 17 00:00:00 2001 From: Alyssa Travitz <31974495+atravitz@users.noreply.github.com> Date: Tue, 12 Aug 2025 19:23:55 -0700 Subject: [PATCH 069/105] Update gufe/settings/models.py --- gufe/settings/models.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/gufe/settings/models.py b/gufe/settings/models.py index cb221468d..373f61fea 100644 --- a/gufe/settings/models.py +++ b/gufe/settings/models.py @@ -22,7 +22,8 @@ class SettingsBaseModel(_BaseModel): _is_frozen: bool = PrivateAttr(default_factory=lambda: False) model_config = ConfigDict( extra="forbid", - # TODO: needs to be True for current pydantic v2 implementation, try to change back +# needed to parse custom types +arbitrary_types_allowed=True # arbitrary_types_allowed=False # use_enum_values=True, ) From 9c3d55f1e7d6f7f8d8cfbbd3486afe072f0b0350 Mon Sep 17 00:00:00 2001 From: Alyssa Travitz Date: Tue, 12 Aug 2025 19:44:12 -0700 Subject: [PATCH 070/105] update autodoc pydantic settings --- docs/conf.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index 7917d840e..4765f0227 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -69,13 +69,13 @@ ] # autodoc_pydantic settings -autodoc_pydantic_show_config = False -autodoc_pydantic_model_show_config = False + +# autodoc_pydantic settings autodoc_pydantic_model_show_config_summary = False -autodoc_pydantic_show_validators = False -autodoc_pydantic_model_show_validators = False -autodoc_pydantic_field_show_alias = False -autodoc_pydantic_model_show_json = False +autodoc_pydantic_model_show_validator_summary = False +autodoc_pydantic_model_show_validator_members = False +autodoc_pydantic_model_show_json_error_strategy = "coerce" # TODO: we cannot currently generate schemas for models w/ pint quantities + # -- Options for HTML output ------------------------------------------------- From 9037249a5f7513362091d04e48c650120f578c6b Mon Sep 17 00:00:00 2001 From: Alyssa Travitz Date: Thu, 14 Aug 2025 11:39:36 -0700 Subject: [PATCH 071/105] simplify into PydanticQuantity --- gufe/settings/models.py | 4 +-- gufe/settings/types.py | 65 +++++++++++++---------------------------- 2 files changed, 21 insertions(+), 48 deletions(-) diff --git a/gufe/settings/models.py b/gufe/settings/models.py index 373f61fea..3958b0960 100644 --- a/gufe/settings/models.py +++ b/gufe/settings/models.py @@ -22,9 +22,7 @@ class SettingsBaseModel(_BaseModel): _is_frozen: bool = PrivateAttr(default_factory=lambda: False) model_config = ConfigDict( extra="forbid", -# needed to parse custom types -arbitrary_types_allowed=True - # arbitrary_types_allowed=False + arbitrary_types_allowed=True, # needed to parse custom types # use_enum_values=True, ) diff --git a/gufe/settings/types.py b/gufe/settings/types.py index 46a32906d..5f6519cee 100644 --- a/gufe/settings/types.py +++ b/gufe/settings/types.py @@ -3,7 +3,7 @@ # from enum import StrEnum from typing import Annotated -from openff.toolkit import Quantity +from openff.units import Quantity from pydantic import ( AfterValidator, BeforeValidator, @@ -20,111 +20,86 @@ quantity_validator, ) -AngstromQuantity = Annotated[ +PydanticQuantity = Annotated[ Quantity, WrapValidator(quantity_validator), - AfterValidator(_unit_validator_factory("angstrom")), WrapSerializer(quantity_json_serializer), ] +AngstromQuantity = Annotated[ + PydanticQuantity, + AfterValidator(_unit_validator_factory("angstrom")), +] NanometerQuantity = Annotated[ - Quantity, - WrapValidator(quantity_validator), + PydanticQuantity, AfterValidator(_unit_validator_factory("nanometer")), - WrapSerializer(quantity_json_serializer), ] FemtosecondQuantity = Annotated[ - Quantity, - WrapValidator(quantity_validator), + PydanticQuantity, AfterValidator(_unit_validator_factory("femtosecond")), - WrapSerializer(quantity_json_serializer), ] NanosecondQuantity = Annotated[ - Quantity, - WrapValidator(quantity_validator), + PydanticQuantity, AfterValidator(_unit_validator_factory("nanosecond")), - WrapSerializer(quantity_json_serializer), ] PicosecondQuantity = Annotated[ - Quantity, - WrapValidator(quantity_validator), + PydanticQuantity, AfterValidator(_unit_validator_factory("picosecond")), - WrapSerializer(quantity_json_serializer), ] InversePicosecondQuantity = Annotated[ - Quantity, - WrapValidator(quantity_validator), + PydanticQuantity, AfterValidator(_unit_validator_factory("1/picosecond")), - WrapSerializer(quantity_json_serializer), ] AtmQuantity = Annotated[ - Quantity, - WrapValidator(quantity_validator), + PydanticQuantity, AfterValidator(_unit_validator_factory("atm")), - WrapSerializer(quantity_json_serializer), ] KelvinQuantity = Annotated[ - Quantity, - WrapValidator(quantity_validator), + PydanticQuantity, AfterValidator(_unit_validator_factory("kelvin")), - WrapSerializer(quantity_json_serializer), ] KCalPerMolQuantity = Annotated[ - Quantity, - WrapValidator(quantity_validator), + PydanticQuantity, AfterValidator(_unit_validator_factory("kilocalorie_per_mole")), - WrapSerializer(quantity_json_serializer), ] TimestepQuantity = Annotated[ - Quantity, - WrapValidator(quantity_validator), + PydanticQuantity, AfterValidator(_unit_validator_factory("timestep")), - WrapSerializer(quantity_json_serializer), ] SpringConstantLinearQuantity = Annotated[ - Quantity, - WrapValidator(quantity_validator), + PydanticQuantity, AfterValidator(_unit_validator_factory("kilojoule_per_mole / nm ** 2")), - WrapSerializer(quantity_json_serializer), ] SpringConstantAngularQuantity = Annotated[ - Quantity, - WrapValidator(quantity_validator), + PydanticQuantity, AfterValidator(_unit_validator_factory("kilojoule_per_mole / radians ** 2")), - WrapSerializer(quantity_json_serializer), ] RadiansQuantity = Annotated[ - Quantity, - WrapValidator(quantity_validator), + PydanticQuantity, AfterValidator(_unit_validator_factory("radians")), - WrapSerializer(quantity_json_serializer), ] ArrayQuantity = Annotated[ - Quantity, - WrapValidator(quantity_validator), + PydanticQuantity, BeforeValidator(_unwrap_list_of_openmm_quantities), - WrapSerializer(quantity_json_serializer), ] NanometerArrayQuantity = Annotated[ - Quantity, - WrapValidator(quantity_validator), + PydanticQuantity, AfterValidator(_unit_validator_factory("nanometer")), BeforeValidator(_duck_to_nanometer), BeforeValidator(_unwrap_list_of_openmm_quantities), - WrapSerializer(quantity_json_serializer), ] # class CaseInsensitiveStrEnum(StrEnum): From 44f8f136bfb51aba28d1a642d4c6632d2087b1a4 Mon Sep 17 00:00:00 2001 From: Alyssa Travitz Date: Thu, 14 Aug 2025 15:07:14 -0700 Subject: [PATCH 072/105] comment out what's unneeded --- gufe/settings/types.py | 151 +++++++++++++++++++---------------------- 1 file changed, 71 insertions(+), 80 deletions(-) diff --git a/gufe/settings/types.py b/gufe/settings/types.py index 5f6519cee..6dd167d9e 100644 --- a/gufe/settings/types.py +++ b/gufe/settings/types.py @@ -14,100 +14,91 @@ from ..vendor.openff.interchange._annotations import _BoxQuantity as BoxQuantity from ..vendor.openff.interchange._annotations import ( _duck_to_nanometer, - _unit_validator_factory, + _unit_validator_factory as unit_validator, _unwrap_list_of_openmm_quantities, quantity_json_serializer, quantity_validator, ) -PydanticQuantity = Annotated[ +GufeQuantity = Annotated[ Quantity, WrapValidator(quantity_validator), WrapSerializer(quantity_json_serializer), ] -AngstromQuantity = Annotated[ - PydanticQuantity, - AfterValidator(_unit_validator_factory("angstrom")), -] NanometerQuantity = Annotated[ - PydanticQuantity, - AfterValidator(_unit_validator_factory("nanometer")), -] - -FemtosecondQuantity = Annotated[ - PydanticQuantity, - AfterValidator(_unit_validator_factory("femtosecond")), -] - -NanosecondQuantity = Annotated[ - PydanticQuantity, - AfterValidator(_unit_validator_factory("nanosecond")), -] - -PicosecondQuantity = Annotated[ - PydanticQuantity, - AfterValidator(_unit_validator_factory("picosecond")), -] - -InversePicosecondQuantity = Annotated[ - PydanticQuantity, - AfterValidator(_unit_validator_factory("1/picosecond")), + GufeQuantity, + AfterValidator(unit_validator("nanometer")), ] AtmQuantity = Annotated[ - PydanticQuantity, - AfterValidator(_unit_validator_factory("atm")), + GufeQuantity, + AfterValidator(unit_validator("atm")), ] KelvinQuantity = Annotated[ - PydanticQuantity, - AfterValidator(_unit_validator_factory("kelvin")), -] - -KCalPerMolQuantity = Annotated[ - PydanticQuantity, - AfterValidator(_unit_validator_factory("kilocalorie_per_mole")), -] - -TimestepQuantity = Annotated[ - PydanticQuantity, - AfterValidator(_unit_validator_factory("timestep")), -] - -SpringConstantLinearQuantity = Annotated[ - PydanticQuantity, - AfterValidator(_unit_validator_factory("kilojoule_per_mole / nm ** 2")), -] - -SpringConstantAngularQuantity = Annotated[ - PydanticQuantity, - AfterValidator(_unit_validator_factory("kilojoule_per_mole / radians ** 2")), -] - -RadiansQuantity = Annotated[ - PydanticQuantity, - AfterValidator(_unit_validator_factory("radians")), -] - -ArrayQuantity = Annotated[ - PydanticQuantity, - BeforeValidator(_unwrap_list_of_openmm_quantities), -] - -NanometerArrayQuantity = Annotated[ - PydanticQuantity, - AfterValidator(_unit_validator_factory("nanometer")), - BeforeValidator(_duck_to_nanometer), - BeforeValidator(_unwrap_list_of_openmm_quantities), -] - -# class CaseInsensitiveStrEnum(StrEnum): -# # SEE: https://docs.python.org/3/library/enum.html#enum.Enum._missing_ -# @classmethod -# def _missing_(cls, value): -# value = value.lower() -# for member in cls: -# if member.value == value: -# return member -# return None + GufeQuantity, + AfterValidator(unit_validator("kelvin")), +] + +# NanosecondQuantity = Annotated[ +# GufeQuantity, +# AfterValidator(unit_validator("nanosecond")), +# ] + +# AngstromQuantity = Annotated[ +# GufeQuantity, +# AfterValidator(unit_validator("angstrom")), +# ] + +# FemtosecondQuantity = Annotated[ +# GufeQuantity, +# AfterValidator(unit_validator("femtosecond")), +# ] + +# PicosecondQuantity = Annotated[ +# GufeQuantity, +# AfterValidator(unit_validator("picosecond")), +# ] + +# InversePicosecondQuantity = Annotated[ +# GufeQuantity, +# AfterValidator(unit_validator("1/picosecond")), +# ] + +# KCalPerMolQuantity = Annotated[ +# GufeQuantity, +# AfterValidator(unit_validator("kilocalorie_per_mole")), +# ] + +# TimestepQuantity = Annotated[ +# GufeQuantity, +# AfterValidator(unit_validator("timestep")), +# ] + +# SpringConstantLinearQuantity = Annotated[ +# GufeQuantity, +# AfterValidator(unit_validator("kilojoule_per_mole / nm ** 2")), +# ] + +# SpringConstantAngularQuantity = Annotated[ +# GufeQuantity, +# AfterValidator(unit_validator("kilojoule_per_mole / radians ** 2")), +# ] + +# RadiansQuantity = Annotated[ +# GufeQuantity, +# AfterValidator(unit_validator("radians")), +# ] + +# ArrayQuantity = Annotated[ +# GufeQuantity, +# BeforeValidator(_unwrap_list_of_openmm_quantities), +# ] + +# NanometerArrayQuantity = Annotated[ +# GufeQuantity, +# AfterValidator(unit_validator("nanometer")), +# BeforeValidator(_duck_to_nanometer), +# BeforeValidator(_unwrap_list_of_openmm_quantities), +# ] \ No newline at end of file From ec26064302e59148f4b0210b4086c27d396661ba Mon Sep 17 00:00:00 2001 From: Alyssa Travitz Date: Thu, 14 Aug 2025 15:08:34 -0700 Subject: [PATCH 073/105] array quantity --- gufe/settings/types.py | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/gufe/settings/types.py b/gufe/settings/types.py index 6dd167d9e..46310a136 100644 --- a/gufe/settings/types.py +++ b/gufe/settings/types.py @@ -91,14 +91,12 @@ # AfterValidator(unit_validator("radians")), # ] -# ArrayQuantity = Annotated[ -# GufeQuantity, -# BeforeValidator(_unwrap_list_of_openmm_quantities), -# ] +ArrayQuantity = Annotated[ + GufeQuantity, + BeforeValidator(_unwrap_list_of_openmm_quantities), +] -# NanometerArrayQuantity = Annotated[ -# GufeQuantity, -# AfterValidator(unit_validator("nanometer")), -# BeforeValidator(_duck_to_nanometer), -# BeforeValidator(_unwrap_list_of_openmm_quantities), -# ] \ No newline at end of file +NanometerArrayQuantity = Annotated[ + ArrayQuantity, + AfterValidator(unit_validator("nanometer")), +] \ No newline at end of file From 5c0e46d285e84f0eb3d26307cd52b763330de203 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 14 Aug 2025 22:10:10 +0000 Subject: [PATCH 074/105] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- docs/conf.py | 5 +++-- gufe/settings/types.py | 6 ++++-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index 4765f0227..914df9aee 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -74,8 +74,9 @@ autodoc_pydantic_model_show_config_summary = False autodoc_pydantic_model_show_validator_summary = False autodoc_pydantic_model_show_validator_members = False -autodoc_pydantic_model_show_json_error_strategy = "coerce" # TODO: we cannot currently generate schemas for models w/ pint quantities - +autodoc_pydantic_model_show_json_error_strategy = ( + "coerce" # TODO: we cannot currently generate schemas for models w/ pint quantities +) # -- Options for HTML output ------------------------------------------------- diff --git a/gufe/settings/types.py b/gufe/settings/types.py index 46310a136..7c53be19a 100644 --- a/gufe/settings/types.py +++ b/gufe/settings/types.py @@ -14,11 +14,13 @@ from ..vendor.openff.interchange._annotations import _BoxQuantity as BoxQuantity from ..vendor.openff.interchange._annotations import ( _duck_to_nanometer, - _unit_validator_factory as unit_validator, _unwrap_list_of_openmm_quantities, quantity_json_serializer, quantity_validator, ) +from ..vendor.openff.interchange._annotations import ( + _unit_validator_factory as unit_validator, +) GufeQuantity = Annotated[ Quantity, @@ -99,4 +101,4 @@ NanometerArrayQuantity = Annotated[ ArrayQuantity, AfterValidator(unit_validator("nanometer")), -] \ No newline at end of file +] From 298c957f1e1f55cbc0c40601062e0e85f63a6774 Mon Sep 17 00:00:00 2001 From: Alyssa Travitz Date: Thu, 14 Aug 2025 15:29:26 -0700 Subject: [PATCH 075/105] adding helper function --- gufe/settings/types.py | 69 +++++++++++++++++++++++++++--------------- 1 file changed, 45 insertions(+), 24 deletions(-) diff --git a/gufe/settings/types.py b/gufe/settings/types.py index 46310a136..c8f29147c 100644 --- a/gufe/settings/types.py +++ b/gufe/settings/types.py @@ -1,7 +1,6 @@ # adapted from from https://github.com/openforcefield/openff-interchange/blob/main/openff/interchange/_annotations.py -# from enum import StrEnum -from typing import Annotated +from typing import Annotated, Type from openff.units import Quantity from pydantic import ( @@ -13,8 +12,7 @@ from ..vendor.openff.interchange._annotations import _BoxQuantity as BoxQuantity from ..vendor.openff.interchange._annotations import ( - _duck_to_nanometer, - _unit_validator_factory as unit_validator, + _unit_validator_factory, _unwrap_list_of_openmm_quantities, quantity_json_serializer, quantity_validator, @@ -26,69 +24,92 @@ WrapSerializer(quantity_json_serializer), ] + +def custom_quantity(unit_name: str) -> Type: + """Helper function for generating custom quantity types. + + Parameters + ---------- + unit_name : str + unit name to validate against (e.g. 'nanometer') + + Returns + ------- + Type + A custom type that inherits from openff.units.Quantity. + """ + + CustomQuantity = Annotated[ + GufeQuantity, AfterValidator(_unit_validator_factory(unit_name)) + ] + return CustomQuantity + +# brute-force these custom types so that mypy recognizes them NanometerQuantity = Annotated[ GufeQuantity, - AfterValidator(unit_validator("nanometer")), + AfterValidator(_unit_validator_factory("nanometer")), ] AtmQuantity = Annotated[ GufeQuantity, - AfterValidator(unit_validator("atm")), + AfterValidator(_unit_validator_factory("atm")), ] KelvinQuantity = Annotated[ GufeQuantity, - AfterValidator(unit_validator("kelvin")), + AfterValidator(_unit_validator_factory("kelvin")), ] -# NanosecondQuantity = Annotated[ -# GufeQuantity, -# AfterValidator(unit_validator("nanosecond")), -# ] +# types used elsewhere in the ecosystem +# TODO: add tests here or let that happen in openfe? +NanosecondQuantity = Annotated[ + GufeQuantity, + AfterValidator(_unit_validator_factory("nanosecond")), +] -# AngstromQuantity = Annotated[ -# GufeQuantity, -# AfterValidator(unit_validator("angstrom")), -# ] +AngstromQuantity = Annotated[ + GufeQuantity, + AfterValidator(_unit_validator_factory("angstrom")), +] # FemtosecondQuantity = Annotated[ # GufeQuantity, -# AfterValidator(unit_validator("femtosecond")), +# AfterValidator(_unit_validator_factory("femtosecond")), # ] # PicosecondQuantity = Annotated[ # GufeQuantity, -# AfterValidator(unit_validator("picosecond")), +# AfterValidator(_unit_validator_factory("picosecond")), # ] # InversePicosecondQuantity = Annotated[ # GufeQuantity, -# AfterValidator(unit_validator("1/picosecond")), +# AfterValidator(_unit_validator_factory("1/picosecond")), # ] # KCalPerMolQuantity = Annotated[ # GufeQuantity, -# AfterValidator(unit_validator("kilocalorie_per_mole")), +# AfterValidator(_unit_validator_factory("kilocalorie_per_mole")), # ] # TimestepQuantity = Annotated[ # GufeQuantity, -# AfterValidator(unit_validator("timestep")), +# AfterValidator(_unit_validator_factory("timestep")), # ] # SpringConstantLinearQuantity = Annotated[ # GufeQuantity, -# AfterValidator(unit_validator("kilojoule_per_mole / nm ** 2")), +# AfterValidator(_unit_validator_factory("kilojoule_per_mole / nm ** 2")), # ] # SpringConstantAngularQuantity = Annotated[ # GufeQuantity, -# AfterValidator(unit_validator("kilojoule_per_mole / radians ** 2")), +# AfterValidator(_unit_validator_factory("kilojoule_per_mole / radians ** 2")), # ] # RadiansQuantity = Annotated[ # GufeQuantity, -# AfterValidator(unit_validator("radians")), +# AfterValidator(_unit_validator_factory("radians")), # ] ArrayQuantity = Annotated[ @@ -98,5 +119,5 @@ NanometerArrayQuantity = Annotated[ ArrayQuantity, - AfterValidator(unit_validator("nanometer")), + AfterValidator(_unit_validator_factory("nanometer")), ] \ No newline at end of file From 60f3a2c19ec041237cc7a0391694ffc029cd0866 Mon Sep 17 00:00:00 2001 From: Alyssa Travitz Date: Thu, 14 Aug 2025 15:41:45 -0700 Subject: [PATCH 076/105] move some specific classes to openfe --- gufe/settings/types.py | 45 +++++------------------------------------- 1 file changed, 5 insertions(+), 40 deletions(-) diff --git a/gufe/settings/types.py b/gufe/settings/types.py index 9b3e8515f..1389cbd19 100644 --- a/gufe/settings/types.py +++ b/gufe/settings/types.py @@ -28,7 +28,7 @@ ] -def custom_quantity(unit_name: str) -> Type: +def make_custom_quantity(unit_name: str) -> Type: """Helper function for generating custom quantity types. Parameters @@ -75,45 +75,10 @@ def custom_quantity(unit_name: str) -> Type: AfterValidator(_unit_validator_factory("angstrom")), ] -# FemtosecondQuantity = Annotated[ -# GufeQuantity, -# AfterValidator(_unit_validator_factory("femtosecond")), -# ] - -# PicosecondQuantity = Annotated[ -# GufeQuantity, -# AfterValidator(_unit_validator_factory("picosecond")), -# ] - -# InversePicosecondQuantity = Annotated[ -# GufeQuantity, -# AfterValidator(_unit_validator_factory("1/picosecond")), -# ] - -# KCalPerMolQuantity = Annotated[ -# GufeQuantity, -# AfterValidator(_unit_validator_factory("kilocalorie_per_mole")), -# ] - -# TimestepQuantity = Annotated[ -# GufeQuantity, -# AfterValidator(_unit_validator_factory("timestep")), -# ] - -# SpringConstantLinearQuantity = Annotated[ -# GufeQuantity, -# AfterValidator(_unit_validator_factory("kilojoule_per_mole / nm ** 2")), -# ] - -# SpringConstantAngularQuantity = Annotated[ -# GufeQuantity, -# AfterValidator(_unit_validator_factory("kilojoule_per_mole / radians ** 2")), -# ] - -# RadiansQuantity = Annotated[ -# GufeQuantity, -# AfterValidator(_unit_validator_factory("radians")), -# ] +KCalPerMolQuantity = Annotated[ + GufeQuantity, + AfterValidator(_unit_validator_factory("kilocalorie_per_mole")), +] ArrayQuantity = Annotated[ GufeQuantity, From 1a7faf79a67a272d2f4e7fac06d76d7e21e67269 Mon Sep 17 00:00:00 2001 From: Alyssa Travitz Date: Thu, 14 Aug 2025 15:49:46 -0700 Subject: [PATCH 077/105] format --- gufe/settings/types.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/gufe/settings/types.py b/gufe/settings/types.py index 1389cbd19..30c528943 100644 --- a/gufe/settings/types.py +++ b/gufe/settings/types.py @@ -42,11 +42,10 @@ def make_custom_quantity(unit_name: str) -> Type: A custom type that inherits from openff.units.Quantity. """ - CustomQuantity = Annotated[ - GufeQuantity, AfterValidator(_unit_validator_factory(unit_name)) - ] + CustomQuantity = Annotated[GufeQuantity, AfterValidator(_unit_validator_factory(unit_name))] return CustomQuantity + # brute-force these custom types so that mypy recognizes them NanometerQuantity = Annotated[ GufeQuantity, From ae1025455bd8a0c1940004d6e877632c1e2a9bb9 Mon Sep 17 00:00:00 2001 From: Alyssa Travitz Date: Thu, 14 Aug 2025 15:55:37 -0700 Subject: [PATCH 078/105] add back picosecond --- gufe/settings/types.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/gufe/settings/types.py b/gufe/settings/types.py index 30c528943..3d551d96a 100644 --- a/gufe/settings/types.py +++ b/gufe/settings/types.py @@ -69,6 +69,11 @@ def make_custom_quantity(unit_name: str) -> Type: AfterValidator(_unit_validator_factory("nanosecond")), ] +PicosecondQuantity = Annotated[ + GufeQuantity, + AfterValidator(_unit_validator_factory("picosecond")), +] + AngstromQuantity = Annotated[ GufeQuantity, AfterValidator(_unit_validator_factory("angstrom")), From de2ae5460df8b3cc673285e4e772e41c2b7695ea Mon Sep 17 00:00:00 2001 From: Alyssa Travitz Date: Thu, 14 Aug 2025 21:05:18 -0700 Subject: [PATCH 079/105] tests pass, but i don't trust it --- gufe/settings/types.py | 39 ++++++++++--- gufe/settings/types_works.py | 92 +++++++++++++++++++++++++++++ gufe/tests/test_models.py | 110 ++++++++++++++++++----------------- 3 files changed, 178 insertions(+), 63 deletions(-) create mode 100644 gufe/settings/types_works.py diff --git a/gufe/settings/types.py b/gufe/settings/types.py index 3d551d96a..3eabfd565 100644 --- a/gufe/settings/types.py +++ b/gufe/settings/types.py @@ -1,14 +1,18 @@ # adapted from from https://github.com/openforcefield/openff-interchange/blob/main/openff/interchange/_annotations.py -from typing import Annotated, Type +from typing import Annotated, Any, Type from openff.units import Quantity from pydantic import ( AfterValidator, BeforeValidator, + GetCoreSchemaHandler, + ValidationInfo, + ValidatorFunctionWrapHandler, WrapSerializer, WrapValidator, ) +from pydantic_core import core_schema from ..vendor.openff.interchange._annotations import _BoxQuantity as BoxQuantity from ..vendor.openff.interchange._annotations import ( @@ -17,15 +21,32 @@ quantity_json_serializer, quantity_validator, ) -from ..vendor.openff.interchange._annotations import ( - _unit_validator_factory as unit_validator, -) -GufeQuantity = Annotated[ - Quantity, - WrapValidator(quantity_validator), - WrapSerializer(quantity_json_serializer), -] + +class _QuantityPydanticAnnotation: + @classmethod + def __get_pydantic_core_schema__( + cls, + source: Any, + handler: GetCoreSchemaHandler, + ) -> core_schema.CoreSchema: + json_schema = core_schema.with_info_wrap_validator_function( + function=quantity_validator, + schema=core_schema.str_schema(), + ) + python_schema = core_schema.with_info_wrap_validator_function( + function=quantity_validator, + schema=core_schema.is_instance_schema(Quantity), + ) + serialize_schema = core_schema.wrap_serializer_function_ser_schema(quantity_json_serializer) + return core_schema.json_or_python_schema( + json_schema=json_schema, + python_schema=python_schema, + serialization=serialize_schema, + ) + + +GufeQuantity = Annotated[Quantity, _QuantityPydanticAnnotation] def make_custom_quantity(unit_name: str) -> Type: diff --git a/gufe/settings/types_works.py b/gufe/settings/types_works.py new file mode 100644 index 000000000..4ddeee259 --- /dev/null +++ b/gufe/settings/types_works.py @@ -0,0 +1,92 @@ +# adapted from from https://github.com/openforcefield/openff-interchange/blob/main/openff/interchange/_annotations.py + +from typing import Annotated, Type + +from openff.units import Quantity +from pydantic import ( + AfterValidator, + BeforeValidator, + WrapSerializer, + WrapValidator, +) + +from ..vendor.openff.interchange._annotations import _BoxQuantity as BoxQuantity +from ..vendor.openff.interchange._annotations import ( + _unit_validator_factory, + _unwrap_list_of_openmm_quantities, + quantity_json_serializer, + quantity_validator, +) + +GufeQuantity = Annotated[ + Quantity, + WrapValidator(quantity_validator), + WrapSerializer(quantity_json_serializer), +] + + +def make_custom_quantity(unit_name: str) -> Type: + """Helper function for generating custom quantity types. + + Parameters + ---------- + unit_name : str + unit name to validate against (e.g. 'nanometer') + + Returns + ------- + Type + A custom type that inherits from openff.units.Quantity. + """ + + CustomQuantity = Annotated[GufeQuantity, AfterValidator(_unit_validator_factory(unit_name))] + return CustomQuantity + + +# brute-force these custom types so that mypy recognizes them +NanometerQuantity = Annotated[ + GufeQuantity, + AfterValidator(_unit_validator_factory("nanometer")), +] + +AtmQuantity = Annotated[ + GufeQuantity, + AfterValidator(_unit_validator_factory("atm")), +] + +KelvinQuantity = Annotated[ + GufeQuantity, + AfterValidator(_unit_validator_factory("kelvin")), +] + +# types used elsewhere in the ecosystem +# TODO: add tests here or let that happen in openfe? +NanosecondQuantity = Annotated[ + GufeQuantity, + AfterValidator(_unit_validator_factory("nanosecond")), +] + +PicosecondQuantity = Annotated[ + GufeQuantity, + AfterValidator(_unit_validator_factory("picosecond")), +] + +AngstromQuantity = Annotated[ + GufeQuantity, + AfterValidator(_unit_validator_factory("angstrom")), +] + +KCalPerMolQuantity = Annotated[ + GufeQuantity, + AfterValidator(_unit_validator_factory("kilocalorie_per_mole")), +] + +ArrayQuantity = Annotated[ + GufeQuantity, + BeforeValidator(_unwrap_list_of_openmm_quantities), +] + +NanometerArrayQuantity = Annotated[ + ArrayQuantity, + AfterValidator(_unit_validator_factory("nanometer")), +] diff --git a/gufe/tests/test_models.py b/gufe/tests/test_models.py index fdd496db5..b69eea680 100644 --- a/gufe/tests/test_models.py +++ b/gufe/tests/test_models.py @@ -14,60 +14,62 @@ def test_settings_schema(): """Settings schema should be stable""" - # expected_schema = { - # "$defs": { - # "BaseForceFieldSettings": { - # "additionalProperties": False, - # "description": "Base class for ForceFieldSettings objects", - # "properties": {}, - # "title": "BaseForceFieldSettings", - # "type": "object", - # }, - # "ThermoSettings": { - # "additionalProperties": False, - # "description": "Settings for thermodynamic parameters.\n\n.. note::\n No checking is done to ensure a valid thermodynamic ensemble is\n possible.", - # "properties": { - # "temperature": { - # "anyOf": [{"additionalProperties": True, "type": "object"}, {"type": "null"}], - # "default": None, - # "description": "Simulation temperature, default units kelvin", - # "title": "Temperature", - # }, - # "pressure": { - # "anyOf": [{"additionalProperties": True, "type": "object"}, {"type": "null"}], - # "default": None, - # "description": "Simulation pressure, default units standard atmosphere (atm)", - # "title": "Pressure", - # }, - # "ph": { - # "anyOf": [{"exclusiveMinimum": 0, "type": "number"}, {"type": "null"}], - # "default": None, - # "description": "Simulation pH", - # "title": "Ph", - # }, - # "redox_potential": { - # "anyOf": [{"type": "number"}, {"type": "null"}], - # "default": None, - # "description": "Simulation redox potential", - # "title": "Redox Potential", - # }, - # }, - # "title": "ThermoSettings", - # "type": "object", - # }, - # }, - # "additionalProperties": False, - # "description": "Container for all settings needed by a protocol\n\nThis represents the minimal surface that all settings objects will have.\n\nProtocols can subclass this to extend this to cater for their additional settings.", - # "properties": { - # "forcefield_settings": {"$ref": "#/$defs/BaseForceFieldSettings"}, - # "thermo_settings": {"$ref": "#/$defs/ThermoSettings"}, - # }, - # "required": ["forcefield_settings", "thermo_settings"], - # "title": "Settings", - # "type": "object", - # } - schema = Settings.model_json_schema(mode="serialization") - # assert schema == expected_schema + expected_schema = { + "$defs": { + "BaseForceFieldSettings": { + "additionalProperties": False, + "description": "Base class for ForceFieldSettings objects", + "properties": {}, + "title": "BaseForceFieldSettings", + "type": "object", + }, + "ThermoSettings": { + "additionalProperties": False, + "description": "Settings for thermodynamic parameters.\n\n.. note::\n No checking is done to ensure a valid thermodynamic ensemble is\n possible.", + "properties": { + "temperature": { + "anyOf": [{"additionalProperties": True, "type": "object"}, {"type": "null"}], + "default": None, + "description": "Simulation temperature, default units kelvin", + "title": "Temperature", + }, + "pressure": { + "anyOf": [{"additionalProperties": True, "type": "object"}, {"type": "null"}], + "default": None, + "description": "Simulation pressure, default units standard atmosphere (atm)", + "title": "Pressure", + }, + "ph": { + "anyOf": [{"exclusiveMinimum": 0, "type": "number"}, {"type": "null"}], + "default": None, + "description": "Simulation pH", + "title": "Ph", + }, + "redox_potential": { + "anyOf": [{"type": "number"}, {"type": "null"}], + "default": None, + "description": "Simulation redox potential", + "title": "Redox Potential", + }, + }, + "title": "ThermoSettings", + "type": "object", + }, + }, + "additionalProperties": False, + "description": "Container for all settings needed by a protocol\n\nThis represents the minimal surface that all settings objects will have.\n\nProtocols can subclass this to extend this to cater for their additional settings.", + "properties": { + "forcefield_settings": {"$ref": "#/$defs/BaseForceFieldSettings"}, + "thermo_settings": {"$ref": "#/$defs/ThermoSettings"}, + }, + "required": ["forcefield_settings", "thermo_settings"], + "title": "Settings", + "type": "object", + } + breakpoint() + ser_schema = Settings.model_json_schema(mode="serialization") + val_schema = Settings.model_json_schema(mode="validation") + assert ser_schema == expected_schema def test_default_settings(): From be261c2715f965658050cb78affe83c5dd6ec8c8 Mon Sep 17 00:00:00 2001 From: Alyssa Travitz Date: Thu, 14 Aug 2025 21:29:32 -0700 Subject: [PATCH 080/105] add dict schema --- gufe/settings/types.py | 13 ++++++++----- gufe/tests/test_models.py | 15 ++++++++------- 2 files changed, 16 insertions(+), 12 deletions(-) diff --git a/gufe/settings/types.py b/gufe/settings/types.py index 3eabfd565..5e8b2fa73 100644 --- a/gufe/settings/types.py +++ b/gufe/settings/types.py @@ -7,10 +7,6 @@ AfterValidator, BeforeValidator, GetCoreSchemaHandler, - ValidationInfo, - ValidatorFunctionWrapHandler, - WrapSerializer, - WrapValidator, ) from pydantic_core import core_schema @@ -30,9 +26,16 @@ def __get_pydantic_core_schema__( source: Any, handler: GetCoreSchemaHandler, ) -> core_schema.CoreSchema: + """ + This Annotation lets us define a GufeQuantity that is identical to + an openff-units Quantity, except it's also pydantic-compatible. + """ json_schema = core_schema.with_info_wrap_validator_function( function=quantity_validator, - schema=core_schema.str_schema(), + schema=core_schema.dict_schema( + values_schema=core_schema.float_schema(), # TODO not just floats + keys_schema=core_schema.str_schema(), + ), ) python_schema = core_schema.with_info_wrap_validator_function( function=quantity_validator, diff --git a/gufe/tests/test_models.py b/gufe/tests/test_models.py index b69eea680..f01006922 100644 --- a/gufe/tests/test_models.py +++ b/gufe/tests/test_models.py @@ -28,15 +28,15 @@ def test_settings_schema(): "description": "Settings for thermodynamic parameters.\n\n.. note::\n No checking is done to ensure a valid thermodynamic ensemble is\n possible.", "properties": { "temperature": { - "anyOf": [{"additionalProperties": True, "type": "object"}, {"type": "null"}], + "anyOf": [{"additionalProperties": {"type": "number"}, "type": "object"}, {"type": "null"}], "default": None, - "description": "Simulation temperature, default units kelvin", + "description": "Simulation temperature in kelvin)", "title": "Temperature", }, "pressure": { - "anyOf": [{"additionalProperties": True, "type": "object"}, {"type": "null"}], + "anyOf": [{"additionalProperties": {"type": "number"}, "type": "object"}, {"type": "null"}], "default": None, - "description": "Simulation pressure, default units standard atmosphere (atm)", + "description": "Simulation pressure in standard atmosphere (atm)", "title": "Pressure", }, "ph": { @@ -59,17 +59,18 @@ def test_settings_schema(): "additionalProperties": False, "description": "Container for all settings needed by a protocol\n\nThis represents the minimal surface that all settings objects will have.\n\nProtocols can subclass this to extend this to cater for their additional settings.", "properties": { - "forcefield_settings": {"$ref": "#/$defs/BaseForceFieldSettings"}, - "thermo_settings": {"$ref": "#/$defs/ThermoSettings"}, + "forcefield_settings": {"$ref": "#/$defs/BaseForceFieldSettings", "title": "Forcefield Settings"}, + "thermo_settings": {"$ref": "#/$defs/ThermoSettings", "title": "Thermo Settings"}, }, "required": ["forcefield_settings", "thermo_settings"], "title": "Settings", "type": "object", } - breakpoint() ser_schema = Settings.model_json_schema(mode="serialization") val_schema = Settings.model_json_schema(mode="validation") + # TODO: should our serialization and validation schemas really be the same? assert ser_schema == expected_schema + assert val_schema == expected_schema def test_default_settings(): From bdf4fbc74812309a7fb48be772e7d8e830277a9c Mon Sep 17 00:00:00 2001 From: Alyssa Travitz Date: Thu, 14 Aug 2025 21:31:20 -0700 Subject: [PATCH 081/105] float schema for backwards compatiblity --- gufe/settings/types.py | 6 +----- gufe/tests/test_models.py | 4 ++-- 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/gufe/settings/types.py b/gufe/settings/types.py index 5e8b2fa73..418987713 100644 --- a/gufe/settings/types.py +++ b/gufe/settings/types.py @@ -31,11 +31,7 @@ def __get_pydantic_core_schema__( an openff-units Quantity, except it's also pydantic-compatible. """ json_schema = core_schema.with_info_wrap_validator_function( - function=quantity_validator, - schema=core_schema.dict_schema( - values_schema=core_schema.float_schema(), # TODO not just floats - keys_schema=core_schema.str_schema(), - ), + function=quantity_validator, schema=core_schema.float_schema() ) python_schema = core_schema.with_info_wrap_validator_function( function=quantity_validator, diff --git a/gufe/tests/test_models.py b/gufe/tests/test_models.py index f01006922..2e2e59189 100644 --- a/gufe/tests/test_models.py +++ b/gufe/tests/test_models.py @@ -28,13 +28,13 @@ def test_settings_schema(): "description": "Settings for thermodynamic parameters.\n\n.. note::\n No checking is done to ensure a valid thermodynamic ensemble is\n possible.", "properties": { "temperature": { - "anyOf": [{"additionalProperties": {"type": "number"}, "type": "object"}, {"type": "null"}], + "anyOf": [{"type": "number"}, {"type": "null"}], "default": None, "description": "Simulation temperature in kelvin)", "title": "Temperature", }, "pressure": { - "anyOf": [{"additionalProperties": {"type": "number"}, "type": "object"}, {"type": "null"}], + "anyOf": [{"type": "number"}, {"type": "null"}], "default": None, "description": "Simulation pressure in standard atmosphere (atm)", "title": "Pressure", From 36f98e8b1271377e9c4660bedd99729331be1691 Mon Sep 17 00:00:00 2001 From: Alyssa Travitz Date: Thu, 14 Aug 2025 21:55:57 -0700 Subject: [PATCH 082/105] everybody is a typealias --- gufe/settings/types.py | 20 ++++---- gufe/settings/types_works.py | 92 ------------------------------------ 2 files changed, 10 insertions(+), 102 deletions(-) delete mode 100644 gufe/settings/types_works.py diff --git a/gufe/settings/types.py b/gufe/settings/types.py index 418987713..402000295 100644 --- a/gufe/settings/types.py +++ b/gufe/settings/types.py @@ -1,6 +1,6 @@ # adapted from from https://github.com/openforcefield/openff-interchange/blob/main/openff/interchange/_annotations.py -from typing import Annotated, Any, Type +from typing import Annotated, Any, Type, TypeAlias from openff.units import Quantity from pydantic import ( @@ -67,49 +67,49 @@ def make_custom_quantity(unit_name: str) -> Type: # brute-force these custom types so that mypy recognizes them -NanometerQuantity = Annotated[ +NanometerQuantity: TypeAlias = Annotated[ GufeQuantity, AfterValidator(_unit_validator_factory("nanometer")), ] -AtmQuantity = Annotated[ +AtmQuantity: TypeAlias = Annotated[ GufeQuantity, AfterValidator(_unit_validator_factory("atm")), ] -KelvinQuantity = Annotated[ +KelvinQuantity: TypeAlias = Annotated[ GufeQuantity, AfterValidator(_unit_validator_factory("kelvin")), ] # types used elsewhere in the ecosystem # TODO: add tests here or let that happen in openfe? -NanosecondQuantity = Annotated[ +NanosecondQuantity: TypeAlias = Annotated[ GufeQuantity, AfterValidator(_unit_validator_factory("nanosecond")), ] -PicosecondQuantity = Annotated[ +PicosecondQuantity: TypeAlias = Annotated[ GufeQuantity, AfterValidator(_unit_validator_factory("picosecond")), ] -AngstromQuantity = Annotated[ +AngstromQuantity: TypeAlias = Annotated[ GufeQuantity, AfterValidator(_unit_validator_factory("angstrom")), ] -KCalPerMolQuantity = Annotated[ +KCalPerMolQuantity: TypeAlias = Annotated[ GufeQuantity, AfterValidator(_unit_validator_factory("kilocalorie_per_mole")), ] -ArrayQuantity = Annotated[ +ArrayQuantity: TypeAlias = Annotated[ GufeQuantity, BeforeValidator(_unwrap_list_of_openmm_quantities), ] -NanometerArrayQuantity = Annotated[ +NanometerArrayQuantity: TypeAlias = Annotated[ ArrayQuantity, AfterValidator(_unit_validator_factory("nanometer")), ] diff --git a/gufe/settings/types_works.py b/gufe/settings/types_works.py deleted file mode 100644 index 4ddeee259..000000000 --- a/gufe/settings/types_works.py +++ /dev/null @@ -1,92 +0,0 @@ -# adapted from from https://github.com/openforcefield/openff-interchange/blob/main/openff/interchange/_annotations.py - -from typing import Annotated, Type - -from openff.units import Quantity -from pydantic import ( - AfterValidator, - BeforeValidator, - WrapSerializer, - WrapValidator, -) - -from ..vendor.openff.interchange._annotations import _BoxQuantity as BoxQuantity -from ..vendor.openff.interchange._annotations import ( - _unit_validator_factory, - _unwrap_list_of_openmm_quantities, - quantity_json_serializer, - quantity_validator, -) - -GufeQuantity = Annotated[ - Quantity, - WrapValidator(quantity_validator), - WrapSerializer(quantity_json_serializer), -] - - -def make_custom_quantity(unit_name: str) -> Type: - """Helper function for generating custom quantity types. - - Parameters - ---------- - unit_name : str - unit name to validate against (e.g. 'nanometer') - - Returns - ------- - Type - A custom type that inherits from openff.units.Quantity. - """ - - CustomQuantity = Annotated[GufeQuantity, AfterValidator(_unit_validator_factory(unit_name))] - return CustomQuantity - - -# brute-force these custom types so that mypy recognizes them -NanometerQuantity = Annotated[ - GufeQuantity, - AfterValidator(_unit_validator_factory("nanometer")), -] - -AtmQuantity = Annotated[ - GufeQuantity, - AfterValidator(_unit_validator_factory("atm")), -] - -KelvinQuantity = Annotated[ - GufeQuantity, - AfterValidator(_unit_validator_factory("kelvin")), -] - -# types used elsewhere in the ecosystem -# TODO: add tests here or let that happen in openfe? -NanosecondQuantity = Annotated[ - GufeQuantity, - AfterValidator(_unit_validator_factory("nanosecond")), -] - -PicosecondQuantity = Annotated[ - GufeQuantity, - AfterValidator(_unit_validator_factory("picosecond")), -] - -AngstromQuantity = Annotated[ - GufeQuantity, - AfterValidator(_unit_validator_factory("angstrom")), -] - -KCalPerMolQuantity = Annotated[ - GufeQuantity, - AfterValidator(_unit_validator_factory("kilocalorie_per_mole")), -] - -ArrayQuantity = Annotated[ - GufeQuantity, - BeforeValidator(_unwrap_list_of_openmm_quantities), -] - -NanometerArrayQuantity = Annotated[ - ArrayQuantity, - AfterValidator(_unit_validator_factory("nanometer")), -] From e400fe28a3cc589346f914c64120d10e093b0b1b Mon Sep 17 00:00:00 2001 From: Alyssa Travitz Date: Thu, 14 Aug 2025 22:22:41 -0700 Subject: [PATCH 083/105] wrap into nicer function --- gufe/settings/types.py | 28 +++++++++++++++------------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/gufe/settings/types.py b/gufe/settings/types.py index 402000295..e1185b099 100644 --- a/gufe/settings/types.py +++ b/gufe/settings/types.py @@ -48,7 +48,7 @@ def __get_pydantic_core_schema__( GufeQuantity = Annotated[Quantity, _QuantityPydanticAnnotation] -def make_custom_quantity(unit_name: str) -> Type: +def specify_quantity_units(unit_name: str) -> AfterValidator: """Helper function for generating custom quantity types. Parameters @@ -58,50 +58,52 @@ def make_custom_quantity(unit_name: str) -> Type: Returns ------- - Type - A custom type that inherits from openff.units.Quantity. + AfterValidator + An AfterValidator for defining a custom Quantity type. + + + """ - CustomQuantity = Annotated[GufeQuantity, AfterValidator(_unit_validator_factory(unit_name))] - return CustomQuantity + return AfterValidator(_unit_validator_factory(unit_name)) # brute-force these custom types so that mypy recognizes them NanometerQuantity: TypeAlias = Annotated[ GufeQuantity, - AfterValidator(_unit_validator_factory("nanometer")), + specify_quantity_units("nanometer"), ] AtmQuantity: TypeAlias = Annotated[ GufeQuantity, - AfterValidator(_unit_validator_factory("atm")), + specify_quantity_units("atm"), ] KelvinQuantity: TypeAlias = Annotated[ GufeQuantity, - AfterValidator(_unit_validator_factory("kelvin")), + specify_quantity_units("kelvin"), ] # types used elsewhere in the ecosystem # TODO: add tests here or let that happen in openfe? NanosecondQuantity: TypeAlias = Annotated[ GufeQuantity, - AfterValidator(_unit_validator_factory("nanosecond")), + specify_quantity_units("nanosecond"), ] PicosecondQuantity: TypeAlias = Annotated[ GufeQuantity, - AfterValidator(_unit_validator_factory("picosecond")), + specify_quantity_units("picosecond"), ] AngstromQuantity: TypeAlias = Annotated[ GufeQuantity, - AfterValidator(_unit_validator_factory("angstrom")), + specify_quantity_units("angstrom"), ] KCalPerMolQuantity: TypeAlias = Annotated[ GufeQuantity, - AfterValidator(_unit_validator_factory("kilocalorie_per_mole")), + specify_quantity_units("kilocalorie_per_mole"), ] ArrayQuantity: TypeAlias = Annotated[ @@ -111,5 +113,5 @@ def make_custom_quantity(unit_name: str) -> Type: NanometerArrayQuantity: TypeAlias = Annotated[ ArrayQuantity, - AfterValidator(_unit_validator_factory("nanometer")), + specify_quantity_units("nanometer"), ] From 6be5f720ef60f7823722b6559fb9e42830a60793 Mon Sep 17 00:00:00 2001 From: Alyssa Travitz Date: Mon, 18 Aug 2025 15:57:19 -0700 Subject: [PATCH 084/105] update news items --- news/{pydantic_changes.rst => pydantic_v2.rst} | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) rename news/{pydantic_changes.rst => pydantic_v2.rst} (69%) diff --git a/news/pydantic_changes.rst b/news/pydantic_v2.rst similarity index 69% rename from news/pydantic_changes.rst rename to news/pydantic_v2.rst index b76cc6cfc..d7c6e15f1 100644 --- a/news/pydantic_changes.rst +++ b/news/pydantic_v2.rst @@ -4,14 +4,15 @@ **Changed:** -* system generator setting ``nonbonded_cutoff`` no longer attempts to coerce ambiguous inputs to ``unit.nanometer``. Instead, a length unit is required, e.g. ``2.2 * unit.nanometer`` or ``"2.2 nm"``. +* ``FloatQuantity`` is no longer supported. Instead, use `GufeQuantity` and `specify_quantity_units()` to make a `TypeAlias`. +* System generator setting ``nonbonded_cutoff`` no longer attempts to coerce ambiguous inputs to ``unit.nanometer``. Instead, a length unit is required, e.g. ``2.2 * unit.nanometer`` or ``"2.2 nm"``. * ``ThermoSettings`` parameters ``pressure`` and ``temperature`` no longer attempt to coerce ambiguous inputs to unts. Instead, the units must be passed explicitly, e.g. ``1.0 * units.atm`` or ``"1 atm"`` for pressure, and ``300 * unit.kelvin`` or ``"300 kelvin"`` for temperature. -* system generator setting ``nonbonded_method`` now is case sensitive and must be one of ``"CutoffNonPeriodic", "CutoffPeriodic", "Ewald", "LJPME", "NoCutoff", "PME"``. **Deprecated:** -* +* +.. TODO: add a link to docs **Removed:** * From 29d638a69fff72c29a715b25c52fadf42288214f Mon Sep 17 00:00:00 2001 From: Alyssa Travitz Date: Wed, 20 Aug 2025 10:13:44 -0700 Subject: [PATCH 085/105] ArrayQuantity -> GufeArrayQuantity --- gufe/settings/types.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/gufe/settings/types.py b/gufe/settings/types.py index e1185b099..47feb7c56 100644 --- a/gufe/settings/types.py +++ b/gufe/settings/types.py @@ -67,8 +67,6 @@ def specify_quantity_units(unit_name: str) -> AfterValidator: return AfterValidator(_unit_validator_factory(unit_name)) - -# brute-force these custom types so that mypy recognizes them NanometerQuantity: TypeAlias = Annotated[ GufeQuantity, specify_quantity_units("nanometer"), @@ -85,7 +83,6 @@ def specify_quantity_units(unit_name: str) -> AfterValidator: ] # types used elsewhere in the ecosystem -# TODO: add tests here or let that happen in openfe? NanosecondQuantity: TypeAlias = Annotated[ GufeQuantity, specify_quantity_units("nanosecond"), @@ -106,12 +103,12 @@ def specify_quantity_units(unit_name: str) -> AfterValidator: specify_quantity_units("kilocalorie_per_mole"), ] -ArrayQuantity: TypeAlias = Annotated[ +GufeArrayQuantity: TypeAlias = Annotated[ GufeQuantity, BeforeValidator(_unwrap_list_of_openmm_quantities), ] NanometerArrayQuantity: TypeAlias = Annotated[ - ArrayQuantity, + GufeArrayQuantity, specify_quantity_units("nanometer"), ] From c18c9b0e22bab11b0d773ab3260e1021a93cb9ce Mon Sep 17 00:00:00 2001 From: Alyssa Travitz Date: Wed, 20 Aug 2025 10:21:59 -0700 Subject: [PATCH 086/105] remove unused code --- gufe/settings/models.py | 7 ------- gufe/settings/types.py | 2 +- 2 files changed, 1 insertion(+), 8 deletions(-) diff --git a/gufe/settings/models.py b/gufe/settings/models.py index 3958b0960..c62a125ba 100644 --- a/gufe/settings/models.py +++ b/gufe/settings/models.py @@ -23,7 +23,6 @@ class SettingsBaseModel(_BaseModel): model_config = ConfigDict( extra="forbid", arbitrary_types_allowed=True, # needed to parse custom types - # use_enum_values=True, ) def _ipython_display_(self): @@ -115,12 +114,6 @@ class BaseForceFieldSettings(SettingsBaseModel, abc.ABC): ... -# class ConstraintEnum(CaseInsensitiveStrEnum): -# hbonds = "hbonds" -# allbonds = "allbonds" -# hangles = "hangles" - - def _to_lowercase(value: Any): """make any string input lowercase""" if isinstance(value, (str)): diff --git a/gufe/settings/types.py b/gufe/settings/types.py index 47feb7c56..2b9b3139a 100644 --- a/gufe/settings/types.py +++ b/gufe/settings/types.py @@ -10,7 +10,6 @@ ) from pydantic_core import core_schema -from ..vendor.openff.interchange._annotations import _BoxQuantity as BoxQuantity from ..vendor.openff.interchange._annotations import ( _unit_validator_factory, _unwrap_list_of_openmm_quantities, @@ -67,6 +66,7 @@ def specify_quantity_units(unit_name: str) -> AfterValidator: return AfterValidator(_unit_validator_factory(unit_name)) + NanometerQuantity: TypeAlias = Annotated[ GufeQuantity, specify_quantity_units("nanometer"), From 5fe9d252b49131d65408091c8d58b7404505d637 Mon Sep 17 00:00:00 2001 From: Alyssa Travitz <31974495+atravitz@users.noreply.github.com> Date: Wed, 20 Aug 2025 10:39:00 -0700 Subject: [PATCH 087/105] Apply suggestions from code review Co-authored-by: David L. Dotson --- gufe/tests/test_models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gufe/tests/test_models.py b/gufe/tests/test_models.py index 2e2e59189..9c2d6a4de 100644 --- a/gufe/tests/test_models.py +++ b/gufe/tests/test_models.py @@ -110,7 +110,7 @@ def test_openmmff_constraints(self, value, valid, expected): ("1.1 nm", True, 1.1 * unit.nanometer), ("1.1", False, None), (-1.0 * unit.nanometer, False, None), - # NOTE: this is not precisely equal for smaller values due to pint unit floating point precision things. + # NOTE: this is not precisely equal for smaller values due to pint unit floating point precision (100.0 * unit.angstrom, True, 10.0 * unit.nanometer), (300 * unit.kelvin, False, None), (True, False, None), From c7c6bc68b2d13bc7e2b2ce7ff832b66e2d003e3c Mon Sep 17 00:00:00 2001 From: Alyssa Travitz Date: Wed, 20 Aug 2025 10:43:25 -0700 Subject: [PATCH 088/105] remove unneeded TODO --- gufe/settings/models.py | 1 - 1 file changed, 1 deletion(-) diff --git a/gufe/settings/models.py b/gufe/settings/models.py index c62a125ba..cd83cf63b 100644 --- a/gufe/settings/models.py +++ b/gufe/settings/models.py @@ -174,7 +174,6 @@ class OpenMMSystemGeneratorFFSettings(BaseForceFieldSettings): @field_validator("nonbonded_method", mode="after") def allowed_nonbonded(cls, v): - # TODO: replace this with an annotated Literal. options = ["CutoffNonPeriodic", "CutoffPeriodic", "Ewald", "LJPME", "NoCutoff", "PME"] if v.lower() not in [x.lower() for x in options]: errmsg = f"Only {options} are allowed nonbonded_methods" From 96a4cbf3780880a2aeb942c6d97d5cb62845ad41 Mon Sep 17 00:00:00 2001 From: Alyssa Travitz Date: Wed, 20 Aug 2025 10:50:27 -0700 Subject: [PATCH 089/105] remove straggling comments --- gufe/settings/models.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/gufe/settings/models.py b/gufe/settings/models.py index cd83cf63b..bb3b65725 100644 --- a/gufe/settings/models.py +++ b/gufe/settings/models.py @@ -158,9 +158,6 @@ class OpenMMSystemGeneratorFFSettings(BaseForceFieldSettings): """Name of the force field to be used for :class:`SmallMoleculeComponent` """ nonbonded_method: str = "PME" - # TODO: using either of the following will break serialization, look into this. - # nonbonded_method: Annotated[Literal["cutoffnonperiodic", "cutoffperiodic", "ewald", "ljpme", "nocutoff", "pme"], BeforeValidator(_to_lowercase)] | None = "PME" - # nonbonded_method: CaseInsensitiveStrEnum("NonbondedMethod", ["CutoffPeriodic", "Ewald", "LJPME", "NoCutoff", "PME"]) = "PME" """ Method for treating nonbonded interactions, options are currently "CutoffNonPeriodic", "CutoffPeriodic", "Ewald", "LJPME", "NoCutoff", "PME". @@ -173,7 +170,7 @@ class OpenMMSystemGeneratorFFSettings(BaseForceFieldSettings): """ @field_validator("nonbonded_method", mode="after") - def allowed_nonbonded(cls, v): + def allowed_nonbonded_methods(cls, v): options = ["CutoffNonPeriodic", "CutoffPeriodic", "Ewald", "LJPME", "NoCutoff", "PME"] if v.lower() not in [x.lower() for x in options]: errmsg = f"Only {options} are allowed nonbonded_methods" From 6e922216a0e8bbc9b5c4b734222b5589068c9f03 Mon Sep 17 00:00:00 2001 From: Alyssa Travitz Date: Wed, 20 Aug 2025 11:39:23 -0700 Subject: [PATCH 090/105] add annotated type docstring --- gufe/settings/types.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/gufe/settings/types.py b/gufe/settings/types.py index 2b9b3139a..1127c57f9 100644 --- a/gufe/settings/types.py +++ b/gufe/settings/types.py @@ -1,6 +1,9 @@ # adapted from from https://github.com/openforcefield/openff-interchange/blob/main/openff/interchange/_annotations.py +""" +Custom types that inherit from openff.units.Quantity and are pydantic-compatible. +""" -from typing import Annotated, Any, Type, TypeAlias +from typing import Annotated, Any, TypeAlias from openff.units import Quantity from pydantic import ( @@ -71,37 +74,45 @@ def specify_quantity_units(unit_name: str) -> AfterValidator: GufeQuantity, specify_quantity_units("nanometer"), ] +"""Convert a pint.Quantity or to nanometers, if possible.""" AtmQuantity: TypeAlias = Annotated[ GufeQuantity, specify_quantity_units("atm"), ] +"""Convert a pint.Quantity or to atm, if possible.""" KelvinQuantity: TypeAlias = Annotated[ GufeQuantity, specify_quantity_units("kelvin"), ] +"""Convert a pint.Quantity or to kelvin, if possible.""" # types used elsewhere in the ecosystem NanosecondQuantity: TypeAlias = Annotated[ GufeQuantity, specify_quantity_units("nanosecond"), ] +"""Convert a pint.Quantity or to nanoseconds, if possible.""" + PicosecondQuantity: TypeAlias = Annotated[ GufeQuantity, specify_quantity_units("picosecond"), ] +"""Convert a pint.Quantity or to picoseconds, if possible.""" AngstromQuantity: TypeAlias = Annotated[ GufeQuantity, specify_quantity_units("angstrom"), ] +"""Convert a pint.Quantity or to angstroms, if possible.""" KCalPerMolQuantity: TypeAlias = Annotated[ GufeQuantity, specify_quantity_units("kilocalorie_per_mole"), ] +"""Convert a pint.Quantity or to kcal/mol, if possible.""" GufeArrayQuantity: TypeAlias = Annotated[ GufeQuantity, From af0e3998c69869c82c353c44f0ac60334104bdf2 Mon Sep 17 00:00:00 2001 From: Alyssa Travitz Date: Wed, 20 Aug 2025 11:59:45 -0700 Subject: [PATCH 091/105] updating config --- docs/conf.py | 14 +++----------- 1 file changed, 3 insertions(+), 11 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index 914df9aee..b78073242 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -49,6 +49,9 @@ "undoc-members": True, } +# TODO: temporary workaround to get docs to build I figure out why only nonbonded_cutoff won't serialize. +autodoc_pydantic_model_show_json_error_strategy = ("coerce") + autosummary_generate = True intersphinx_mapping = { @@ -68,17 +71,6 @@ "rdkit", ] -# autodoc_pydantic settings - -# autodoc_pydantic settings -autodoc_pydantic_model_show_config_summary = False -autodoc_pydantic_model_show_validator_summary = False -autodoc_pydantic_model_show_validator_members = False -autodoc_pydantic_model_show_json_error_strategy = ( - "coerce" # TODO: we cannot currently generate schemas for models w/ pint quantities -) - - # -- Options for HTML output ------------------------------------------------- # The theme to use for HTML and HTML Help pages. See the documentation for From 16803d198cec0429374499931764df23d111d5d7 Mon Sep 17 00:00:00 2001 From: Alyssa Travitz Date: Wed, 20 Aug 2025 12:06:33 -0700 Subject: [PATCH 092/105] update docstrings --- docs/conf.py | 2 +- gufe/settings/models.py | 7 +++---- gufe/tests/test_models.py | 6 ++++++ 3 files changed, 10 insertions(+), 5 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index b78073242..23486e9f1 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -49,7 +49,7 @@ "undoc-members": True, } -# TODO: temporary workaround to get docs to build I figure out why only nonbonded_cutoff won't serialize. +# TODO: temporary workaround to get docs to build I figure out why only OpenMMSystemGeneratorFFSettings GufeQuantities won't serialize. autodoc_pydantic_model_show_json_error_strategy = ("coerce") autosummary_generate = True diff --git a/gufe/settings/models.py b/gufe/settings/models.py index bb3b65725..55573c3e3 100644 --- a/gufe/settings/models.py +++ b/gufe/settings/models.py @@ -126,10 +126,9 @@ class OpenMMSystemGeneratorFFSettings(BaseForceFieldSettings): """Parameters to set up the force field with OpenMM ForceFields .. note:: - Right now we just basically just grab what we need for the - :class:`openmmforcefields.system_generators.SystemGenerator` - signature. See the `OpenMMForceField SystemGenerator documentation`_ - for more details. + Currently, this stores what is needed for the + :class:`openmmforcefields.system_generators.SystemGenerator` signature. + See the `OpenMMForceField SystemGenerator documentation`_ for more details. .. _`OpenMMForceField SystemGenerator documentation`: diff --git a/gufe/tests/test_models.py b/gufe/tests/test_models.py index 9c2d6a4de..009bbdee0 100644 --- a/gufe/tests/test_models.py +++ b/gufe/tests/test_models.py @@ -72,6 +72,12 @@ def test_settings_schema(): assert ser_schema == expected_schema assert val_schema == expected_schema +def test_openmmffsettings_schema(): + expected_schema = {'additionalProperties': False, 'description': 'Parameters to set up the force field with OpenMM ForceFields\n\n.. note::\n Currently, this stores what is needed for the\n :class:`openmmforcefields.system_generators.SystemGenerator` signature.\n See the `OpenMMForceField SystemGenerator documentation`_ for more details.\n\n\n.. _`OpenMMForceField SystemGenerator documentation`:\n https://github.com/openmm/openmmforcefields#automating-force-field-management-with-systemgenerator', 'properties': {'constraints': {'anyOf': [{'enum': ['hbonds', 'allbonds', 'hangles'], 'type': 'string'}, {'type': 'null'}], 'default': 'hbonds', 'title': 'Constraints'}, 'rigid_water': {'default': True, 'title': 'Rigid Water', 'type': 'boolean'}, 'hydrogen_mass': {'default': 3.0, 'title': 'Hydrogen Mass', 'type': 'number'}, 'forcefields': {'default': ['amber/ff14SB.xml', 'amber/tip3p_standard.xml', 'amber/tip3p_HFE_multivalent.xml', 'amber/phosaa10.xml'], 'items': {'type': 'string'}, 'title': 'Forcefields', 'type': 'array'}, 'small_molecule_forcefield': {'default': 'openff-2.1.1', 'title': 'Small Molecule Forcefield', 'type': 'string'}, 'nonbonded_method': {'default': 'PME', 'title': 'Nonbonded Method', 'type': 'string'}, 'nonbonded_cutoff': {'title': 'Nonbonded Cutoff', 'type': 'number'}}, 'title': 'OpenMMSystemGeneratorFFSettings', 'type': 'object'} + ser_schema = OpenMMSystemGeneratorFFSettings.model_json_schema(mode="serialization") + val_schema = OpenMMSystemGeneratorFFSettings.model_json_schema(mode="serialization") + assert ser_schema == expected_schema + assert val_schema == expected_schema def test_default_settings(): my_settings = Settings.get_defaults() From a6e7071ae47de19b42672f84ab3ec1caeede10ec Mon Sep 17 00:00:00 2001 From: Alyssa Travitz Date: Wed, 20 Aug 2025 12:07:50 -0700 Subject: [PATCH 093/105] format --- gufe/tests/test_models.py | 36 +++++++++++++++++++++++++++++++++++- 1 file changed, 35 insertions(+), 1 deletion(-) diff --git a/gufe/tests/test_models.py b/gufe/tests/test_models.py index 009bbdee0..92017b8c5 100644 --- a/gufe/tests/test_models.py +++ b/gufe/tests/test_models.py @@ -72,13 +72,47 @@ def test_settings_schema(): assert ser_schema == expected_schema assert val_schema == expected_schema + def test_openmmffsettings_schema(): - expected_schema = {'additionalProperties': False, 'description': 'Parameters to set up the force field with OpenMM ForceFields\n\n.. note::\n Currently, this stores what is needed for the\n :class:`openmmforcefields.system_generators.SystemGenerator` signature.\n See the `OpenMMForceField SystemGenerator documentation`_ for more details.\n\n\n.. _`OpenMMForceField SystemGenerator documentation`:\n https://github.com/openmm/openmmforcefields#automating-force-field-management-with-systemgenerator', 'properties': {'constraints': {'anyOf': [{'enum': ['hbonds', 'allbonds', 'hangles'], 'type': 'string'}, {'type': 'null'}], 'default': 'hbonds', 'title': 'Constraints'}, 'rigid_water': {'default': True, 'title': 'Rigid Water', 'type': 'boolean'}, 'hydrogen_mass': {'default': 3.0, 'title': 'Hydrogen Mass', 'type': 'number'}, 'forcefields': {'default': ['amber/ff14SB.xml', 'amber/tip3p_standard.xml', 'amber/tip3p_HFE_multivalent.xml', 'amber/phosaa10.xml'], 'items': {'type': 'string'}, 'title': 'Forcefields', 'type': 'array'}, 'small_molecule_forcefield': {'default': 'openff-2.1.1', 'title': 'Small Molecule Forcefield', 'type': 'string'}, 'nonbonded_method': {'default': 'PME', 'title': 'Nonbonded Method', 'type': 'string'}, 'nonbonded_cutoff': {'title': 'Nonbonded Cutoff', 'type': 'number'}}, 'title': 'OpenMMSystemGeneratorFFSettings', 'type': 'object'} + expected_schema = { + "additionalProperties": False, + "description": "Parameters to set up the force field with OpenMM ForceFields\n\n.. note::\n Currently, this stores what is needed for the\n :class:`openmmforcefields.system_generators.SystemGenerator` signature.\n See the `OpenMMForceField SystemGenerator documentation`_ for more details.\n\n\n.. _`OpenMMForceField SystemGenerator documentation`:\n https://github.com/openmm/openmmforcefields#automating-force-field-management-with-systemgenerator", + "properties": { + "constraints": { + "anyOf": [{"enum": ["hbonds", "allbonds", "hangles"], "type": "string"}, {"type": "null"}], + "default": "hbonds", + "title": "Constraints", + }, + "rigid_water": {"default": True, "title": "Rigid Water", "type": "boolean"}, + "hydrogen_mass": {"default": 3.0, "title": "Hydrogen Mass", "type": "number"}, + "forcefields": { + "default": [ + "amber/ff14SB.xml", + "amber/tip3p_standard.xml", + "amber/tip3p_HFE_multivalent.xml", + "amber/phosaa10.xml", + ], + "items": {"type": "string"}, + "title": "Forcefields", + "type": "array", + }, + "small_molecule_forcefield": { + "default": "openff-2.1.1", + "title": "Small Molecule Forcefield", + "type": "string", + }, + "nonbonded_method": {"default": "PME", "title": "Nonbonded Method", "type": "string"}, + "nonbonded_cutoff": {"ge": 0, "title": "Nonbonded Cutoff", "type": "number"}, + }, + "title": "OpenMMSystemGeneratorFFSettings", + "type": "object", + } ser_schema = OpenMMSystemGeneratorFFSettings.model_json_schema(mode="serialization") val_schema = OpenMMSystemGeneratorFFSettings.model_json_schema(mode="serialization") assert ser_schema == expected_schema assert val_schema == expected_schema + def test_default_settings(): my_settings = Settings.get_defaults() my_settings.thermo_settings.temperature = 298 * unit.kelvin From b8308b44acd18092a13a8e584e965ddb950ddb0c Mon Sep 17 00:00:00 2001 From: Alyssa Travitz Date: Wed, 20 Aug 2025 17:01:58 -0700 Subject: [PATCH 094/105] remove unused comment --- gufe/settings/models.py | 1 - 1 file changed, 1 deletion(-) diff --git a/gufe/settings/models.py b/gufe/settings/models.py index 55573c3e3..936bd5495 100644 --- a/gufe/settings/models.py +++ b/gufe/settings/models.py @@ -135,7 +135,6 @@ class OpenMMSystemGeneratorFFSettings(BaseForceFieldSettings): https://github.com/openmm/openmmforcefields#automating-force-field-management-with-systemgenerator """ - # constraints: CaseInsensitiveStrEnum('Constraints', ['hbonds', 'allbonds', 'hangles']) | None = 'hbonds' constraints: Annotated[Literal["hbonds", "allbonds", "hangles"], BeforeValidator(_to_lowercase)] | None = "hbonds" """Constraints to be applied to system. One of 'hbonds', 'allbonds', 'hangles' or None, default 'hbonds'""" From 51cf535ece7a4f72975495bd7819ec035018212b Mon Sep 17 00:00:00 2001 From: Alyssa Travitz Date: Wed, 20 Aug 2025 17:24:56 -0700 Subject: [PATCH 095/105] define redox potential units as mV --- gufe/settings/models.py | 7 ++++--- gufe/settings/types.py | 1 - gufe/tests/test_models.py | 11 ++++++++--- 3 files changed, 12 insertions(+), 7 deletions(-) diff --git a/gufe/settings/models.py b/gufe/settings/models.py index 936bd5495..6a5cf25d6 100644 --- a/gufe/settings/models.py +++ b/gufe/settings/models.py @@ -6,15 +6,16 @@ import abc import pprint -from typing import Annotated, Any, Literal +from typing import Annotated, Any, Literal, TypeAlias from annotated_types import Ge from openff.units import unit from pydantic import BeforeValidator, ConfigDict, Field, InstanceOf, PositiveFloat, PrivateAttr, field_validator from ..vendor.openff.interchange.pydantic import _BaseModel -from .types import AtmQuantity, KelvinQuantity, NanometerQuantity +from .types import AtmQuantity, GufeQuantity, KelvinQuantity, NanometerQuantity, specify_quantity_units +VoltsQuantity: TypeAlias = Annotated[GufeQuantity, specify_quantity_units("volts")] class SettingsBaseModel(_BaseModel): """Settings and modifications we want for all settings classes.""" @@ -105,7 +106,7 @@ class ThermoSettings(SettingsBaseModel): temperature: KelvinQuantity | None = Field(None, description="Simulation temperature in kelvin)") pressure: AtmQuantity | None = Field(None, description="Simulation pressure in standard atmosphere (atm)") ph: PositiveFloat | None = Field(None, description="Simulation pH") - redox_potential: float | None = Field(None, description="Simulation redox potential") + redox_potential: VoltsQuantity | None = Field(None, description="Simulation redox potential in millivolts (mV).") class BaseForceFieldSettings(SettingsBaseModel, abc.ABC): diff --git a/gufe/settings/types.py b/gufe/settings/types.py index 1127c57f9..efc5d52fb 100644 --- a/gufe/settings/types.py +++ b/gufe/settings/types.py @@ -69,7 +69,6 @@ def specify_quantity_units(unit_name: str) -> AfterValidator: return AfterValidator(_unit_validator_factory(unit_name)) - NanometerQuantity: TypeAlias = Annotated[ GufeQuantity, specify_quantity_units("nanometer"), diff --git a/gufe/tests/test_models.py b/gufe/tests/test_models.py index 92017b8c5..0b4ddb4c7 100644 --- a/gufe/tests/test_models.py +++ b/gufe/tests/test_models.py @@ -48,7 +48,7 @@ def test_settings_schema(): "redox_potential": { "anyOf": [{"type": "number"}, {"type": "null"}], "default": None, - "description": "Simulation redox potential", + "description": "Simulation redox potential in millivolts (mV).", "title": "Redox Potential", }, }, @@ -238,9 +238,14 @@ def test_thermo_ph(self, value, valid, expected): @pytest.mark.parametrize( "value,valid,expected", [ - (1.0, True, 1.0), (None, True, None), - ("1", True, 1.0), + (1 * unit.mV, True, 1 * unit.mV), + ("1.0 mV", True, 1 * unit.mV), + ("0.001 volts", True, 1 * unit.mV), + (0.001 * unit.volt, True, 1 * unit.mV), + (0.001 * unit.nanometer, False, None), + ("0.001 nm", False, None), + ], ) def test_thermo_redox(self, value, valid, expected): From 977f91f5ed53ddfd91ae1281e5d108557742e2f5 Mon Sep 17 00:00:00 2001 From: Alyssa Travitz Date: Wed, 20 Aug 2025 17:28:41 -0700 Subject: [PATCH 096/105] add note about default params not getting captured by validator --- gufe/settings/models.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/gufe/settings/models.py b/gufe/settings/models.py index 6a5cf25d6..b42d4e327 100644 --- a/gufe/settings/models.py +++ b/gufe/settings/models.py @@ -162,7 +162,9 @@ class OpenMMSystemGeneratorFFSettings(BaseForceFieldSettings): "CutoffNonPeriodic", "CutoffPeriodic", "Ewald", "LJPME", "NoCutoff", "PME". Default PME. """ - nonbonded_cutoff: Annotated[NanometerQuantity, Ge(0)] = 1.0 * unit.nanometer + # TODO: currently, serialization scheme doesn't work for default values since we're not using PlainValidator + # see https://github.com/pydantic/pydantic/issues/11446 + nonbonded_cutoff: Annotated[NanometerQuantity, Ge(0)] = 1.0 * unit.nanometer # type: ignore """ Cutoff value for short range nonbonded interactions. Default 1.0 * unit.nanometer. From b89bbc1aaeacb905262d4d1e34bb0fae81944743 Mon Sep 17 00:00:00 2001 From: Alyssa Travitz Date: Wed, 20 Aug 2025 17:41:02 -0700 Subject: [PATCH 097/105] use a plain serializer for a bettter chance at getting default serialization down the line --- docs/conf.py | 2 +- gufe/settings/models.py | 1 + gufe/settings/types.py | 21 +++++++++++++++++++-- gufe/tests/test_models.py | 1 - 4 files changed, 21 insertions(+), 4 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index 23486e9f1..d24a7c8e1 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -50,7 +50,7 @@ } # TODO: temporary workaround to get docs to build I figure out why only OpenMMSystemGeneratorFFSettings GufeQuantities won't serialize. -autodoc_pydantic_model_show_json_error_strategy = ("coerce") +autodoc_pydantic_model_show_json_error_strategy = "coerce" autosummary_generate = True diff --git a/gufe/settings/models.py b/gufe/settings/models.py index b42d4e327..6a1693f59 100644 --- a/gufe/settings/models.py +++ b/gufe/settings/models.py @@ -17,6 +17,7 @@ VoltsQuantity: TypeAlias = Annotated[GufeQuantity, specify_quantity_units("volts")] + class SettingsBaseModel(_BaseModel): """Settings and modifications we want for all settings classes.""" diff --git a/gufe/settings/types.py b/gufe/settings/types.py index efc5d52fb..74a8142ba 100644 --- a/gufe/settings/types.py +++ b/gufe/settings/types.py @@ -3,13 +3,15 @@ Custom types that inherit from openff.units.Quantity and are pydantic-compatible. """ -from typing import Annotated, Any, TypeAlias +from typing import Annotated, Any, Dict, TypeAlias +import numpy from openff.units import Quantity from pydantic import ( AfterValidator, BeforeValidator, GetCoreSchemaHandler, + PlainSerializer, ) from pydantic_core import core_schema @@ -21,6 +23,19 @@ ) +def plain_quantity_serializer(quantity: Quantity) -> Dict[str, Any]: + magnitude = quantity.m + + if isinstance(magnitude, numpy.ndarray): + # This could be something fancier, list a bytestring + magnitude = magnitude.tolist() + + return { + "val": magnitude, + "unit": str(quantity.units), + } + + class _QuantityPydanticAnnotation: @classmethod def __get_pydantic_core_schema__( @@ -39,7 +54,8 @@ def __get_pydantic_core_schema__( function=quantity_validator, schema=core_schema.is_instance_schema(Quantity), ) - serialize_schema = core_schema.wrap_serializer_function_ser_schema(quantity_json_serializer) + + serialize_schema = core_schema.plain_serializer_function_ser_schema(plain_quantity_serializer) return core_schema.json_or_python_schema( json_schema=json_schema, python_schema=python_schema, @@ -69,6 +85,7 @@ def specify_quantity_units(unit_name: str) -> AfterValidator: return AfterValidator(_unit_validator_factory(unit_name)) + NanometerQuantity: TypeAlias = Annotated[ GufeQuantity, specify_quantity_units("nanometer"), diff --git a/gufe/tests/test_models.py b/gufe/tests/test_models.py index 0b4ddb4c7..3235de1f2 100644 --- a/gufe/tests/test_models.py +++ b/gufe/tests/test_models.py @@ -245,7 +245,6 @@ def test_thermo_ph(self, value, valid, expected): (0.001 * unit.volt, True, 1 * unit.mV), (0.001 * unit.nanometer, False, None), ("0.001 nm", False, None), - ], ) def test_thermo_redox(self, value, valid, expected): From b568923a4f0cc8cadf2a24f0a807f8baf9c46d48 Mon Sep 17 00:00:00 2001 From: Alyssa Travitz Date: Wed, 20 Aug 2025 18:16:15 -0700 Subject: [PATCH 098/105] defined nonbonded_cutoff as field --- gufe/settings/models.py | 8 +++----- gufe/tests/test_models.py | 7 ++++++- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/gufe/settings/models.py b/gufe/settings/models.py index 6a1693f59..980781a9c 100644 --- a/gufe/settings/models.py +++ b/gufe/settings/models.py @@ -165,11 +165,9 @@ class OpenMMSystemGeneratorFFSettings(BaseForceFieldSettings): """ # TODO: currently, serialization scheme doesn't work for default values since we're not using PlainValidator # see https://github.com/pydantic/pydantic/issues/11446 - nonbonded_cutoff: Annotated[NanometerQuantity, Ge(0)] = 1.0 * unit.nanometer # type: ignore - """ - Cutoff value for short range nonbonded interactions. - Default 1.0 * unit.nanometer. - """ + nonbonded_cutoff: Annotated[NanometerQuantity, Ge(0)] = Field( + default=1.0 * unit.nanometer, description="Cutoff value for short range nonbonded interactions." + ) @field_validator("nonbonded_method", mode="after") def allowed_nonbonded_methods(cls, v): diff --git a/gufe/tests/test_models.py b/gufe/tests/test_models.py index 3235de1f2..6207597f8 100644 --- a/gufe/tests/test_models.py +++ b/gufe/tests/test_models.py @@ -102,7 +102,12 @@ def test_openmmffsettings_schema(): "type": "string", }, "nonbonded_method": {"default": "PME", "title": "Nonbonded Method", "type": "string"}, - "nonbonded_cutoff": {"ge": 0, "title": "Nonbonded Cutoff", "type": "number"}, + "nonbonded_cutoff": { + "description": "Cutoff value for short range nonbonded interactions.", + "ge": 0, + "title": "Nonbonded Cutoff", + "type": "number", + }, }, "title": "OpenMMSystemGeneratorFFSettings", "type": "object", From 90143aa4f82c6986d6dfa0e214871cccba0485eb Mon Sep 17 00:00:00 2001 From: Alyssa Travitz Date: Wed, 20 Aug 2025 18:37:12 -0700 Subject: [PATCH 099/105] test validation schema --- gufe/tests/test_models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gufe/tests/test_models.py b/gufe/tests/test_models.py index 6207597f8..ce9313ea6 100644 --- a/gufe/tests/test_models.py +++ b/gufe/tests/test_models.py @@ -113,7 +113,7 @@ def test_openmmffsettings_schema(): "type": "object", } ser_schema = OpenMMSystemGeneratorFFSettings.model_json_schema(mode="serialization") - val_schema = OpenMMSystemGeneratorFFSettings.model_json_schema(mode="serialization") + val_schema = OpenMMSystemGeneratorFFSettings.model_json_schema(mode="validation") assert ser_schema == expected_schema assert val_schema == expected_schema From 108e1534ec4f8a4eeda19a50047d4d8ac832944f Mon Sep 17 00:00:00 2001 From: Alyssa Travitz Date: Wed, 20 Aug 2025 21:18:37 -0700 Subject: [PATCH 100/105] define all parameters as fields so that descriptions get included in schema --- docs/conf.py | 2 +- gufe/settings/models.py | 59 +++++++++++++++++++++------------------ gufe/tests/test_models.py | 24 ++++++++++++++-- 3 files changed, 54 insertions(+), 31 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index d24a7c8e1..0a26d570f 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -49,7 +49,7 @@ "undoc-members": True, } -# TODO: temporary workaround to get docs to build I figure out why only OpenMMSystemGeneratorFFSettings GufeQuantities won't serialize. +# TODO: temporary workaround until defaults validation is fixed in pydantic v2.12 autodoc_pydantic_model_show_json_error_strategy = "coerce" autosummary_generate = True diff --git a/gufe/settings/models.py b/gufe/settings/models.py index 980781a9c..1eda7b606 100644 --- a/gufe/settings/models.py +++ b/gufe/settings/models.py @@ -137,33 +137,38 @@ class OpenMMSystemGeneratorFFSettings(BaseForceFieldSettings): https://github.com/openmm/openmmforcefields#automating-force-field-management-with-systemgenerator """ - constraints: Annotated[Literal["hbonds", "allbonds", "hangles"], BeforeValidator(_to_lowercase)] | None = "hbonds" - """Constraints to be applied to system. - One of 'hbonds', 'allbonds', 'hangles' or None, default 'hbonds'""" - rigid_water: bool = True - """Whether to use a rigid water model. Default True""" - hydrogen_mass: float = 3.0 - """Mass to be repartitioned to hydrogens from neighbouring - heavy atoms (in amu), default 3.0""" - - forcefields: list[str] = [ - "amber/ff14SB.xml", # ff14SB protein force field - "amber/tip3p_standard.xml", # TIP3P and recommended monovalent ion parameters - "amber/tip3p_HFE_multivalent.xml", # for divalent ions - "amber/phosaa10.xml", # Handles THE TPO - ] - """List of force field paths for all components except :class:`SmallMoleculeComponent` """ - - small_molecule_forcefield: str = "openff-2.1.1" # other default ideas 'openff-2.0.0', 'gaff-2.11', 'espaloma-0.2.0' - """Name of the force field to be used for :class:`SmallMoleculeComponent` """ - - nonbonded_method: str = "PME" - """ - Method for treating nonbonded interactions, options are currently - "CutoffNonPeriodic", "CutoffPeriodic", "Ewald", "LJPME", "NoCutoff", "PME". - Default PME. - """ - # TODO: currently, serialization scheme doesn't work for default values since we're not using PlainValidator + constraints: Annotated[Literal["hbonds", "allbonds", "hangles"], BeforeValidator(_to_lowercase)] | None = Field( + default="hbonds", + description="Constraints to be applied to system. One of ``'hbonds'``, ``'allbonds'``, ``'hangles'`` or None, default ``'hbonds'``.", + ) + rigid_water: bool = Field(True, description="Whether to use a rigid water model, default ``True``.") + + hydrogen_mass: AtmQuantity = Field( + default=3.0 * unit.atm, + description="Mass to be repartitioned to hydrogens from neighbouring heavy atoms (in amu), default ``3.0 atm``", + ) + + forcefields: list[str] = Field( + default=[ + "amber/ff14SB.xml", # ff14SB protein force field + "amber/tip3p_standard.xml", # TIP3P and recommended monovalent ion parameters + "amber/tip3p_HFE_multivalent.xml", # for divalent ions + "amber/phosaa10.xml", # Handles THE TPO + ], + description="List of force field paths for all components except :class:`SmallMoleculeComponent`", + ) + + # other default ideas 'openff-2.0.0', 'gaff-2.11', 'espaloma-0.2.0' + small_molecule_forcefield: str = Field( + default="openff-2.1.1", description="Name of the force field to be used for :class:`SmallMoleculeComponent`" + ) + + nonbonded_method: str = Field( + default="PME", + description="Method for treating nonbonded interactions, options are currently ``'CutoffNonPeriodic'``, ``'CutoffPeriodic'``, ``'Ewald'``, ``'LJPME'``, ``'NoCutoff'``, ``'PME'``. Default ``'PME'``. ", + ) + + # TODO: currently, serialization scheme doesn't work for default values, will be fixed in pydantic v2.12 # see https://github.com/pydantic/pydantic/issues/11446 nonbonded_cutoff: Annotated[NanometerQuantity, Ge(0)] = Field( default=1.0 * unit.nanometer, description="Cutoff value for short range nonbonded interactions." diff --git a/gufe/tests/test_models.py b/gufe/tests/test_models.py index ce9313ea6..59a38f642 100644 --- a/gufe/tests/test_models.py +++ b/gufe/tests/test_models.py @@ -68,6 +68,7 @@ def test_settings_schema(): } ser_schema = Settings.model_json_schema(mode="serialization") val_schema = Settings.model_json_schema(mode="validation") + # TODO: should our serialization and validation schemas really be the same? assert ser_schema == expected_schema assert val_schema == expected_schema @@ -81,10 +82,20 @@ def test_openmmffsettings_schema(): "constraints": { "anyOf": [{"enum": ["hbonds", "allbonds", "hangles"], "type": "string"}, {"type": "null"}], "default": "hbonds", + "description": "Constraints to be applied to system. One of ``'hbonds'``, ``'allbonds'``, ``'hangles'`` or None, default ``'hbonds'``.", "title": "Constraints", }, - "rigid_water": {"default": True, "title": "Rigid Water", "type": "boolean"}, - "hydrogen_mass": {"default": 3.0, "title": "Hydrogen Mass", "type": "number"}, + "rigid_water": { + "default": True, + "description": "Whether to use a rigid water model, default ``True``.", + "title": "Rigid Water", + "type": "boolean", + }, + "hydrogen_mass": { + "description": "Mass to be repartitioned to hydrogens from neighbouring heavy atoms (in amu), default ``3.0 atm``", + "title": "Hydrogen Mass", + "type": "number", + }, "forcefields": { "default": [ "amber/ff14SB.xml", @@ -92,16 +103,23 @@ def test_openmmffsettings_schema(): "amber/tip3p_HFE_multivalent.xml", "amber/phosaa10.xml", ], + "description": "List of force field paths for all components except :class:`SmallMoleculeComponent`", "items": {"type": "string"}, "title": "Forcefields", "type": "array", }, "small_molecule_forcefield": { "default": "openff-2.1.1", + "description": "Name of the force field to be used for :class:`SmallMoleculeComponent`", "title": "Small Molecule Forcefield", "type": "string", }, - "nonbonded_method": {"default": "PME", "title": "Nonbonded Method", "type": "string"}, + "nonbonded_method": { + "default": "PME", + "description": "Method for treating nonbonded interactions, options are currently ``'CutoffNonPeriodic'``, ``'CutoffPeriodic'``, ``'Ewald'``, ``'LJPME'``, ``'NoCutoff'``, ``'PME'``. Default ``'PME'``. ", + "title": "Nonbonded Method", + "type": "string", + }, "nonbonded_cutoff": { "description": "Cutoff value for short range nonbonded interactions.", "ge": 0, From fe0231a0e11bef26706fc1e0412bfbe1addd528e Mon Sep 17 00:00:00 2001 From: Alyssa Travitz Date: Wed, 20 Aug 2025 21:28:00 -0700 Subject: [PATCH 101/105] don't import toolkit when building docs --- docs/environment.yaml | 1 - gufe/vendor/openff/interchange/_annotations.py | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/docs/environment.yaml b/docs/environment.yaml index e8b561306..47444d394 100644 --- a/docs/environment.yaml +++ b/docs/environment.yaml @@ -5,7 +5,6 @@ channels: dependencies: - autodoc-pydantic >=2.0 - openff-units -- openff-toolkit - python=3.12 - sphinx - openmm diff --git a/gufe/vendor/openff/interchange/_annotations.py b/gufe/vendor/openff/interchange/_annotations.py index f50dc3f1a..62995ee58 100644 --- a/gufe/vendor/openff/interchange/_annotations.py +++ b/gufe/vendor/openff/interchange/_annotations.py @@ -5,7 +5,7 @@ import numpy from annotated_types import Gt -from openff.toolkit import Quantity +from openff.units import Quantity # import from units so we don't have to build toolkit just for docs from pydantic import ( AfterValidator, BeforeValidator, From 278219f7c2ccd6ed6dab0b83d212bbd151255901 Mon Sep 17 00:00:00 2001 From: Alyssa Travitz Date: Wed, 20 Aug 2025 21:33:20 -0700 Subject: [PATCH 102/105] back to using vendored serialization until we move to annotated --- gufe/settings/types.py | 19 ++----------------- .../vendor/openff/interchange/_annotations.py | 2 +- 2 files changed, 3 insertions(+), 18 deletions(-) diff --git a/gufe/settings/types.py b/gufe/settings/types.py index 74a8142ba..341a11471 100644 --- a/gufe/settings/types.py +++ b/gufe/settings/types.py @@ -3,15 +3,13 @@ Custom types that inherit from openff.units.Quantity and are pydantic-compatible. """ -from typing import Annotated, Any, Dict, TypeAlias +from typing import Annotated, Any, TypeAlias -import numpy from openff.units import Quantity from pydantic import ( AfterValidator, BeforeValidator, GetCoreSchemaHandler, - PlainSerializer, ) from pydantic_core import core_schema @@ -23,19 +21,6 @@ ) -def plain_quantity_serializer(quantity: Quantity) -> Dict[str, Any]: - magnitude = quantity.m - - if isinstance(magnitude, numpy.ndarray): - # This could be something fancier, list a bytestring - magnitude = magnitude.tolist() - - return { - "val": magnitude, - "unit": str(quantity.units), - } - - class _QuantityPydanticAnnotation: @classmethod def __get_pydantic_core_schema__( @@ -55,7 +40,7 @@ def __get_pydantic_core_schema__( schema=core_schema.is_instance_schema(Quantity), ) - serialize_schema = core_schema.plain_serializer_function_ser_schema(plain_quantity_serializer) + serialize_schema = core_schema.wrap_serializer_function_ser_schema(quantity_json_serializer) return core_schema.json_or_python_schema( json_schema=json_schema, python_schema=python_schema, diff --git a/gufe/vendor/openff/interchange/_annotations.py b/gufe/vendor/openff/interchange/_annotations.py index 62995ee58..65192bad6 100644 --- a/gufe/vendor/openff/interchange/_annotations.py +++ b/gufe/vendor/openff/interchange/_annotations.py @@ -5,7 +5,7 @@ import numpy from annotated_types import Gt -from openff.units import Quantity # import from units so we don't have to build toolkit just for docs +from openff.units import Quantity # import from units so we don't have to build toolkit just for docs from pydantic import ( AfterValidator, BeforeValidator, From dd3bc616803c133351f91a9838d1f88f1f1b3346 Mon Sep 17 00:00:00 2001 From: Alyssa Travitz Date: Wed, 20 Aug 2025 21:49:37 -0700 Subject: [PATCH 103/105] Revert "define all parameters as fields so that descriptions get included in schema" This reverts commit 108e1534ec4f8a4eeda19a50047d4d8ac832944f. --- docs/conf.py | 2 +- gufe/settings/models.py | 59 ++++++++++++++++++--------------------- gufe/tests/test_models.py | 24 ++-------------- 3 files changed, 31 insertions(+), 54 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index 0a26d570f..d24a7c8e1 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -49,7 +49,7 @@ "undoc-members": True, } -# TODO: temporary workaround until defaults validation is fixed in pydantic v2.12 +# TODO: temporary workaround to get docs to build I figure out why only OpenMMSystemGeneratorFFSettings GufeQuantities won't serialize. autodoc_pydantic_model_show_json_error_strategy = "coerce" autosummary_generate = True diff --git a/gufe/settings/models.py b/gufe/settings/models.py index 1eda7b606..980781a9c 100644 --- a/gufe/settings/models.py +++ b/gufe/settings/models.py @@ -137,38 +137,33 @@ class OpenMMSystemGeneratorFFSettings(BaseForceFieldSettings): https://github.com/openmm/openmmforcefields#automating-force-field-management-with-systemgenerator """ - constraints: Annotated[Literal["hbonds", "allbonds", "hangles"], BeforeValidator(_to_lowercase)] | None = Field( - default="hbonds", - description="Constraints to be applied to system. One of ``'hbonds'``, ``'allbonds'``, ``'hangles'`` or None, default ``'hbonds'``.", - ) - rigid_water: bool = Field(True, description="Whether to use a rigid water model, default ``True``.") - - hydrogen_mass: AtmQuantity = Field( - default=3.0 * unit.atm, - description="Mass to be repartitioned to hydrogens from neighbouring heavy atoms (in amu), default ``3.0 atm``", - ) - - forcefields: list[str] = Field( - default=[ - "amber/ff14SB.xml", # ff14SB protein force field - "amber/tip3p_standard.xml", # TIP3P and recommended monovalent ion parameters - "amber/tip3p_HFE_multivalent.xml", # for divalent ions - "amber/phosaa10.xml", # Handles THE TPO - ], - description="List of force field paths for all components except :class:`SmallMoleculeComponent`", - ) - - # other default ideas 'openff-2.0.0', 'gaff-2.11', 'espaloma-0.2.0' - small_molecule_forcefield: str = Field( - default="openff-2.1.1", description="Name of the force field to be used for :class:`SmallMoleculeComponent`" - ) - - nonbonded_method: str = Field( - default="PME", - description="Method for treating nonbonded interactions, options are currently ``'CutoffNonPeriodic'``, ``'CutoffPeriodic'``, ``'Ewald'``, ``'LJPME'``, ``'NoCutoff'``, ``'PME'``. Default ``'PME'``. ", - ) - - # TODO: currently, serialization scheme doesn't work for default values, will be fixed in pydantic v2.12 + constraints: Annotated[Literal["hbonds", "allbonds", "hangles"], BeforeValidator(_to_lowercase)] | None = "hbonds" + """Constraints to be applied to system. + One of 'hbonds', 'allbonds', 'hangles' or None, default 'hbonds'""" + rigid_water: bool = True + """Whether to use a rigid water model. Default True""" + hydrogen_mass: float = 3.0 + """Mass to be repartitioned to hydrogens from neighbouring + heavy atoms (in amu), default 3.0""" + + forcefields: list[str] = [ + "amber/ff14SB.xml", # ff14SB protein force field + "amber/tip3p_standard.xml", # TIP3P and recommended monovalent ion parameters + "amber/tip3p_HFE_multivalent.xml", # for divalent ions + "amber/phosaa10.xml", # Handles THE TPO + ] + """List of force field paths for all components except :class:`SmallMoleculeComponent` """ + + small_molecule_forcefield: str = "openff-2.1.1" # other default ideas 'openff-2.0.0', 'gaff-2.11', 'espaloma-0.2.0' + """Name of the force field to be used for :class:`SmallMoleculeComponent` """ + + nonbonded_method: str = "PME" + """ + Method for treating nonbonded interactions, options are currently + "CutoffNonPeriodic", "CutoffPeriodic", "Ewald", "LJPME", "NoCutoff", "PME". + Default PME. + """ + # TODO: currently, serialization scheme doesn't work for default values since we're not using PlainValidator # see https://github.com/pydantic/pydantic/issues/11446 nonbonded_cutoff: Annotated[NanometerQuantity, Ge(0)] = Field( default=1.0 * unit.nanometer, description="Cutoff value for short range nonbonded interactions." diff --git a/gufe/tests/test_models.py b/gufe/tests/test_models.py index 59a38f642..ce9313ea6 100644 --- a/gufe/tests/test_models.py +++ b/gufe/tests/test_models.py @@ -68,7 +68,6 @@ def test_settings_schema(): } ser_schema = Settings.model_json_schema(mode="serialization") val_schema = Settings.model_json_schema(mode="validation") - # TODO: should our serialization and validation schemas really be the same? assert ser_schema == expected_schema assert val_schema == expected_schema @@ -82,20 +81,10 @@ def test_openmmffsettings_schema(): "constraints": { "anyOf": [{"enum": ["hbonds", "allbonds", "hangles"], "type": "string"}, {"type": "null"}], "default": "hbonds", - "description": "Constraints to be applied to system. One of ``'hbonds'``, ``'allbonds'``, ``'hangles'`` or None, default ``'hbonds'``.", "title": "Constraints", }, - "rigid_water": { - "default": True, - "description": "Whether to use a rigid water model, default ``True``.", - "title": "Rigid Water", - "type": "boolean", - }, - "hydrogen_mass": { - "description": "Mass to be repartitioned to hydrogens from neighbouring heavy atoms (in amu), default ``3.0 atm``", - "title": "Hydrogen Mass", - "type": "number", - }, + "rigid_water": {"default": True, "title": "Rigid Water", "type": "boolean"}, + "hydrogen_mass": {"default": 3.0, "title": "Hydrogen Mass", "type": "number"}, "forcefields": { "default": [ "amber/ff14SB.xml", @@ -103,23 +92,16 @@ def test_openmmffsettings_schema(): "amber/tip3p_HFE_multivalent.xml", "amber/phosaa10.xml", ], - "description": "List of force field paths for all components except :class:`SmallMoleculeComponent`", "items": {"type": "string"}, "title": "Forcefields", "type": "array", }, "small_molecule_forcefield": { "default": "openff-2.1.1", - "description": "Name of the force field to be used for :class:`SmallMoleculeComponent`", "title": "Small Molecule Forcefield", "type": "string", }, - "nonbonded_method": { - "default": "PME", - "description": "Method for treating nonbonded interactions, options are currently ``'CutoffNonPeriodic'``, ``'CutoffPeriodic'``, ``'Ewald'``, ``'LJPME'``, ``'NoCutoff'``, ``'PME'``. Default ``'PME'``. ", - "title": "Nonbonded Method", - "type": "string", - }, + "nonbonded_method": {"default": "PME", "title": "Nonbonded Method", "type": "string"}, "nonbonded_cutoff": { "description": "Cutoff value for short range nonbonded interactions.", "ge": 0, From 0193c08025dc9305debf2e49ddb814c7ca81c0eb Mon Sep 17 00:00:00 2001 From: Alyssa Travitz Date: Thu, 21 Aug 2025 12:22:05 -0700 Subject: [PATCH 104/105] expose BoxQuantity --- gufe/settings/types.py | 1 + 1 file changed, 1 insertion(+) diff --git a/gufe/settings/types.py b/gufe/settings/types.py index 341a11471..523067a55 100644 --- a/gufe/settings/types.py +++ b/gufe/settings/types.py @@ -18,6 +18,7 @@ _unwrap_list_of_openmm_quantities, quantity_json_serializer, quantity_validator, + _BoxQuantity as BoxQuantity, ) From 088e875937563e2c20e25c32fb08553d304f1696 Mon Sep 17 00:00:00 2001 From: Alyssa Travitz Date: Thu, 21 Aug 2025 15:41:04 -0700 Subject: [PATCH 105/105] copying over templates from openfe-sphinx-theme --- docs/_templates/README.md | 5 ++ docs/_templates/autosummary/base.rst | 15 ++++ docs/_templates/autosummary/class.rst | 65 +++++++++++++++ docs/_templates/autosummary/module.rst | 106 +++++++++++++++++++++++++ gufe/components/proteincomponent.py | 14 ++-- gufe/settings/types.py | 7 +- 6 files changed, 199 insertions(+), 13 deletions(-) create mode 100644 docs/_templates/README.md create mode 100644 docs/_templates/autosummary/base.rst create mode 100644 docs/_templates/autosummary/class.rst create mode 100644 docs/_templates/autosummary/module.rst diff --git a/docs/_templates/README.md b/docs/_templates/README.md new file mode 100644 index 000000000..78625de95 --- /dev/null +++ b/docs/_templates/README.md @@ -0,0 +1,5 @@ +# `templates` directory + +This directory is appended to the `templates_path` Sphinx config variable in `conf.py`. Jinja templates in this directory are available to Sphinx. This is used to provide an Autosummary template that supports an automatic API reference. + +Note that templates used for the *theme itself* are not placed in this directory! \ No newline at end of file diff --git a/docs/_templates/autosummary/base.rst b/docs/_templates/autosummary/base.rst new file mode 100644 index 000000000..35816c986 --- /dev/null +++ b/docs/_templates/autosummary/base.rst @@ -0,0 +1,15 @@ +{% block title -%} + +{{ ("``" ~ objname ~ "``") | underline}} + +{%- endblock %} +{% block base %} + +.. currentmodule:: {{ module }} + +.. auto{{ objtype }}:: {{ objname }} + {% if objtype in ["attribute", "data"] -%} + :no-value: + {%- endif %} + +{%- endblock %} diff --git a/docs/_templates/autosummary/class.rst b/docs/_templates/autosummary/class.rst new file mode 100644 index 000000000..ad9380a46 --- /dev/null +++ b/docs/_templates/autosummary/class.rst @@ -0,0 +1,65 @@ +{% block title -%} + +.. raw:: html + +
+ +{{ ("``" ~ objname ~ "``") | underline('=')}} + +.. raw:: html + +
+ +{%- endblock %} +{% block base %} + +.. currentmodule:: {{ module }} + +.. autoclass:: {{ objname }} + :members: + :member-order: alphabetical {# For consistency with Autosummary #} + {% if show_inherited_members %}:inherited-members: + {% endif %}{% if show_undoc_members %}:undoc-members: + {% endif %}{% if show_inheritance %}:show-inheritance: + {% endif %} + + {% block methods %} + + {%- set doc_methods = [] -%} + {%- for item in methods -%} + {%- if item not in ["__new__", "__init__"] and (show_inherited_members or item not in inherited_members) -%} + {%- set _ = doc_methods.append(item) -%} + {%- endif -%} + {%- endfor %} + + {% if doc_methods %} + .. rubric:: {{ _('Methods') }} + + .. autosummary:: + :nosignatures: + {% for item in doc_methods %} + ~{{ name }}.{{ item }} + {%- endfor %} + {% endif %} + {% endblock %} + + {% block attributes %} + + {%- set doc_attributes = [] -%} + {%- for item in attributes -%} + {%- if show_inherited_members or item not in inherited_members -%} + {%- set _ = doc_attributes.append(item) -%} + {%- endif -%} + {%- endfor %} + + {% if doc_attributes %} + .. rubric:: {{ _('Attributes') }} + + .. autosummary:: + :nosignatures: + {% for item in doc_attributes %} + ~{{ name }}.{{ item }} + {%- endfor %} + {% endif %} + {% endblock %} +{% endblock %} diff --git a/docs/_templates/autosummary/module.rst b/docs/_templates/autosummary/module.rst new file mode 100644 index 000000000..b7691f7b7 --- /dev/null +++ b/docs/_templates/autosummary/module.rst @@ -0,0 +1,106 @@ +{% block title -%} + +{{ ("``" ~ objname ~ "``") | underline('=')}} + +{%- endblock %} +{% block base %} + +.. automodule:: {{ fullname }} + :no-members: + :no-inherited-members: + :no-special-members: + + {% block modules %} + + {%- set included_modules = [] -%} + {%- for item in modules -%} + {%- if item not in exclude_modules -%} + {%- set _ = included_modules.append(item) -%} + {%- endif -%} + {%- endfor -%} + + {% if included_modules %} + .. rubric:: Modules + + .. autosummary:: + :caption: Modules + :toctree: + :recursive: + {% for item in included_modules %} + ~{{ item }} + {%- endfor %} + + {% endif %} + {% endblock %} + + {% block attributes %} + {% if attributes %} + .. rubric:: {{ _('Module Attributes') }} + + .. autosummary:: + :caption: Attributes + :toctree: + :nosignatures: + {% for item in attributes %} + {{ item }} + {%- endfor %} + {% endif %} + {%- endblock -%} + + {% block functions %} + {% if functions %} + .. rubric:: {{ _('Functions') }} + + .. autosummary:: + :caption: Functions + :toctree: + :nosignatures: + {% for item in functions %} + {{ item }} + {%- endfor %} + {% endif %} + {% endblock %} + + {% block classes %} + + {%- set types = [] -%} + {%- for item in members -%} + {%- if not item.startswith('_') and not ( + item in functions + or item in attributes + or item in exceptions + or fullname ~ "." ~ item in modules + or item in methods + ) -%} + {%- set _ = types.append(item) -%} + {%- endif -%} + {%- endfor %} + + {% if types %} + .. rubric:: {{ _('Classes') }} + + .. autosummary:: + :caption: Classes + :toctree: + :nosignatures: + {% for item in types %} + {{ item }} + {%- endfor %} + {% endif %} + {% endblock %} + + {% block exceptions %} + {% if exceptions %} + .. rubric:: {{ _('Exceptions') }} + + .. autosummary:: + :caption: Exceptions + :toctree: + :nosignatures: + {% for item in exceptions %} + {{ item }} + {%- endfor %} + {% endif %} + {% endblock %} + +{% endblock %} diff --git a/gufe/components/proteincomponent.py b/gufe/components/proteincomponent.py index 7294ce82e..283a2b00f 100644 --- a/gufe/components/proteincomponent.py +++ b/gufe/components/proteincomponent.py @@ -56,8 +56,8 @@ # ions and charges pulled from amber: -# https://github.com/Amber-MD/AmberClassic/blob/42e88bf9a2214ba008140280713a430f3ecd4a90/dat/leap/lib/atomic_ions.lib#L1C1-L68C6 -ions_dict = { +# see `amber list AfterValidator: GufeArrayQuantity: TypeAlias = Annotated[ GufeQuantity, BeforeValidator(_unwrap_list_of_openmm_quantities), -] - -NanometerArrayQuantity: TypeAlias = Annotated[ - GufeArrayQuantity, - specify_quantity_units("nanometer"), -] +] \ No newline at end of file