hillclimber 0.1.0a1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of hillclimber might be problematic. Click here for more details.

@@ -0,0 +1,92 @@
1
+ import pathlib
2
+ from typing import Protocol
3
+
4
+ import ase
5
+ from ase.calculators.calculator import Calculator
6
+ from PIL import Image
7
+
8
+
9
+ class NodeWithCalculator(Protocol):
10
+ """Any class with a `get_calculator` method returning an ASE Calculator."""
11
+
12
+ def get_calculator(
13
+ self, *, directory: str | pathlib.Path | None = None, **kwargs
14
+ ) -> Calculator: ...
15
+
16
+
17
+ class AtomSelector(Protocol):
18
+ """Protocol for selecting atoms within a single ASE Atoms object.
19
+
20
+ This interface defines the contract for selecting atoms based on various
21
+ criteria within an individual frame/structure.
22
+ """
23
+
24
+ def select(self, atoms: ase.Atoms) -> list[list[int]]:
25
+ """Select atoms based on the implemented criteria.
26
+
27
+ Parameters
28
+ ----------
29
+ atoms : ase.Atoms
30
+ The atomic structure to select from.
31
+
32
+ Returns
33
+ -------
34
+ list[list[int]]
35
+ Groups of indices of selected atoms. All indices in the inner lists
36
+ are representative of the same group, e.g. one molecule.
37
+ """
38
+ ...
39
+
40
+
41
+ class PlumedGenerator(Protocol):
42
+ """Protocol for generating PLUMED strings from collective variables."""
43
+
44
+ def to_plumed(self, atoms: ase.Atoms) -> list[str]: ...
45
+
46
+
47
+ class CollectiveVariable(Protocol):
48
+ """Protocol for collective variables (CVs) that can be used in PLUMED."""
49
+
50
+ prefix: str
51
+
52
+ def get_img(self, atoms: ase.Atoms) -> Image.Image: ...
53
+
54
+ def to_plumed(self, atoms: ase.Atoms) -> tuple[list[str], list[str]]:
55
+ """
56
+ Convert the collective variable to a PLUMED string.
57
+
58
+ Parameters
59
+ ----------
60
+ atoms : ase.Atoms
61
+ The atomic structure to use for generating the PLUMED string.
62
+
63
+ Returns
64
+ -------
65
+ tuple[list[str], str]
66
+ - List of distance labels.
67
+ - list of PLUMED strings representing the CV.
68
+ """
69
+ ...
70
+
71
+
72
+ class MetadynamicsBiasCollectiveVariable(Protocol):
73
+ """Protocol for metadata associated with a bias in PLUMED."""
74
+
75
+ cv: CollectiveVariable
76
+ sigma: float | None = None
77
+ grid_min: float | None = None
78
+ grid_max: float | None = None
79
+ grid_bin: int | None = None
80
+
81
+
82
+ __all__ = [
83
+ "NodeWithCalculator",
84
+ "AtomSelector",
85
+ "PlumedGenerator",
86
+ "CollectiveVariable",
87
+ "MetadynamicsBiasCollectiveVariable",
88
+ ]
89
+
90
+ def interfaces() -> dict[str, list[str]]:
91
+ """Return a dictionary of available interfaces."""
92
+ return {"plumed-nodes": __all__}
@@ -0,0 +1,249 @@
1
+ import dataclasses
2
+ from pathlib import Path
3
+
4
+ import ase.units
5
+ import zntrack
6
+
7
+ from hillclimber.calc import NonOverwritingPlumed
8
+ from hillclimber.interfaces import (
9
+ CollectiveVariable,
10
+ MetadynamicsBiasCollectiveVariable,
11
+ NodeWithCalculator,
12
+ PlumedGenerator,
13
+ )
14
+
15
+
16
+ @dataclasses.dataclass
17
+ class MetaDBiasCV(MetadynamicsBiasCollectiveVariable):
18
+ """Metadynamics bias on a collective variable.
19
+
20
+ Parameters
21
+ ----------
22
+ cv : CollectiveVariable
23
+ The collective variable to bias.
24
+ sigma : float, optional
25
+ The width of the Gaussian potential, by default None.
26
+ grid_min : float | str, optional
27
+ The minimum value of the grid, by default None.
28
+ grid_max : float | str, optional
29
+ The maximum value of the grid, by default None.
30
+ grid_bin : int, optional
31
+ The number of bins in the grid, by default None.
32
+
33
+ Resources
34
+ ---------
35
+ - https://www.plumed.org/doc-master/user-doc/html/METAD/
36
+ """
37
+
38
+ cv: CollectiveVariable
39
+ sigma: float | None = None
40
+ grid_min: float | str | None = None
41
+ grid_max: float | str | None = None
42
+ grid_bin: int | None = None
43
+
44
+
45
+ @dataclasses.dataclass
46
+ class MetaDynamicsConfig:
47
+ """Base configuration for metadynamics.
48
+
49
+ This contains only the global parameters that apply to all CVs.
50
+
51
+ Parameters
52
+ ----------
53
+ height : float, optional
54
+ The height of the Gaussian potential in kJ/mol, by default 1.0.
55
+ pace : int, optional
56
+ The frequency of Gaussian deposition, by default 500.
57
+ biasfactor : float, optional
58
+ The bias factor for well-tempered metadynamics, by default None.
59
+ temp : float, optional
60
+ The temperature of the system in Kelvin, by default 300.0.
61
+ file : str, optional
62
+ The name of the hills file, by default "HILLS".
63
+ adaptive : str, optional
64
+ The adaptive scheme to use, by default "NONE".
65
+ flush : int | None
66
+ The frequency of flushing the output files.
67
+ If None, uses the plumed default.
68
+
69
+ Resources
70
+ ---------
71
+ - https://www.plumed.org/doc-master/user-doc/html/METAD/
72
+ - https://www.plumed.org/doc-master/user-doc/html/FLUSH/
73
+ """
74
+
75
+ height: float = 1.0 # kJ/mol
76
+ pace: int = 500
77
+ biasfactor: float | None = None
78
+ temp: float = 300.0
79
+ file: str = "HILLS"
80
+ adaptive: str = "NONE" # NONE, DIFF, GEOM
81
+ flush: int | None = None
82
+
83
+
84
+ # THERE SEEMS to be an issue with MetaDynamicsModel changing but ASEMD not updating?!?!
85
+
86
+
87
+ class MetaDynamicsModel(zntrack.Node, NodeWithCalculator):
88
+ """A metadynamics model.
89
+
90
+ Parameters
91
+ ----------
92
+ config : MetaDynamicsConfig
93
+ The configuration for the metadynamics simulation.
94
+ data : list[ase.Atoms]
95
+ The input data for the simulation.
96
+ data_idx : int, optional
97
+ The index of the data to use, by default -1.
98
+ bias_cvs : list[MetaDBiasCV], optional
99
+ The collective variables to bias, by default [].
100
+ actions : list[PlumedGenerator], optional
101
+ A list of actions to perform during the simulation, by default [].
102
+ timestep : float, optional
103
+ The timestep of the simulation in fs, by default 1.0.
104
+ model : NodeWithCalculator
105
+ The model to use for the simulation.
106
+
107
+ Example
108
+ -------
109
+ >>> import hillclimber as pn
110
+ >>> import ipsuite as ips
111
+ >>>
112
+ >>> data = ips.AddData("seed.xyz")
113
+ >>> cv1 = pn.DistanceCV(
114
+ ... x1=pn.SMARTSSelector(pattern="[H]O[H]"),
115
+ ... x2=pn.SMARTSSelector(pattern="CO[C:1]"),
116
+ ... prefix="d",
117
+ ... )
118
+ >>> metad_cv1 = pn.MetaDBiasCV(
119
+ ... cv=cv1, sigma=0.1, grid_min=0.0, grid_max=2.0, grid_bin=200
120
+ ... )
121
+ >>> model = pn.MetaDynamicsModel(
122
+ ... config=pn.MetaDynamicsConfig(height=0.25, temp=300, pace=2000, biasfactor=10),
123
+ ... bias_cvs=[metad_cv1],
124
+ ... data=data.frames,
125
+ ... model=ips.MACEMP(),
126
+ ... timestep=0.5
127
+ ... )
128
+ >>> md = ips.ASEMD(
129
+ ... model=model, data=data.frames, ...
130
+ ... )
131
+ """
132
+
133
+ config: MetaDynamicsConfig = zntrack.deps()
134
+ data: list[ase.Atoms] = zntrack.deps()
135
+ data_idx: int = zntrack.params(-1)
136
+ bias_cvs: list[MetaDBiasCV] = zntrack.deps(default_factory=list)
137
+ actions: list[PlumedGenerator] = zntrack.deps(default_factory=list)
138
+ timestep: float = zntrack.params(1.0) # in fs, default is 1 fs
139
+ model: NodeWithCalculator = zntrack.deps()
140
+
141
+ figures: Path = zntrack.outs_path(zntrack.nwd / "figures", independent=True)
142
+
143
+ def run(self):
144
+ self.figures.mkdir(parents=True, exist_ok=True)
145
+ for cv in self.bias_cvs:
146
+ img = cv.cv.get_img(self.data[self.data_idx])
147
+ img.save(self.figures / f"{cv.cv.prefix}.png")
148
+
149
+ def get_calculator(
150
+ self, *, directory: str | Path | None = None, **kwargs
151
+ ) -> NonOverwritingPlumed:
152
+ if directory is None:
153
+ raise ValueError("Directory must be specified for PLUMED input files.")
154
+ directory = Path(directory)
155
+ directory.mkdir(parents=True, exist_ok=True)
156
+
157
+ lines = self.to_plumed(self.data[self.data_idx])
158
+ # replace FILE= with f"FILE={directory}/" inside config
159
+ lines = [line.replace("FILE=", f"FILE={directory}/") for line in lines]
160
+
161
+ # Write plumed input file
162
+ with (directory / "plumed.dat").open("w") as file:
163
+ for line in lines:
164
+ file.write(line + "\n")
165
+
166
+ kT = ase.units.kB * self.config.temp
167
+
168
+ return NonOverwritingPlumed(
169
+ calc=self.model.get_calculator(directory=directory),
170
+ atoms=self.data[self.data_idx],
171
+ input=lines,
172
+ timestep=float(self.timestep * ase.units.fs),
173
+ kT=float(kT),
174
+ log=(directory / "plumed.log").as_posix(),
175
+ )
176
+
177
+ def to_plumed(self, atoms: ase.Atoms) -> list[str]:
178
+ """Generate PLUMED input string for the metadynamics model."""
179
+ # check for duplicate CV prefixes
180
+ cv_labels = set()
181
+ for bias_cv in self.bias_cvs:
182
+ if bias_cv.cv.prefix in cv_labels:
183
+ raise ValueError(f"Duplicate CV prefix found: {bias_cv.cv.prefix}")
184
+ cv_labels.add(bias_cv.cv.prefix)
185
+
186
+ plumed_lines = []
187
+ all_labels = []
188
+
189
+ sigmas, grid_mins, grid_maxs, grid_bins = [], [], [], []
190
+
191
+ plumed_lines.append(
192
+ f"UNITS LENGTH=A TIME={1 / (1000 * ase.units.fs)} ENERGY={ase.units.mol / ase.units.kJ}"
193
+ )
194
+
195
+ for bias_cv in self.bias_cvs:
196
+ labels, cv_str = bias_cv.cv.to_plumed(atoms)
197
+ plumed_lines.extend(cv_str)
198
+ all_labels.extend(labels)
199
+
200
+ # Collect per-CV parameters for later
201
+ sigmas.append(str(bias_cv.sigma) if bias_cv.sigma is not None else None)
202
+ grid_mins.append(
203
+ str(bias_cv.grid_min) if bias_cv.grid_min is not None else None
204
+ )
205
+ grid_maxs.append(
206
+ str(bias_cv.grid_max) if bias_cv.grid_max is not None else None
207
+ )
208
+ grid_bins.append(
209
+ str(bias_cv.grid_bin) if bias_cv.grid_bin is not None else None
210
+ )
211
+
212
+ metad_parts = [
213
+ "METAD",
214
+ f"ARG={','.join(all_labels)}",
215
+ f"HEIGHT={self.config.height}",
216
+ f"PACE={self.config.pace}",
217
+ f"TEMP={self.config.temp}",
218
+ f"FILE={self.config.file}",
219
+ f"ADAPTIVE={self.config.adaptive}",
220
+ ]
221
+ if self.config.biasfactor is not None:
222
+ metad_parts.append(f"BIASFACTOR={self.config.biasfactor}")
223
+
224
+ # Add SIGMA, GRID_MIN, GRID_MAX, GRID_BIN only if any value is set
225
+ if any(v is not None for v in sigmas):
226
+ metad_parts.append(
227
+ f"SIGMA={','.join(v if v is not None else '0.0' for v in sigmas)}"
228
+ )
229
+ if any(v is not None for v in grid_mins):
230
+ metad_parts.append(
231
+ f"GRID_MIN={','.join(v if v is not None else '0.0' for v in grid_mins)}"
232
+ )
233
+ if any(v is not None for v in grid_maxs):
234
+ metad_parts.append(
235
+ f"GRID_MAX={','.join(v if v is not None else '0.0' for v in grid_maxs)}"
236
+ )
237
+ if any(v is not None for v in grid_bins):
238
+ metad_parts.append(
239
+ f"GRID_BIN={','.join(v if v is not None else '0' for v in grid_bins)}"
240
+ )
241
+
242
+ plumed_lines.append(f"metad: {' '.join(metad_parts)}")
243
+ # Temporary until https://github.com/zincware/ZnTrack/issues/936
244
+ from hillclimber.actions import PrintCVAction
245
+ lines = PrintCVAction(cvs=[x.cv for x in self.bias_cvs], stride=100).to_plumed(atoms)
246
+ plumed_lines.extend(lines)
247
+ if self.config.flush is not None:
248
+ plumed_lines.append(f"FLUSH STRIDE={self.config.flush}")
249
+ return plumed_lines
hillclimber/nodes.py ADDED
@@ -0,0 +1,6 @@
1
+ from hillclimber import __all__
2
+
3
+
4
+ def nodes() -> dict[str, list[str]]:
5
+ """Return all available nodes."""
6
+ return {"hillclimber": __all__}
@@ -0,0 +1,96 @@
1
+ import dataclasses
2
+ import typing as tp
3
+
4
+ import ase
5
+ import rdkit2ase
6
+
7
+ from hillclimber.interfaces import AtomSelector
8
+
9
+
10
+ @dataclasses.dataclass
11
+ class IndexSelector(AtomSelector):
12
+ """Select atoms based on grouped indices.
13
+
14
+ Parameters
15
+ ----------
16
+ indices : list[list[int]]
17
+ A list of atom index groups to select. Each inner list represents
18
+ a group of atoms (e.g., a molecule). For example:
19
+ - [[0, 1], [2, 3]] selects two groups: atoms [0,1] and atoms [2,3]
20
+ - [[0], [1]] selects two single-atom groups
21
+ """
22
+
23
+ # mostly used for debugging
24
+ indices: list[list[int]]
25
+
26
+ def select(self, atoms: ase.Atoms) -> list[list[int]]:
27
+ return self.indices
28
+
29
+
30
+ @dataclasses.dataclass
31
+ class SMILESSelector(AtomSelector):
32
+ """Select atoms based on a SMILES string.
33
+
34
+ Parameters
35
+ ----------
36
+ smiles : str
37
+ The SMILES string to use for selection.
38
+ """
39
+
40
+ smiles: str
41
+
42
+ def select(self, atoms: ase.Atoms) -> list[list[int]]:
43
+ matches = rdkit2ase.match_substructure(atoms, smiles=self.smiles)
44
+ return [list(match) for match in matches]
45
+
46
+
47
+ @dataclasses.dataclass
48
+ class SMARTSSelector(AtomSelector):
49
+ """Select atoms based on SMARTS or mapped SMILES patterns.
50
+
51
+ This selector uses RDKit's substructure matching to identify atoms
52
+ matching a given SMARTS pattern or mapped SMILES string. It supports
53
+ flexible hydrogen handling and can work with mapped atoms for
54
+ precise selection.
55
+
56
+ Note
57
+ ----
58
+ The selector is applied only to the first trajectory frame.
59
+ Since indices can change during e.g. proton transfer, biasing specific groups (e.g. `[OH-]`) may fail.
60
+ In such cases, select all `[OH2]` and `[OH-]` groups and use CoordinationNumber CVs.
61
+ Account for this method with all changes in chemical structure.
62
+
63
+ Parameters
64
+ ----------
65
+ pattern : str
66
+ SMARTS pattern (e.g., "[F]", "[OH]", "C(=O)O") or SMILES with
67
+ atom maps (e.g., "C1[C:1]OC(=[O:1])O1"). If atom maps are present,
68
+ only the mapped atoms are selected.
69
+ hydrogens : {'exclude', 'include', 'isolated'}, default='exclude'
70
+ How to handle hydrogen atoms in the selection:
71
+ - 'exclude': Remove all hydrogens from the selection
72
+ - 'include': Include hydrogens bonded to selected heavy atoms
73
+ - 'isolated': Select only hydrogens bonded to selected heavy atoms
74
+
75
+ Examples
76
+ --------
77
+ >>> # Select all fluorine atoms
78
+ >>> selector = SMARTSSelection(pattern="[F]")
79
+
80
+ >>> # Select carboxylic acid groups including hydrogens
81
+ >>> selector = SMARTSSelection(pattern="C(=O)O", hydrogens="include")
82
+
83
+ >>> # Select only specific mapped atoms
84
+ >>> selector = SMARTSSelection(pattern="C1[C:1]OC(=[O:1])O1")
85
+
86
+ >>> # Select 4 elements in order to define an angle
87
+ >>> selector = SMARTSSelection(pattern="CC(=O)N[C:1]([C:2])[C:3](=O)[N:4]C")
88
+ """
89
+
90
+ pattern: str
91
+ hydrogens: tp.Literal["include", "exclude", "isolated"] = "exclude"
92
+
93
+ def select(self, atoms: ase.Atoms) -> list[list[int]]:
94
+ return rdkit2ase.select_atoms_grouped(
95
+ rdkit2ase.ase2rdkit(atoms), self.pattern, self.hydrogens
96
+ )