Coverage for openxps/bounds/base.py: 96%
50 statements
« prev ^ index » next coverage.py v7.11.3, created at 2025-11-13 22:08 +0000
« prev ^ index » next coverage.py v7.11.3, created at 2025-11-13 22:08 +0000
1"""
2Base class for boundary conditions.
4.. module:: openxps.bounds.base
5 :platform: Linux, MacOS, Windows
6 :synopsis: Base class for boundary conditions
8.. classauthor:: Charlles Abreu <craabreu@gmail.com>
10"""
12import typing as t
13from dataclasses import dataclass
15import cvpack
16from cvpack.serialization import Serializable
17from cvpack.units import Quantity
18from openmm import unit as mmunit
20from ..utils import preprocess_args
23@dataclass(frozen=True, eq=False)
24class Bounds(Serializable):
25 """Boundary condition for a dynamical variable.
27 Parameters
28 ----------
29 lower
30 The lower bound for the dynamical variable.
31 upper
32 The upper bound for the dynamical variable.
33 unit
34 The unity of measurement of the bounds. If the bounds do not have a unit, use
35 ``dimensionless``.
36 """
38 lower: float
39 upper: float
40 unit: cvpack.units.Unit
42 def __post_init__(self) -> None:
43 for kind in ("lower", "upper"):
44 if not isinstance(getattr(self, kind), (int, float)):
45 raise TypeError(f"The {kind} bound must be a real number.")
46 if self.lower >= self.upper:
47 raise ValueError("The upper bound must be greater than the lower bound.")
48 if not mmunit.is_unit(self.unit):
49 raise TypeError("The unit must be a valid OpenMM unit.")
50 object.__setattr__(self, "unit", cvpack.units.Unit(self.unit))
51 object.__setattr__(self, "length", self.upper - self.lower)
53 def __getstate__(self) -> dict[str, t.Any]:
54 return {"lower": self.lower, "upper": self.upper, "unit": self.unit}
56 def __setstate__(self, keywords: dict[str, t.Any]) -> None:
57 self.__init__(keywords["lower"], keywords["upper"], keywords["unit"])
59 def __eq__(self, other: t.Any) -> bool:
60 return (
61 isinstance(other, Bounds)
62 and self.lower * self.unit == other.lower * other.unit
63 and self.upper * self.unit == other.upper * other.unit
64 )
66 def __hash__(self) -> int:
67 unit, factor = self._md_unit_and_conversion_factor()
68 return hash((self.lower * factor, self.upper * factor, unit))
70 def _md_unit_and_conversion_factor(self) -> tuple[mmunit.Unit, float]:
71 """
72 Return the MD unit and conversion factor for the bounds.
73 """
74 unit = self.unit.in_unit_system(mmunit.md_unit_system)
75 factor = self.unit.conversion_factor_to(unit)
76 return unit, factor
78 def in_md_units(self) -> "Bounds":
79 """
80 Return the bounds in the MD unit system.
82 Example
83 -------
84 >>> import openxps as xps
85 >>> from openmm import unit
86 >>> bounds = xps.bounds.PeriodicBounds(-1.0, 1.0, unit.kilocalories_per_mole)
87 >>> bounds.in_md_units()
88 PeriodicBounds(lower=-4.184, upper=4.184, unit=nm**2 Da/(ps**2))
89 """
90 unit, factor = self._md_unit_and_conversion_factor()
91 return self.__class__(self.lower * factor, self.upper * factor, unit)
93 def convert(self, unit: mmunit.Unit) -> "Bounds":
94 """
95 Convert the bounds to a different unit of measurement.
97 Parameters
98 ----------
99 unit
100 The new unit of measurement for the bounds.
102 Returns
103 -------
104 Bounds
105 The bounds in the new unit of measurement.
107 Example
108 -------
109 >>> import openxps as xps
110 >>> from openmm import unit
111 >>> bounds = xps.bounds.PeriodicBounds(-180, 180, unit.degree)
112 >>> bounds.convert(unit.radian)
113 PeriodicBounds(lower=-3.14159..., upper=3.14159..., unit=rad)
114 """
115 factor = 1.0 * self.unit / unit
116 if not isinstance(factor, float):
117 raise ValueError("The unit must be compatible with the bounds unit.")
118 return self.__class__(factor * self.lower, factor * self.upper, unit)
120 def asQuantity(self) -> mmunit.Quantity:
121 """
122 Return the bounds as a Quantity object.
124 Returns
125 -------
126 Quantity
127 The bounds as a Quantity object.
129 Example
130 -------
131 >>> import openxps as xps
132 >>> from openmm import unit
133 >>> bounds = xps.bounds.PeriodicBounds(-180, 180, unit.degree)
134 >>> bounds.asQuantity()
135 (-180, 180) deg
136 """
137 return Quantity((self.lower, self.upper), self.unit)
139 def leptonExpression(self, variable: str) -> str:
140 """
141 Return a lepton expression representing the transformation from an unwrapped
142 variable to a wrapped value under the boundary conditions.
144 Parameters
145 ----------
146 variable
147 The name of the variable in the expression.
148 Returns
149 -------
150 str
151 A string representing the transformation.
153 Example
154 -------
155 >>> import openxps as xps
156 >>> from openmm import unit
157 >>> periodic = xps.bounds.PeriodicBounds(-180, 180, unit.degree)
158 >>> print(periodic.leptonExpression("x"))
159 (scaled_x-floor(scaled_x))*360-180;
160 scaled_x=(x+180)/360
161 >>> reflective = xps.bounds.ReflectiveBounds(1, 10, unit.angstrom)
162 >>> print(reflective.leptonExpression("y"))
163 min(wrapped_y,1-wrapped_y)*18+1;
164 wrapped_y=scaled_y-floor(scaled_y);
165 scaled_y=(y-1)/18
166 """
167 raise NotImplementedError(
168 "The method transformation must be implemented in subclasses."
169 )
171 def wrap(self, value: float, rate: float) -> tuple[float, float]:
172 """
173 Wrap a value around the bounds and adjust its rate of change.
175 Parameters
176 ----------
177 value
178 The unwrapped value, in the same unit of measurement as the bounds.
179 rate
180 The rate of change of the unwrapped value, in the same unit of measurement
181 as the bounds divided by a time unit.
183 Returns
184 -------
185 float
186 The wrapped value, in the same unit of measurement as the bounds.
187 float
188 The adjusted rate of change of the wrapped value, in the same unit of
189 measurement as the original rate.
190 """
191 raise NotImplementedError("The method wrap must be implemented in subclasses.")
194Bounds.__init__ = preprocess_args(Bounds.__init__)