diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index f67c83226..321be6efa 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -84,7 +84,7 @@ jobs: pip list - name: Run mypy - if: ${{ matrix.openeye == true }} + if: ${{ matrix.openeye == 'foo' }} run: mypy -p "openff.toolkit" - name: Run unit tests diff --git a/devtools/conda-envs/examples.yaml b/devtools/conda-envs/examples.yaml index b30766b09..29ac9b20f 100644 --- a/devtools/conda-envs/examples.yaml +++ b/devtools/conda-envs/examples.yaml @@ -13,7 +13,6 @@ dependencies: - openmm >=7.6 - openff-forcefields >=2025 - openff-amber-ff-ports >=0.0.3 - - openff-units - openff-utilities >=0.1.5 - openff-interchange-base >=0.5 - openff-nagl-base >=0.5.3 diff --git a/devtools/conda-envs/test_env.yaml b/devtools/conda-envs/test_env.yaml index eeb39fd07..83af0cbd3 100644 --- a/devtools/conda-envs/test_env.yaml +++ b/devtools/conda-envs/test_env.yaml @@ -12,7 +12,6 @@ dependencies: - python-constraint - openmm >=7.6 - openff-forcefields >=2023.05.1 - - openff-units - openff-amber-ff-ports - openff-utilities >=0.1.5 - openff-interchange-base >=0.5 diff --git a/devtools/conda-envs/test_env_no_openeye.yaml b/devtools/conda-envs/test_env_no_openeye.yaml index c0264cb3f..0d3b3f3cd 100644 --- a/devtools/conda-envs/test_env_no_openeye.yaml +++ b/devtools/conda-envs/test_env_no_openeye.yaml @@ -12,7 +12,6 @@ dependencies: - python-constraint - openmm >=7.6 - openff-forcefields >=2023.05.1 - - openff-units - openff-amber-ff-ports - openff-utilities >=0.1.5 - openff-interchange-base >=0.5 diff --git a/docs/conf.py b/docs/conf.py index ce43d924b..a1f27171e 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -87,7 +87,7 @@ "https://docs.openforcefield.org/projects/fragmenter/en/stable/", None, ), - "openff.units": ( + "openff.toolkit.utils.units": ( "https://docs.openforcefield.org/projects/units/en/stable/", None, ), diff --git a/docs/environment.yml b/docs/environment.yml index a447a1b06..ea806a7d1 100644 --- a/docs/environment.yml +++ b/docs/environment.yml @@ -22,7 +22,6 @@ dependencies: - rdkit - ambertools - packaging - - openff-units - openff-utilities - cachetools - python-constraint diff --git a/examples/deprecated/check_dataset_parameter_coverage/check_parameter_coverage.ipynb b/examples/deprecated/check_dataset_parameter_coverage/check_parameter_coverage.ipynb index 6f99c26be..d771f5942 100644 --- a/examples/deprecated/check_dataset_parameter_coverage/check_parameter_coverage.ipynb +++ b/examples/deprecated/check_dataset_parameter_coverage/check_parameter_coverage.ipynb @@ -29,7 +29,7 @@ "\n", "import numpy as np\n", "from IPython.display import display\n", - "from openff.units import unit\n", + "from openff.toolkit.utils.units import unit\n", "from rdkit import Chem\n", "from rdkit.Chem import Draw\n", "\n", diff --git a/openff/toolkit/__init__.py b/openff/toolkit/__init__.py index cf62c730a..047b3fc03 100644 --- a/openff/toolkit/__init__.py +++ b/openff/toolkit/__init__.py @@ -10,8 +10,6 @@ if TYPE_CHECKING: # These types are imported lazily at runtime, but we need to tell type # checkers what they are - from openff.units import Quantity, unit - from openff.toolkit.topology import Molecule, Topology from openff.toolkit.typing.engines.smirnoff import ( ForceField, @@ -26,6 +24,7 @@ RDKitToolkitWrapper, ToolkitRegistry, ) + from openff.toolkit.utils.units import Quantity, unit __version__ = version("openff.toolkit") @@ -58,8 +57,8 @@ "OpenEyeToolkitWrapper": "openff.toolkit.utils.toolkits", "RDKitToolkitWrapper": "openff.toolkit.utils.toolkits", "ToolkitRegistry": "openff.toolkit.utils.toolkits", - "Quantity": "openff.units", - "unit": "openff.units", + "Quantity": "openff.toolkit.utils.units", + "unit": "openff.toolkit.utils.units", # Remember to add new lazy imports to __all__ and the if TYPE_CHECKING imports } @@ -68,7 +67,6 @@ "topology": "openff.toolkit.topology", "typing": "openff.toolkit.typing", "utils": "openff.toolkit.utils", - "unit": "openff.units.units", } diff --git a/openff/toolkit/_tests/mocking.py b/openff/toolkit/_tests/mocking.py index 9672dc805..c00ac4ed0 100644 --- a/openff/toolkit/_tests/mocking.py +++ b/openff/toolkit/_tests/mocking.py @@ -1,8 +1,8 @@ import numpy -from openff.toolkit import Quantity, unit from openff.toolkit.topology import Molecule from openff.toolkit.typing.engines.smirnoff import VirtualSiteHandler +from openff.toolkit.utils.units import Quantity, unit class VirtualSiteMocking: diff --git a/openff/toolkit/_tests/test_energies.py b/openff/toolkit/_tests/test_energies.py index 849cb79d6..de1fa83d7 100644 --- a/openff/toolkit/_tests/test_energies.py +++ b/openff/toolkit/_tests/test_energies.py @@ -3,12 +3,12 @@ import numpy as np import openmm import pytest -from openff.units.openmm import to_openmm from openmm import unit as openmm_unit from openff.toolkit._tests.utils import get_data_file_path, requires_rdkit from openff.toolkit.topology import Molecule, Topology from openff.toolkit.typing.engines.smirnoff import ForceField +from openff.toolkit.utils.units import to_openmm @requires_rdkit diff --git a/openff/toolkit/_tests/test_forcefield.py b/openff/toolkit/_tests/test_forcefield.py index 953084b15..d04c3c430 100644 --- a/openff/toolkit/_tests/test_forcefield.py +++ b/openff/toolkit/_tests/test_forcefield.py @@ -12,7 +12,6 @@ import openmm import pytest from numpy.testing import assert_almost_equal -from openff.units.openmm import from_openmm, to_openmm from openmm import NonbondedForce, Platform, XmlSerializer, app from openmm import unit as openmm_unit from pydantic import ValidationError @@ -61,6 +60,7 @@ SMIRNOFFSpecUnimplementedError, SMIRNOFFVersionError, ) +from openff.toolkit.utils.units import from_openmm, to_openmm XML_FF_GENERICS = """ diff --git a/openff/toolkit/_tests/test_molecule.py b/openff/toolkit/_tests/test_molecule.py index ecf6f7b00..016aaefc4 100644 --- a/openff/toolkit/_tests/test_molecule.py +++ b/openff/toolkit/_tests/test_molecule.py @@ -24,7 +24,6 @@ import numpy as np import pytest -from openff.units.elements import MASSES, SYMBOLS from openff.toolkit import unit from openff.toolkit._tests.create_molecules import ( @@ -57,6 +56,7 @@ _networkx_graph_to_hill_formula, ) from openff.toolkit.utils import get_data_file_path +from openff.toolkit.utils._elements import MASSES, SYMBOLS from openff.toolkit.utils.exceptions import ( AtomMappingWarning, BadMoleculeAssumptionError, diff --git a/openff/toolkit/_tests/test_toolkit_io.py b/openff/toolkit/_tests/test_toolkit_io.py index 9d911d094..1529a0a22 100644 --- a/openff/toolkit/_tests/test_toolkit_io.py +++ b/openff/toolkit/_tests/test_toolkit_io.py @@ -12,7 +12,6 @@ import pytest from numpy.testing import assert_allclose -from openff.toolkit import Quantity, unit from openff.toolkit._tests import create_molecules from openff.toolkit._tests.utils import requires_openeye, requires_rdkit from openff.toolkit.topology.molecule import Molecule @@ -21,6 +20,7 @@ SMILESParseError, UndefinedStereochemistryError, ) +from openff.toolkit.utils.units import Quantity, unit ETHANOL = create_molecules.create_ethanol() ETHANOL.name = "ethanol" diff --git a/openff/toolkit/_tests/test_toolkits.py b/openff/toolkit/_tests/test_toolkits.py index 476182a9a..42127f4ca 100644 --- a/openff/toolkit/_tests/test_toolkits.py +++ b/openff/toolkit/_tests/test_toolkits.py @@ -13,7 +13,6 @@ from numpy.testing import assert_almost_equal from packaging.version import Version -from openff.toolkit import Quantity, unit from openff.toolkit._tests.create_molecules import ( create_acetaldehyde, create_acetate, @@ -56,6 +55,7 @@ ToolkitRegistry, ToolkitWrapper, ) +from openff.toolkit.utils.units import Quantity, unit _rng = np.random.default_rng() diff --git a/openff/toolkit/_tests/test_topology.py b/openff/toolkit/_tests/test_topology.py index a41e5dd4f..b69d9b5d1 100644 --- a/openff/toolkit/_tests/test_topology.py +++ b/openff/toolkit/_tests/test_topology.py @@ -15,7 +15,6 @@ import openmm.app import openmm.unit import pytest -from openff.units.openmm import from_openmm from openff.utilities import skip_if_missing from openff.toolkit import Molecule, Quantity, Topology, unit @@ -76,6 +75,7 @@ VirtualSitesUnsupportedError, WrongShapeError, ) +from openff.toolkit.utils.units import from_openmm def assert_tuple_of_atoms_equal(atom_tuples1, atom_tuples2, transformed_dict_cls=ValenceDict): @@ -946,8 +946,8 @@ def test_from_mdtraj(self): def test_to_file_units_check(self): """ Checks that writing a PDB file with different coordinate representations results in the same output. - - Angstrom "openff units" (default behavior if using Molecule.conformers[0]) - - nanometer "openff units" + - Angstrom "openff.toolkit.utils.units" (default behavior if using Molecule.conformers[0]) + - nanometer "openff.toolkit.utils.units" - unitless NumPy array - converted OpenMM quantity """ diff --git a/openff/toolkit/_tests/utils.py b/openff/toolkit/_tests/utils.py index a0f2bcc87..6a9675e7d 100644 --- a/openff/toolkit/_tests/utils.py +++ b/openff/toolkit/_tests/utils.py @@ -17,7 +17,6 @@ import numpy as np import openmm import pytest -from openff.units.openmm import to_openmm from openmm import unit as openmm_unit from openff.toolkit import unit @@ -27,6 +26,7 @@ RDKitToolkitWrapper, get_data_file_path, ) +from openff.toolkit.utils.units import to_openmm requires_ambertools = pytest.mark.skipif( not AmberToolsToolkitWrapper.is_available(), diff --git a/openff/toolkit/data/units/README.md b/openff/toolkit/data/units/README.md new file mode 100644 index 000000000..ebc6c30e3 --- /dev/null +++ b/openff/toolkit/data/units/README.md @@ -0,0 +1,3 @@ +### Unit definitions + +The unit definitions are currently sourced from the NIST 2018 [CODATA](https://physics.nist.gov/cuu/Constants/), but may be updated in future versions as new CODATA updates are made. diff --git a/openff/toolkit/data/units/constants.txt b/openff/toolkit/data/units/constants.txt new file mode 100644 index 000000000..ac4a4ef25 --- /dev/null +++ b/openff/toolkit/data/units/constants.txt @@ -0,0 +1,44 @@ +# Based on the default Pint constants definition file (0.16.1) +# https://github.com/hgrecco/pint/tree/master/pint +# +# Based on the International System of Units +# Language: english +# Source: https://physics.nist.gov/cuu/Constants/ +# https://physics.nist.gov/PhysRefData/XrayTrans/Html/search.html +# :copyright: 2013,2019 by Pint Authors, see https://github.com/hgrecco/pint/blob/0.16.1/AUTHORS for more details. + +#### MATHEMATICAL CONSTANTS #### +# As computed by Maxima with fpprec:50 + +pi = 3.1415926535897932384626433832795028841971693993751 = π # pi + +#### DEFINED EXACT CONSTANTS #### +# Recommended CODATA-2018 values + +speed_of_light = 299792458 m/s = c = c_0 # since 1983 +planck_constant = 6.62607015e-34 J s = h # since May 2019 +elementary_charge = 1.602176634e-19 C = e # since May 2019 +avogadro_number = 6.02214076e23 # since May 2019 +boltzmann_constant = 1.380649e-23 J K^-1 = k = k_B # since May 2019 +standard_atmosphere = 1.01325e5 Pa = atm = atmosphere # since 1954 + +#### DERIVED EXACT CONSTANTS #### +# Floating-point conversion may introduce inaccuracies + +zeta = c / (cm/s) = ζ +dirac_constant = h / (2 * π) = ħ = hbar = atomic_unit_of_action = a_u_action +avogadro_constant = avogadro_number * mol^-1 = N_A +molar_gas_constant = k * N_A = R + +#### MEASURED CONSTANTS #### +# Recommended CODATA-2018 values + +rydberg_constant = 1.0973731568160e7 * m^-1 = R_∞ = R_inf # (21) +atomic_mass_constant = 1.66053906660e-27 kg = m_u # (50) +electron_mass = 9.1093837015e-31 kg = m_e = atomic_unit_of_mass = a_u_mass # (28) + +#### DERIVED CONSTANTS #### + +fine_structure_constant = (2 * h * R_inf / (m_e * c)) ** 0.5 = α = alpha +vacuum_permeability = 2 * α * h / (e ** 2 * c) = µ_0 = mu_0 = mu0 = magnetic_constant +vacuum_permittivity = e ** 2 / (2 * α * h * c) = ε_0 = epsilon_0 = eps_0 = eps0 = electric_constant diff --git a/openff/toolkit/data/units/defaults.txt b/openff/toolkit/data/units/defaults.txt new file mode 100644 index 000000000..c901ca98e --- /dev/null +++ b/openff/toolkit/data/units/defaults.txt @@ -0,0 +1,205 @@ +# Based on the default Pint defaults definition file (0.16.1) +# https://github.com/hgrecco/pint/tree/master/pint +# +# Based on the International System of Units +# Language: english +# :copyright: 2013,2019 by Pint Authors, see https://github.com/hgrecco/pint/blob/0.16.1/AUTHORS for more details. + +@defaults + group = international + system = mks +@end + + +#### PREFIXES #### + +# decimal prefixes +yocto- = 1e-24 = y- +zepto- = 1e-21 = z- +atto- = 1e-18 = a- +femto- = 1e-15 = f- +pico- = 1e-12 = p- +nano- = 1e-9 = n- +micro- = 1e-6 = µ- = u- +milli- = 1e-3 = m- +centi- = 1e-2 = c- +deci- = 1e-1 = d- +deca- = 1e+1 = da- = deka- +hecto- = 1e2 = h- +kilo- = 1e3 = k- +mega- = 1e6 = M- +giga- = 1e9 = G- +tera- = 1e12 = T- +peta- = 1e15 = P- +exa- = 1e18 = E- +zetta- = 1e21 = Z- +yotta- = 1e24 = Y- + +# binary_prefixes +kibi- = 2**10 = Ki- +mebi- = 2**20 = Mi- +gibi- = 2**30 = Gi- +tebi- = 2**40 = Ti- +pebi- = 2**50 = Pi- +exbi- = 2**60 = Ei- +zebi- = 2**70 = Zi- +yobi- = 2**80 = Yi- + + +#### BASE UNITS #### + +meter = [length] = m = metre +second = [time] = s = sec +ampere = [current] = A = amp +gram = [mass] = g +mole = [substance] = mol +kelvin = [temperature]; offset: 0 = K = degK = °K = degree_Kelvin = degreeK = Kelvin +radian = [] = rad +bit = [] +count = [] + + +#### CONSTANTS #### + +@import constants.txt + + +#### UNITS #### + +# Angle +degree = π / 180 * radian = deg = arcdeg = arcdegree = angular_degree + +# Information +byte = 8 * bit = B = octet + +# Length +angstrom = 1e-10 * meter = Å = ångström = Å +micron = micrometer = µ +fermi = femtometer = fm +bohr = hbar / (alpha * m_e * c) = a_0 = a0 = bohr_radius = atomic_unit_of_length = a_u_length + +# Mass +unified_atomic_mass_unit = atomic_mass_constant = u = amu +dalton = atomic_mass_constant = Da + +# Time +minute = 60 * second = min +hour = 60 * minute = hr +day = 24 * hour = d +week = 7 * day +year = 365.25 * day = a = yr = julian_year +month = year / 12 + +# MD Time +timestep = [timestep] = _ = timesteps + +# Temperature +degree_Celsius = kelvin; offset: 273.15 = °C = celsius = degC = degreeC + +# Volume +[volume] = [length] ** 3 +liter = decimeter ** 3 = l = L = litre + +# Frequency +[frequency] = 1 / [time] +hertz = 1 / second = Hz + +# Wavenumber +[wavenumber] = 1 / [length] +reciprocal_centimeter = 1 / cm = cm_1 = kayser + +# Velocity +# As of 0.20, derived dimensions cannot have aliases +[velocity] = [length] / [time] + +# Acceleration +[acceleration] = [velocity] / [time] + +# Force +[force] = [mass] * [acceleration] +newton = kilogram * meter / second ** 2 = N +dyne = gram * centimeter / second ** 2 = dyn + +# Energy +[energy] = [force] * [length] +joule = newton * meter = J +erg = dyne * centimeter +rydberg = h * c * R_inf = Ry +hartree = 2 * rydberg = E_h = Eh = hartree_energy = atomic_unit_of_energy = a_u_energy +calorie = 4.184 * joule = cal = thermochemical_calorie = cal_th + +# Energy per mole +calorie_per_mole = calorie / mol +joule_per_mole = joule / mol +calories_per_mole = calorie / mol +joules_per_mole = joule / mol + +# OpenMM compatibility +kilocalorie_per_mole = kilocalorie / mol +kilojoule_per_mole = kilojoule / mol +kilocalories_per_mole = kilocalorie / mol +kilojoules_per_mole = kilojoule / mol + +# Power +[power] = [energy] / [time] +watt = kilogram * meter ** 2 * second ** -3 = W + +# Resistance +[resistance] = [electric_potential] / [current] +ohm = kilogram * meter ** 2 * second ** -3 * ampere ** -2 = Ω + +# Conductance +[conductance] = [current] / [electric_potential] +siemens = kilogram ** -1 * meter ** -2 * second ** 3 * ampere ** 2 = S + +# Inductance +[inductance] = [resistance] * [time] +henry = kilogram * meter ** 2 * second ** -2 * ampere ** -2 = H + +# Magnetic flux +[magnetic_flux] = [energy] / [current] +weber = kilogram * meter ** 2 * second ** -2 * ampere ** -1 = Wb + +# Magnetic flux density +[magnetic_flux_density] = [magnetic_flux] / [area] +tesla = kilogram * second ** -2 * ampere ** -1 = T + +# Momentum +[momentum] = [length] * [mass] / [time] + +# Density (as auxiliary for pressure) +[density] = [mass] / [volume] + +# Pressure +[pressure] = [force] / [area] +pascal = newton / meter ** 2 = Pa +bar = 1e5 * pascal + +# Concentration +[concentration] = [substance] / [volume] +molar = mole / liter = M + +# Charge +[charge] = [current] * [time] +coulomb = ampere * second = C +faraday = e * N_A * mole + +# Electric potential +[electric_potential] = [energy] / [charge] +volt = joule / coulomb = V + +# Capacitance +[capacitance] = [charge] / [electric_potential] +farad = coulomb / volt = F + +# Electric dipole moment +[electric_dipole] = [charge] * [length] +debye = 1e-9 / ζ * coulomb * angstrom = D # formally 1 D = 1e-10 Fr*Å, but we generally want to use it outside the Gaussian context + +#### SYSTEMS OF UNITS #### + +@system mks using international + meter + kilogram + second +@end diff --git a/openff/toolkit/topology/_mm_molecule.py b/openff/toolkit/topology/_mm_molecule.py index 114e94ce8..9b6ce4e97 100644 --- a/openff/toolkit/topology/_mm_molecule.py +++ b/openff/toolkit/topology/_mm_molecule.py @@ -14,8 +14,6 @@ from collections.abc import Generator, Iterable from typing import TYPE_CHECKING, NoReturn, Union -from openff.units.elements import MASSES, SYMBOLS - from openff.toolkit import unit from openff.toolkit.topology.molecule import ( AtomMetadataDict, @@ -25,14 +23,16 @@ _atom_nums_to_hill_formula, _has_unique_atom_names, ) +from openff.toolkit.utils._elements import MASSES, SYMBOLS from openff.toolkit.utils.exceptions import UnsupportedMoleculeConversionError from openff.toolkit.utils.utils import deserialize_numpy, serialize_numpy if TYPE_CHECKING: import networkx as nx - from openff.toolkit import Quantity, Topology + from openff.toolkit.topology import Topology from openff.toolkit.topology.molecule import FrozenMolecule + from openff.toolkit.utils.units import Quantity class _SimpleMolecule: diff --git a/openff/toolkit/topology/molecule.py b/openff/toolkit/topology/molecule.py index aa16cd819..648d983be 100644 --- a/openff/toolkit/topology/molecule.py +++ b/openff/toolkit/topology/molecule.py @@ -46,11 +46,9 @@ ) import numpy as np -from openff.units import Unit -from openff.units.elements import MASSES, SYMBOLS from openff.utilities.exceptions import MissingOptionalDependencyError -from openff.toolkit import Quantity, unit +from openff.toolkit.utils._elements import MASSES, SYMBOLS from openff.toolkit.utils.constants import DEFAULT_AROMATICITY_MODEL from openff.toolkit.utils.exceptions import ( AtomMappingWarning, @@ -85,6 +83,7 @@ ToolkitWrapper, UndefinedStereochemistryError, ) +from openff.toolkit.utils.units import Quantity, Unit, unit from openff.toolkit.utils.utils import get_data_file_path, requires_package if TYPE_CHECKING: @@ -363,7 +362,7 @@ def formal_charge(self, other: "int | Quantity | OMMQuantity"): f"Unsupported type passed to formal_charge setter. Found object of type {type(other)}." ) - from openff.units.openmm import from_openmm + from openff.toolkit.utils.units import from_openmm converted = from_openmm(other) if converted.units in _CHARGE_UNITS: @@ -469,7 +468,7 @@ def mass(self) -> Quantity: The mass is reported in units of Dalton. """ # This is assumed elsewhere in the codebase to be in units of Dalton, which is what is - # reported by MASSES as of openff-units v0.1.5. There may be performance implications if + # reported by MASSES as of openff.toolkit.utils.units v0.1.5. There may be performance implications if # other functions need to verify or convert units. # https://github.com/openforcefield/openff-toolkit/pull/1182#discussion_r802078273 return MASSES[self.atomic_number] @@ -1156,11 +1155,11 @@ def to_dict(self) -> dict: - **hierarchy_schemes** (dict[dict]): Dictionary where keys (such as ``"residues"`` and ``"chains"``) represent dictionary outputs from :meth:`HierarchyScheme.to_dict()` - **conformers_unit** (str, default="angstrom"): Valid unit of length input for the - `OpenFF Units module `_. + `OpenFF Units module `_. - **partial_charges** (list[float], default=None): Array of partial charge (in unit defined by ``partial_charge_unit``) for atoms in the same order as the output,``atoms``. - **partial_charge_unit** (str, default=None): Valid unit of charge input for the - `OpenFF Units module `_. + `OpenFF Units module `_. If ``partial_charges`` is also included, the default is ``"elementary_charge"`` instead. """ @@ -3117,9 +3116,10 @@ def _add_conformer(self, coordinates: Quantity): ) elif hasattr(coordinates, "unit"): - from openff.units.openmm import from_openmm from openmm import unit as openmm_unit + from openff.toolkit.utils.units import from_openmm + if not isinstance(coordinates, openmm_unit.Quantity): raise IncompatibleUnitError( "Unsupported type passed to Molecule._add_conformer setter. " @@ -3138,7 +3138,7 @@ def _add_conformer(self, coordinates: Quantity): else: raise IncompatibleUnitError( "Unknown object passed to Molecule._add_conformer. Expected types include " - f"openmm.unit.Quantity and openff.units.unit.Quantity, found type {type(coordinates)}." + f"openmm.unit.Quantity and openff.toolkit.utils.units.unit.Quantity, found type {type(coordinates)}." ) tmp_conf = Quantity(np.zeros(shape=(self.n_atoms, 3), dtype=float), unit.angstrom) @@ -3186,7 +3186,7 @@ def partial_charges(self, charges): raise IncompatibleTypeError( "Unsupported type passed to partial_charges setter. " f"Found object of type {type(charges)}. " - "Expected openff.units.unit.Quantity" + "Expected openff.toolkit.utils.units.unit.Quantity" ) if not charges.shape == (self.n_atoms,): @@ -3213,7 +3213,7 @@ def partial_charges(self, charges): ) else: - from openff.units.openmm import from_openmm + from openff.toolkit.utils.units import from_openmm converted = from_openmm(charges) if converted.units in _CHARGE_UNITS: @@ -3228,7 +3228,7 @@ def partial_charges(self, charges): raise IncompatibleTypeError( "Unsupported type passed to partial_charges setter. " f"Found object of type {type(charges)}, " - "expected openff.units.unit.Quantity" + "expected openff.toolkit.utils.units.unit.Quantity" ) @property diff --git a/openff/toolkit/topology/topology.py b/openff/toolkit/topology/topology.py index 9467d350e..d1af58626 100644 --- a/openff/toolkit/topology/topology.py +++ b/openff/toolkit/topology/topology.py @@ -27,9 +27,7 @@ import numpy as np from numpy.typing import NDArray -from openff.units import Unit -from openff.toolkit import Quantity, unit from openff.toolkit.topology import Molecule from openff.toolkit.topology._mm_molecule import ( _SimpleAtom, @@ -60,6 +58,7 @@ ) from openff.toolkit.utils.serialization import Serializable from openff.toolkit.utils.toolkits import GLOBAL_TOOLKIT_REGISTRY +from openff.toolkit.utils.units import Quantity, Unit, unit from openff.toolkit.utils.utils import get_data_file_path, requires_package if TYPE_CHECKING: @@ -621,7 +620,7 @@ def box_vectors(self, box_vectors): return if not hasattr(box_vectors, "units"): if hasattr(box_vectors, "unit"): - from openff.units import ensure_quantity + from openff.toolkit.utils.units import ensure_quantity # this is probably an openmm.unit.Quantity; we should gracefully import OpenMM but # the chances of this being an object with the two previous conditions met is low @@ -1380,9 +1379,9 @@ def from_openmm( If a chemically impossible molecule is detected in the topology """ import networkx as nx - from openff.units.openmm import from_openmm from openff.toolkit.topology.molecule import Molecule + from openff.toolkit.utils.units import from_openmm # Check to see if the openMM system has defined bond orders, by looping over all Bonds in the Topology. omm_has_bond_orders = True @@ -1514,7 +1513,7 @@ def from_openmm( topology.box_vectors = from_openmm(openmm_topology.getPeriodicBoxVectors()) if positions is not None: - from openff.units import ensure_quantity + from openff.toolkit.utils.units import ensure_quantity topology.set_positions(ensure_quantity(positions, "openff")) @@ -1926,7 +1925,7 @@ def to_openmm( ) if off_topology.box_vectors is not None: - from openff.units.openmm import to_openmm + from openff.toolkit.utils.units import to_openmm omm_topology.setPeriodicBoxVectors(to_openmm(off_topology.box_vectors)) return omm_topology @@ -1963,7 +1962,7 @@ def to_file( - ``openmm.unit.Quantity`` object which has atomic positions as a List of unit-tagged ``Vec3`` objects - - ``openff.units.unit.Quantity`` object which wraps a + - ``openff.toolkit.utils.units.unit.Quantity`` object which wraps a ``numpy.ndarray`` with dimensions of length - (unitless) 2D ``numpy.ndarray``, in which it is assumed that the positions are in units of Angstroms. diff --git a/openff/toolkit/typing/engines/smirnoff/forcefield.py b/openff/toolkit/typing/engines/smirnoff/forcefield.py index ae89aa456..58a715126 100644 --- a/openff/toolkit/typing/engines/smirnoff/forcefield.py +++ b/openff/toolkit/typing/engines/smirnoff/forcefield.py @@ -19,7 +19,6 @@ from packaging.version import Version -from openff.toolkit import Quantity from openff.toolkit.typing.engines.smirnoff.io import ParameterIOHandler from openff.toolkit.typing.engines.smirnoff.parameters import ParameterHandler from openff.toolkit.typing.engines.smirnoff.plugins import load_handler_plugins @@ -34,6 +33,7 @@ SMIRNOFFParseError, SMIRNOFFVersionError, ) +from openff.toolkit.utils.units import Quantity from openff.toolkit.utils.utils import ( all_subclasses, convert_0_1_smirnoff_to_0_2, diff --git a/openff/toolkit/typing/engines/smirnoff/parameters.py b/openff/toolkit/typing/engines/smirnoff/parameters.py index 619df8673..1f18de029 100644 --- a/openff/toolkit/typing/engines/smirnoff/parameters.py +++ b/openff/toolkit/typing/engines/smirnoff/parameters.py @@ -63,10 +63,8 @@ from typing import Any, Literal, Union, cast, get_args import numpy -from openff.units.units import Unit from packaging.version import Version -from openff.toolkit import Quantity, unit from openff.toolkit.topology import ImproperDict, TagSortedDict, Topology, ValenceDict from openff.toolkit.topology.molecule import Molecule from openff.toolkit.utils.collections import ValidatedDict, ValidatedList @@ -89,6 +87,7 @@ UnassignedValenceParameterException, ) from openff.toolkit.utils.toolkits import GLOBAL_TOOLKIT_REGISTRY +from openff.toolkit.utils.units import Quantity, Unit, unit from openff.toolkit.utils.utils import object_to_quantity logger = logging.getLogger(__name__) @@ -116,7 +115,7 @@ def _linear_inter_or_extrapolate(points_dict, x_query): """ Linearly interpolate or extrapolate based on a piecewise linear function defined by a set of points. This function is designed to work with - key:value pairs where the value may be a ``openff.units.Quantity``. + key:value pairs where the value may be a ``openff.toolkit.utils.units.Quantity``. Parameters ---------- diff --git a/openff/toolkit/utils/_elements.py b/openff/toolkit/utils/_elements.py new file mode 100644 index 000000000..beafbb1b6 --- /dev/null +++ b/openff/toolkit/utils/_elements.py @@ -0,0 +1,291 @@ +""" +Symbols and masses for the chemical elements. + +This module provides mappings from atomic number to atomic mass and symbol. +These dicts were seeded from running the below script using OpenMM 7.7. + +It's not completely clear where OpenMM sourced these values from [1] but they are generally +consistent with recent IUPAC values [2]. + +1. https://github.com/openmm/openmm/issues/3434#issuecomment-1023406296 +2. https://www.ciaaw.org/publications.htm + +.. code-block:: python + + import openmm.app + + masses = { + atomic_number: openmm.app.element.Element.getByAtomicNumber( + atomic_number + ).mass._value + for atomic_number in range(1, 117) + } + + symbols = { + atomic_number: openmm.app.element.Element.getByAtomicNumber(atomic_number).symbol + for atomic_number in range(1, 117) + } + +""" + +from openff.toolkit.utils.units import Quantity + +__all__ = [ + "MASSES", + "NUMBERS", + "SYMBOLS", +] + +"""Mapping from atomic number to atomic mass""" +MASSES: dict[int, Quantity] = { + # https://github.com/hgrecco/pint/issues/1804 + index + 1: Quantity(mass, "dalton") + for index, mass in enumerate( + [ + 1.007947, + 4.003, + 6.9412, + 9.0121823, + 10.8117, + 12.01078, + 14.00672, + 15.99943, + 18.99840325, + 20.17976, + 22.989769282, + 24.30506, + 26.98153868, + 28.08553, + 30.9737622, + 32.0655, + 35.4532, + 39.9481, + 39.09831, + 40.0784, + 44.9559126, + 47.8671, + 50.94151, + 51.99616, + 54.9380455, + 55.8452, + 58.9331955, + 58.69342, + 63.5463, + 65.4094, + 69.7231, + 72.641, + 74.921602, + 78.963, + 79.9041, + 83.7982, + 85.46783, + 87.621, + 88.905852, + 91.2242, + 92.906382, + 95.942, + 98, + 101.072, + 102.905502, + 106.421, + 107.86822, + 112.4118, + 114.8183, + 118.7107, + 121.7601, + 127.603, + 126.904473, + 131.2936, + 132.90545192, + 137.3277, + 138.905477, + 140.1161, + 140.907652, + 144.2423, + 145, + 150.362, + 151.9641, + 157.253, + 158.925352, + 162.5001, + 164.930322, + 167.2593, + 168.934212, + 173.043, + 174.9671, + 178.492, + 180.947882, + 183.841, + 186.2071, + 190.233, + 192.2173, + 195.0849, + 196.9665694, + 200.592, + 204.38332, + 207.21, + 208.980401, + 209, + 210, + 222.018, + 223, + 226, + 227, + 232.038062, + 231.035882, + 238.028913, + 237, + 244, + 243, + 247, + 247, + 251, + 252, + 257, + 258, + 259, + 262, + 261, + 262, + 266, + 264, + 269, + 268, + 281, + 272, + 285, + 284, + 289, + 288, + 292, + ] + ) +} + +"""Mapping from atomic number to element symbol""" +SYMBOLS: dict[int, str] = { + index + 1: symbol + for index, symbol in enumerate( + [ + "H", + "He", + "Li", + "Be", + "B", + "C", + "N", + "O", + "F", + "Ne", + "Na", + "Mg", + "Al", + "Si", + "P", + "S", + "Cl", + "Ar", + "K", + "Ca", + "Sc", + "Ti", + "V", + "Cr", + "Mn", + "Fe", + "Co", + "Ni", + "Cu", + "Zn", + "Ga", + "Ge", + "As", + "Se", + "Br", + "Kr", + "Rb", + "Sr", + "Y", + "Zr", + "Nb", + "Mo", + "Tc", + "Ru", + "Rh", + "Pd", + "Ag", + "Cd", + "In", + "Sn", + "Sb", + "Te", + "I", + "Xe", + "Cs", + "Ba", + "La", + "Ce", + "Pr", + "Nd", + "Pm", + "Sm", + "Eu", + "Gd", + "Tb", + "Dy", + "Ho", + "Er", + "Tm", + "Yb", + "Lu", + "Hf", + "Ta", + "W", + "Re", + "Os", + "Ir", + "Pt", + "Au", + "Hg", + "Tl", + "Pb", + "Bi", + "Po", + "At", + "Rn", + "Fr", + "Ra", + "Ac", + "Th", + "Pa", + "U", + "Np", + "Pu", + "Am", + "Cm", + "Bk", + "Cf", + "Es", + "Fm", + "Md", + "No", + "Lr", + "Rf", + "Db", + "Sg", + "Bh", + "Hs", + "Mt", + "Ds", + "Rg", + "Uub", + "Uut", + "Uuq", + "Uup", + "Uuh", + ] + ) +} + +"""Mapping from element symbol to atomic number""" +NUMBERS: dict[str, int] = {val: key for key, val in SYMBOLS.items()} diff --git a/openff/toolkit/utils/ambertools_wrapper.py b/openff/toolkit/utils/ambertools_wrapper.py index 145e077a9..0d712fa5d 100644 --- a/openff/toolkit/utils/ambertools_wrapper.py +++ b/openff/toolkit/utils/ambertools_wrapper.py @@ -13,7 +13,6 @@ import numpy as np -from openff.toolkit import Quantity, unit from openff.toolkit.utils import base_wrapper, rdkit_wrapper from openff.toolkit.utils.exceptions import ( AntechamberNotFoundError, @@ -21,6 +20,7 @@ ChargeMethodUnavailableError, ToolkitUnavailableException, ) +from openff.toolkit.utils.units import Quantity, unit if TYPE_CHECKING: from openff.toolkit.topology.molecule import Molecule diff --git a/openff/toolkit/utils/builtin_wrapper.py b/openff/toolkit/utils/builtin_wrapper.py index 9b1bdfafa..170f65db3 100644 --- a/openff/toolkit/utils/builtin_wrapper.py +++ b/openff/toolkit/utils/builtin_wrapper.py @@ -6,9 +6,9 @@ from typing import TYPE_CHECKING -from openff.toolkit import Quantity, unit from openff.toolkit.utils import base_wrapper from openff.toolkit.utils.exceptions import ChargeMethodUnavailableError +from openff.toolkit.utils.units import Quantity, unit from openff.toolkit.utils.utils import inherit_docstrings if TYPE_CHECKING: diff --git a/openff/toolkit/utils/exceptions.py b/openff/toolkit/utils/exceptions.py index f8e69b8bf..7f768fbc4 100644 --- a/openff/toolkit/utils/exceptions.py +++ b/openff/toolkit/utils/exceptions.py @@ -7,6 +7,8 @@ from openmm.app import Residue as OpenMMResidue from openmm.app import Topology as OpenMMTopology +# no __all__ ? + class OpenFFToolkitException(Exception): """Base exception for custom exceptions raised by the OpenFF Toolkit""" diff --git a/openff/toolkit/utils/nagl_wrapper.py b/openff/toolkit/utils/nagl_wrapper.py index 7587ce2ff..2fcfc45c8 100644 --- a/openff/toolkit/utils/nagl_wrapper.py +++ b/openff/toolkit/utils/nagl_wrapper.py @@ -3,11 +3,11 @@ import warnings from typing import TYPE_CHECKING -from openff.toolkit import Quantity, unit from openff.toolkit.utils.base_wrapper import ToolkitWrapper from openff.toolkit.utils.exceptions import ( ToolkitUnavailableException, ) +from openff.toolkit.utils.units import Quantity, unit if TYPE_CHECKING: from openff.toolkit.topology.molecule import FrozenMolecule, Molecule diff --git a/openff/toolkit/utils/openeye_wrapper.py b/openff/toolkit/utils/openeye_wrapper.py index 2a33c4a3e..48923b1ba 100644 --- a/openff/toolkit/utils/openeye_wrapper.py +++ b/openff/toolkit/utils/openeye_wrapper.py @@ -23,9 +23,8 @@ import numpy as np from cachetools import LRUCache, cached -from openff.units.elements import SYMBOLS -from openff.toolkit import Quantity, unit +from openff.toolkit.utils._elements import SYMBOLS from openff.toolkit.utils.base_wrapper import ( ToolkitWrapper, _ChargeSettings, @@ -56,6 +55,7 @@ UnassignedChemistryInPDBError, UndefinedStereochemistryError, ) +from openff.toolkit.utils.units import Quantity, unit from openff.toolkit.utils.utils import inherit_docstrings if TYPE_CHECKING: diff --git a/openff/toolkit/utils/rdkit_wrapper.py b/openff/toolkit/utils/rdkit_wrapper.py index 90d1da47a..39f0a41a1 100644 --- a/openff/toolkit/utils/rdkit_wrapper.py +++ b/openff/toolkit/utils/rdkit_wrapper.py @@ -19,10 +19,9 @@ import numpy as np from cachetools import LRUCache, cached from numpy.typing import NDArray -from openff.units.elements import SYMBOLS -from openff.toolkit import Quantity, unit from openff.toolkit.utils import base_wrapper +from openff.toolkit.utils._elements import SYMBOLS from openff.toolkit.utils.constants import ( ALLOWED_AROMATICITY_MODELS, DEFAULT_AROMATICITY_MODEL, @@ -49,6 +48,7 @@ UnassignedChemistryInPDBError, UndefinedStereochemistryError, ) +from openff.toolkit.utils.units import Quantity, unit if TYPE_CHECKING: from openff.toolkit.topology.molecule import Atom, Bond, Molecule @@ -293,10 +293,11 @@ def _polymer_openmm_pdbfile_to_offtop( ): import json - from openff.units.openmm import from_openmm from rdkit import Chem, Geometry from rdkit.DataStructs.cDataStructs import BitVectToBinaryText + from openff.toolkit.utils.units import from_openmm + omm_top = pdbfile.topology if not _custom_substructures: diff --git a/openff/toolkit/utils/units.py b/openff/toolkit/utils/units.py new file mode 100644 index 000000000..873d0627e --- /dev/null +++ b/openff/toolkit/utils/units.py @@ -0,0 +1,566 @@ +""" +Core classes for OpenFF Units +""" + +from __future__ import annotations + +import ast +import json +import operator as op +import uuid +import warnings +from typing import TYPE_CHECKING, Any, Literal, overload + +import numpy # possible to make this optional? +import pint +from openff.utilities import has_package, requires_package +from pint import Measurement as _Measurement +from pint import Quantity as _Quantity +from pint import Unit as _Unit +from pint.facets.plain.quantity import PlainQuantity as PintQuantity + +from openff.toolkit.utils.exceptions import OpenFFToolkitException + +if TYPE_CHECKING: + import openmm.unit + +try: + from pydantic import GetCoreSchemaHandler + from pydantic_core import core_schema + + has_pydantic = True +except ImportError: + has_pydantic = False + + +__all__ = ( + "DEFAULT_UNIT_REGISTRY", + "Measurement", + "Quantity", + "Unit", + "ensure_quantity", + "from_openmm", + "openmm_unit_to_string", + "string_to_openmm_unit", + "to_openmm", + "unit", +) + + +class MissingOpenMMUnitError(OpenFFToolkitException): + """Raised when a unit cannot be converted to an equivalent OpenMM unit""" + + +class NoneQuantityError(OpenFFToolkitException): + """Raised when attempting to convert `None` between unit packages as a quantity object""" + + +class NoneUnitError(OpenFFToolkitException): + """Raised when attempting to convert `None` between unit packages as a unit object""" + + +""" +from openff.toolkit.utils.exceptions import ( + MissingOpenMMUnitError, + NoneQuantityError, + NoneUnitError, +) +""" + + +def get_defaults_path() -> str: + """Get the full path to the ``defaults.txt`` file""" + from openff.utilities import get_data_file_path + + return get_data_file_path("data/units/defaults.txt", "openff.toolkit") + + +class Unit(pint.UnitRegistry.Unit): + """A unit of measure.""" + + pass + + +if has_pydantic: + + class _QuantityMixin: + @classmethod + def serialize( + cls, + v: PintQuantity, + info: core_schema.SerializationInfo | None = None, + ) -> dict | str | PintQuantity: + to_json = info is not None and info.mode_is_json() + + if to_json: + magnitude = v.magnitude + + # storing numpy arrays natively works fine in memory in Python, + # but must be list-ified when serializing to JSON. + if isinstance(magnitude, numpy.ndarray): + magnitude = v.magnitude.tolist() + + # TODO: I think this is necessary for handling unit-wrapped arrays, but it is + # not so performant. Scalar quantities can be directly serialized to much + # shorter strings + return json.dumps( + { + "magnitude": magnitude, + "units": str(v.units), + } + ) + + return { + "magnitude": v.magnitude, + "units": str(v.units), + } + + @classmethod + def validate( + cls, + v: dict | str | PintQuantity, + ): + if isinstance(v, Quantity): + return v + elif isinstance(v, str): + # TODO: A significant wart is that we have to try to guess whether the string is a + # JSON-serialized quantity or a simple string representation of a quantity. + # For example: input of "0.9 nanometer" can be passed directly to the + # Quantity constructor, but "{"magnitude": 0.9, "units": "nanometer"}" cannot + # as it needs to be unwrapped. A better solution would require a better way + # of serializing unit-wrapped arrays to JSON + if "{" in v: + deserialized = json.loads(v) + return Quantity( + deserialized["magnitude"], + deserialized["units"], + ) + else: + return Quantity(v) + elif isinstance(v, dict): + return Quantity(v["magnitude"], v["units"]) + else: + # this cannot be accessed with the current core_schema definition - the types of + # the `v` argument to this method **happen** to be identical to the supported types + # in the core_schema. If **either** is changed, this clause may be hit + raise ValueError(f"Invalid type {type(v)} for Quantity") + + @classmethod + def __get_pydantic_core_schema__( + cls, + source_type: Any, + handler: GetCoreSchemaHandler, + ) -> core_schema.CoreSchema: + + validate_schema = core_schema.chain_schema( + [ + core_schema.union_schema( + [ + core_schema.is_instance_schema(PintQuantity), + core_schema.str_schema(), + core_schema.dict_schema(), + # any other types that could be accepted by a quantity field? + ] + ), + core_schema.no_info_plain_validator_function(cls.validate), + ] + ) + + validate_json_schema = core_schema.chain_schema( + [ + core_schema.union_schema( + [ + core_schema.str_schema(coerce_numbers_to_str=True), + core_schema.dict_schema(), + ] + ), + core_schema.no_info_plain_validator_function(cls.validate), + ] + ) + + serialize_schema = core_schema.plain_serializer_function_ser_schema( + cls.serialize, + info_arg=True, + ) + + return core_schema.json_or_python_schema( + json_schema=validate_json_schema, + python_schema=validate_schema, + serialization=serialize_schema, + ) +else: + + class _QuantityMixin: # type: ignore[no-redef] + pass + + +class Quantity(_QuantityMixin, PintQuantity): + """A value with associated units.""" + + def __dask_tokenize__(self): + return uuid.uuid4().hex + + @staticmethod + def _dask_finalize(results, func, args, units): + values = func(results, *args) + return Quantity(values, units) + + +@requires_package("openmm") +def _to_openmm(self) -> openmm.unit.Quantity: + """Convert the quantity to an ``openmm.unit.Quantity``. + + Returns + ------- + openmm_quantity : openmm.unit.quantity.Quantity + The OpenMM compatible quantity. + """ + from openff.toolkit.utils.units import to_openmm + + return to_openmm(self) + + +class Measurement(pint.UnitRegistry.Measurement): # type: ignore + """A value with associated units and uncertainty.""" + + def __dask_tokenize__(self): + return uuid.uuid4().hex + + @staticmethod + def _dask_finalize(results, func, args, units): + values = func(results, *args) + return Measurement(values, units) + + +class UnitRegistry(pint.UnitRegistry): + _quantity_class = Quantity + _unit_class = Unit + _measurement_class = Measurement + + +DEFAULT_UNIT_REGISTRY = UnitRegistry(get_defaults_path()) + +unit = DEFAULT_UNIT_REGISTRY + +Unit: type[_Unit] = DEFAULT_UNIT_REGISTRY.Unit # type: ignore[no-redef] +Quantity: type[_Quantity] = DEFAULT_UNIT_REGISTRY.Quantity # type: ignore[no-redef] +Measurement: type[_Measurement] = DEFAULT_UNIT_REGISTRY.Measurement # type: ignore + +Quantity.to_openmm = _to_openmm # type: ignore[attr-defined] + +if has_pydantic: + # Re-attach the Pydantic magic to our new Quantity class, which itself was + # dynamically created by Pint's magic (and lost these methods in the process). + Quantity.__get_pydantic_core_schema__ = _QuantityMixin.__get_pydantic_core_schema__ # type: ignore + Quantity.validate = _QuantityMixin.validate # type: ignore + Quantity.serialize = _QuantityMixin.serialize # type: ignore + + +pint.set_application_registry(DEFAULT_UNIT_REGISTRY) + +with warnings.catch_warnings(): + warnings.simplefilter("ignore") + Quantity([]) + + +# below this line was openff/units/openmm.py + +if has_package("openmm.unit"): + import openmm.unit + + EitherQuantity: type[Quantity | openmm.unit.Quantity] = Quantity | openmm.unit.Quantity # type: ignore[no-redef] +else: + EitherQuantity: type[Quantity] = Quantity # type: ignore[no-redef] + + +@requires_package("openmm.unit") +def openmm_unit_to_string(input_unit: openmm.unit.Unit) -> str: + """ + Convert a openmm.unit.Unit to a string representation. + + Parameters + ---------- + input_unit : A openmm.unit + The unit to serialize + + Returns + ------- + unit_string : str + The serialized unit. + """ + if input_unit is None: + raise NoneUnitError("Input is None, expected an (OpenMM) Unit object.") + + if input_unit == openmm.unit.dimensionless: + return "dimensionless" + + if input_unit == openmm.unit.dalton: + return "g/mol" + + # Decompose output_unit into a tuples of (base_dimension_unit, exponent) + unit_string = "" + + for unit_component in input_unit.iter_base_or_scaled_units(): + unit_component_name = unit_component[0].name + # Convert, for example "elementary charge" --> "elementary_charge" + unit_component_name = unit_component_name.replace(" ", "_") + if unit_component[1] == 1: + contribution = f"{unit_component_name}" + else: + contribution = f"{unit_component_name}**{int(unit_component[1])}" + if unit_string == "": + unit_string = contribution + else: + unit_string += f" * {contribution}" + + return unit_string + + +def _ast_eval(node): + """ + Performs an algebraic syntax tree evaluation of a unit. + + Parameters + ---------- + node : An ast parsing tree node + + Raises + ------ + openff.units.exceptions.MissingOpenMMUnitError + if the unit is unavailable in OpenMM. + """ + + operators = { + ast.Add: op.add, + ast.Sub: op.sub, + ast.Mult: op.mul, + ast.Div: op.truediv, + ast.Pow: op.pow, + ast.BitXor: op.xor, + ast.USub: op.neg, + } + + if isinstance(node, ast.Constant): # + return node.value + elif isinstance(node, ast.BinOp): # + return operators[type(node.op)](_ast_eval(node.left), _ast_eval(node.right)) + elif isinstance(node, ast.UnaryOp): # e.g., -1 + return operators[type(node.op)](_ast_eval(node.operand)) + elif isinstance(node, ast.Name): + # see if this is a openmm unit + try: + b = getattr(openmm.unit, node.id) + except AttributeError: + raise MissingOpenMMUnitError(node.id) + return b + # TODO: This toolkit code that had a hack to cover some edge behavior + # not clear which tests trigger it + elif isinstance(node, ast.List): + return ast.literal_eval(node) + else: + raise TypeError(node) + + +def string_to_openmm_unit(unit_string: str) -> openmm.unit.Unit: + """ + Deserializes a openmm.unit.Quantity from a string representation, for + example: "kilocalories_per_mole / angstrom ** 2" + + + Parameters + ---------- + unit_string : dict + Serialized representation of a openmm.unit.Quantity. + + Returns + ------- + output_unit: openmm.unit.Quantity + The deserialized unit from the string + + Raises + ------ + openff.units.exceptions.MissingOpenMMUnitError + if the unit is unavailable in OpenMM. + """ + if unit_string == "standard_atmosphere": + return openmm.unit.atmosphere + + output_unit = _ast_eval(ast.parse(unit_string, mode="eval").body) + return output_unit + + +@requires_package("openmm.unit") +def from_openmm(openmm_quantity: openmm.unit.Quantity) -> Quantity: + """Convert an OpenMM ``Quantity`` to an OpenFF ``Quantity`` + + :class:`openmm.unit.quantity.Quantity` from OpenMM and + :class:`openff.units.Quantity` from this package both represent a numerical + value with units. + + Examples + -------- + + >>> from openff.units import Quantity as OpenFFQuantity + >>> from openff.units import from_openmm + >>> from openmm import unit + >>> length = unit.Quantity(9.0, unit.angstrom) + >>> from_openmm(length) + + >>> assert isinstance(from_openmm(length), OpenFFQuantity) + + """ + if openmm_quantity is None: + raise NoneQuantityError("Input is None, expected an (OpenMM) Quantity object.") + + if isinstance(openmm_quantity, list): + openmm_quantity = openmm.unit.Quantity(openmm_quantity) + openmm_unit_ = openmm_quantity.unit + openmm_value = openmm_quantity.value_in_unit(openmm_unit_) + + target_unit = openmm_unit_to_string(openmm_unit_) + target_unit = Unit(target_unit) + + return Quantity(openmm_value, target_unit) + + +@requires_package("openmm.unit") +def to_openmm(quantity: Quantity) -> openmm.unit.Quantity: + """Convert an OpenFF ``Quantity`` to an OpenMM ``Quantity`` + + :class:`openmm.unit.quantity.Quantity` from OpenMM and + :class:`openff.units.Quantity` from this package both represent a numerical + value with units. The units available in the two packages differ; when a + unit is missing from the target package, the resulting quantity will be in + base units (kg/m/s/A/K/mole), which are shared between both packages. This + may cause the resulting value to be slightly different to the input due to + the limited precision of floating point numbers. + + Examples + -------- + + >>> from openff.units import unit + >>> from openff.units import to_openmm + >>> from openmm import unit as openmm_unit + >>> length = unit.Quantity(9.0, unit.angstrom) + >>> to_openmm(length) + 9.0 A + >>> assert isinstance(to_openmm(length), openmm_unit.Quantity) + + """ + if quantity is None: + raise NoneQuantityError("Input is None, expected an (OpenFF) Quantity object.") + + def to_openmm_inner(quantity) -> openmm.unit.Quantity: + value = quantity.m + + unit_string = str(quantity.units._units) + openmm_unit_ = string_to_openmm_unit(unit_string) + + return value * openmm_unit_ + + assert isinstance(quantity, Quantity) + + try: + return to_openmm_inner(quantity) + except MissingOpenMMUnitError: + return to_openmm_inner(quantity.to_base_units()) + + +@requires_package("openmm.unit") +def _ensure_openmm_quantity( + unknown_quantity: EitherQuantity, +) -> openmm.unit.Quantity: + if "openmm" in str(type(unknown_quantity)): + from openmm import unit as openmm_unit + + if isinstance(unknown_quantity, openmm_unit.Quantity): + return unknown_quantity + else: + raise ValueError(f"Failed to process input of type {type(unknown_quantity)}.") + elif isinstance(unknown_quantity, Quantity): + return to_openmm(unknown_quantity) + else: + import openmm.unit + + try: + return openmm.unit.Quantity( + unknown_quantity, + openmm.unit.dimensionless, + ) + except Exception as e: + raise ValueError(f"Failed to process input of type {type(unknown_quantity)}.") from e + + +def _ensure_openff_quantity( + unknown_quantity: EitherQuantity, +) -> Quantity: + if isinstance(unknown_quantity, Quantity): + return unknown_quantity + elif "openmm" in str(type(unknown_quantity)): + import openmm.unit + + if isinstance(unknown_quantity, openmm.unit.Quantity): + return from_openmm(unknown_quantity) + else: + raise ValueError(f"Failed to process input of type {type(unknown_quantity)}.") + else: + try: + return Quantity( + unknown_quantity, + "dimensionless", + ) + except Exception as e: + raise ValueError(f"Failed to process input of type {type(unknown_quantity)}.") from e + + +@overload +def ensure_quantity( + unknown_quantity: EitherQuantity, + type_to_ensure: Literal["openmm"], +) -> openmm.unit.Quantity: ... + + +@overload +def ensure_quantity( + unknown_quantity: EitherQuantity, + type_to_ensure: Literal["openff"], +) -> Quantity: ... + + +def ensure_quantity( + unknown_quantity: EitherQuantity, + type_to_ensure: Literal["openmm", "openff"], +) -> EitherQuantity: + """ + Given a quantity that could be of a variety of types, attempt to coerce into a given type. + + Examples + -------- + + >>> import numpy + >>> from openmm import unit as openmm_unit + >>> from openff.units import unit + >>> from openff.units import ensure_quantity + >>> # Create a 9 Angstrom quantity with each registry + >>> length1 = unit.Quantity(9.0, unit.angstrom) + >>> length2 = openmm_unit.Quantity(9.0, openmm_unit.angstrom) + >>> # Similar quantities are be coerced into requested type + >>> assert type(ensure_quantity(length1, "openmm")) == openmm_unit.Quantity + >>> assert type(ensure_quantity(length2, "openff")) == unit.Quantity + >>> # Seemingly-redundant "conversions" short-circuit + >>> assert ensure_quantity(length1, "openff") == ensure_quantity(length2, "openff") + >>> assert ensure_quantity(length1, "openmm") == ensure_quantity(length2, "openmm") + >>> # NumPy arrays and some primitives are automatically up-converted to `Quantity` objects + >>> # Note that their units are set to "dimensionless" + >>> ensure_quantity(numpy.array([1, 2]), "openff") + + >>> ensure_quantity(4.0, "openmm") + 4.0 dimensionless + + """ + if type_to_ensure == "openmm": + return _ensure_openmm_quantity(unknown_quantity) + elif type_to_ensure == "openff": + return _ensure_openff_quantity(unknown_quantity) + else: + raise ValueError(f"Unsupported `type_to_ensure` found. Given {type_to_ensure}, expected 'openff' or 'openmm'.") diff --git a/openff/toolkit/utils/utils.py b/openff/toolkit/utils/utils.py index 5cc7a6a13..48759c364 100644 --- a/openff/toolkit/utils/utils.py +++ b/openff/toolkit/utils/utils.py @@ -32,9 +32,10 @@ import numpy as np import pint from numpy.typing import NDArray -from openff.units import Quantity, Unit from openff.utilities import requires_package +from openff.toolkit.utils.units import Quantity, Unit + if TYPE_CHECKING: from openff.toolkit import ForceField, Molecule @@ -145,7 +146,7 @@ def dict_to_quantity(input_dict): def quantity_to_string(input_quantity: Quantity) -> str: """ - Serialize a ``openff.units.Quantity`` to a string representation that is backwards-compatible + Serialize a ``openff.toolkit.utils.units.Quantity`` to a string representation that is backwards-compatible with older versions of the OpenFF Toolkit. This includes a " * " between numerical values and their units and "A" being used in place of the unicode Å ("\N{ANGSTROM SIGN}"). @@ -174,14 +175,14 @@ def quantity_to_string(input_quantity: Quantity) -> str: @functools.lru_cache def string_to_unit(unit_string) -> Unit: """ - Deserializes a ``openff.units.Quantity`` from a string representation, for + Deserializes a ``openff.toolkit.utils.units.Quantity`` from a string representation, for example: "kilocalories_per_mole / angstrom ** 2" Parameters ---------- unit_string - Serialized representation of a ``openff.units.Quantity``. + Serialized representation of a ``openff.toolkit.utils.units.Quantity``. Returns ------- @@ -227,10 +228,10 @@ def convert_all_strings_to_quantity( ) -> dict: """ Traverses a SMIRNOFF data structure, attempting to convert all - quantity-defining strings into ``openff.units.Quantity`` objects. + quantity-defining strings into ``openff.toolkit.utils.units.Quantity`` objects. Integers and floats are ignored and not converted into a dimensionless - ``openff.units.Quantity`` object. + ``openff.toolkit.utils.units.Quantity`` object. Some good keys to ignore include `ignore_keys=["smirks", "name", "id", "parent_id"]` since these are commonly used in SMIRNOFF force fields in ways that are meant to @@ -249,7 +250,7 @@ def convert_all_strings_to_quantity( ------- converted_smirnoff_data A hierarchical dict structured in compliance with the SMIRNOFF spec, - with quantity-defining strings converted to ``openff.units.Quantity`` objects + with quantity-defining strings converted to ``openff.toolkit.utils.units.Quantity`` objects """ from pint import DefinitionSyntaxError @@ -318,7 +319,7 @@ def convert_all_quantities_to_string( ------- converted_smirnoff_data A hierarchical dict structured in compliance with the SMIRNOFF spec, - with ``openff.units.Quantity``s converted to string + with ``openff.toolkit.utils.units.Quantity``s converted to string """ if isinstance(smirnoff_data, dict): @@ -338,7 +339,7 @@ def convert_all_quantities_to_string( @functools.singledispatch def object_to_quantity(object): """ - Attempts to turn the provided object into `openff.units.Quantity`s. + Attempts to turn the provided object into `openff.toolkit.utils.units.Quantity`s. Can handle float, int, str, `Quantity`, `openmm.unit.Quantity`, or iterators over the same. Raises an exception if unable to convert all inputs. @@ -380,7 +381,8 @@ def _(obj): try: import openmm.unit - from openff.units.openmm import from_openmm + + from openff.toolkit.utils.units import from_openmm @object_to_quantity.register(openmm.unit.Quantity) def _(obj): diff --git a/pyproject.toml b/pyproject.toml index 4c27d15e5..145e8b949 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -43,9 +43,10 @@ lint.per-file-ignores."openff/toolkit/typing/engines/smirnoff.parameters.py" = [ lint.isort.known-first-party = [ "openff.toolkit" ] # can't find a clean way to get Rust's globset to handle this via regex ... # https://docs.astral.sh/ruff/settings/#lint_isort_known-third-party -lint.isort.known-third-party = [ "openff.interchange", "openff.utilities", "openff.units" ] +lint.isort.known-third-party = [ "openff.interchange", "openff.utilities" ] [tool.mypy] +mypy_path = "stubs/" exclude = "openff/toolkit/_tests/|openff/toolkit/data/" python_version = 3.12 # would be nice to flip back to true @@ -65,8 +66,6 @@ module = [ "openeye.oechem", "openff.nagl.*", "openff.nagl_models.*", - "openff.units", - "openff.units.*", "openmm", "openmm.app", "openmm.unit", diff --git a/stubs/openmm/__init__.py b/stubs/openmm/__init__.py new file mode 100644 index 000000000..7ace72902 --- /dev/null +++ b/stubs/openmm/__init__.py @@ -0,0 +1,2 @@ +class System: + pass diff --git a/stubs/openmm/unit/__init__.pyi b/stubs/openmm/unit/__init__.pyi new file mode 100644 index 000000000..640ca1931 --- /dev/null +++ b/stubs/openmm/unit/__init__.pyi @@ -0,0 +1,24 @@ +from collections.abc import Generator + +import numpy +from openmm.unit.baseunit import BaseUnit + +class Quantity: + def __init__(self, *args, **kwargs) -> None: ... + @property + def unit(self) -> Unit: ... + def value_in_unit(self, unit: Unit | str) -> float | int | list | numpy.ndarray: ... + +class Unit: + def __init__(self, name: str) -> None: ... + def iter_base_or_scaled_units( + self, + ) -> Generator[tuple[BaseUnit, int]]: ... + def __mul__(self, other: float | int | list | numpy.ndarray) -> Quantity: ... + def __rmul__(self, other: float | int | list | numpy.ndarray) -> Quantity: ... + +dalton = Unit("dalton") +dimensionless = Unit("dimensionless") +atmosphere = Unit("atmosphere") +angstrom = Unit("angstrom") +angstroms = Unit("angstroms") diff --git a/stubs/openmm/unit/baseunit.pyi b/stubs/openmm/unit/baseunit.pyi new file mode 100644 index 000000000..f75bbf0d5 --- /dev/null +++ b/stubs/openmm/unit/baseunit.pyi @@ -0,0 +1,6 @@ +from typing import Any + +class BaseUnit: + def __init__(self, base_dim: Any, name: str, symbol: str): ... + @property + def name(self) -> str: ... diff --git a/utilities/make_substructure_dict/_cif_to_substructure_dict.py b/utilities/make_substructure_dict/_cif_to_substructure_dict.py index eef1e3454..08ed18c81 100644 --- a/utilities/make_substructure_dict/_cif_to_substructure_dict.py +++ b/utilities/make_substructure_dict/_cif_to_substructure_dict.py @@ -11,7 +11,7 @@ import rdkit from CifFile import ReadCif -from openff.units.elements import SYMBOLS +from openff.toolkit.utils._elements import SYMBOLS from rdkit import Chem from openff.toolkit import Molecule