hillclimber 0.1.0a1__py3-none-any.whl → 0.1.0a2__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.

hillclimber/interfaces.py CHANGED
@@ -69,13 +69,52 @@ class CollectiveVariable(Protocol):
69
69
  ...
70
70
 
71
71
 
72
- class MetadynamicsBiasCollectiveVariable(Protocol):
73
- """Protocol for metadata associated with a bias in PLUMED."""
72
+ class BiasProtocol(Protocol):
73
+ """Protocol for individual bias potentials that act on collective variables.
74
+
75
+ Individual bias potentials (restraints, walls) operate on collective variables
76
+ and generate their own PLUMED commands via to_plumed(). These biases are added
77
+ to MetaDynamicsModel via the `actions` parameter.
78
+
79
+ This is distinct from MetadynamicsBias, which is used for configuring the
80
+ collective METAD command itself (via the `bias_cvs` parameter).
81
+ """
82
+
83
+ cv: CollectiveVariable
84
+
85
+ def to_plumed(self, atoms: ase.Atoms) -> list[str]:
86
+ """Generate PLUMED input strings for this bias.
87
+
88
+ Parameters
89
+ ----------
90
+ atoms : ase.Atoms
91
+ The atomic structure to use for generating the PLUMED string.
92
+
93
+ Returns
94
+ -------
95
+ list[str]
96
+ List of PLUMED command strings.
97
+ """
98
+ ...
99
+
100
+
101
+ class MetadynamicsBias(Protocol):
102
+ """Protocol for metadynamics bias configuration objects.
103
+
104
+ This protocol defines the configuration structure for per-CV metadynamics
105
+ parameters (sigma, grid settings, etc.). These configuration objects are
106
+ used in the `bias_cvs` parameter of MetaDynamicsModel to build the collective
107
+ METAD command.
108
+
109
+ This is distinct from BiasProtocol, which is for individual bias potentials
110
+ (restraints, walls) that generate their own PLUMED commands and are added
111
+ via the `actions` parameter.
112
+ """
74
113
 
75
114
  cv: CollectiveVariable
76
115
  sigma: float | None = None
77
- grid_min: float | None = None
78
- grid_max: float | None = None
116
+ grid_min: float | str | None = None
117
+ grid_max: float | str | None = None
79
118
  grid_bin: int | None = None
80
119
 
81
120
 
@@ -84,7 +123,8 @@ __all__ = [
84
123
  "AtomSelector",
85
124
  "PlumedGenerator",
86
125
  "CollectiveVariable",
87
- "MetadynamicsBiasCollectiveVariable",
126
+ "BiasProtocol",
127
+ "MetadynamicsBias",
88
128
  ]
89
129
 
90
130
  def interfaces() -> dict[str, list[str]]:
@@ -7,15 +7,14 @@ import zntrack
7
7
  from hillclimber.calc import NonOverwritingPlumed
8
8
  from hillclimber.interfaces import (
9
9
  CollectiveVariable,
10
- MetadynamicsBiasCollectiveVariable,
11
10
  NodeWithCalculator,
12
11
  PlumedGenerator,
13
12
  )
14
13
 
15
14
 
16
15
  @dataclasses.dataclass
17
- class MetaDBiasCV(MetadynamicsBiasCollectiveVariable):
18
- """Metadynamics bias on a collective variable.
16
+ class MetadBias:
17
+ """Metadynamics bias configuration for a collective variable.
19
18
 
20
19
  Parameters
21
20
  ----------
@@ -115,7 +114,7 @@ class MetaDynamicsModel(zntrack.Node, NodeWithCalculator):
115
114
  ... x2=pn.SMARTSSelector(pattern="CO[C:1]"),
116
115
  ... prefix="d",
117
116
  ... )
118
- >>> metad_cv1 = pn.MetaDBiasCV(
117
+ >>> metad_cv1 = pn.MetadBias(
119
118
  ... cv=cv1, sigma=0.1, grid_min=0.0, grid_max=2.0, grid_bin=200
120
119
  ... )
121
120
  >>> model = pn.MetaDynamicsModel(
@@ -133,7 +132,7 @@ class MetaDynamicsModel(zntrack.Node, NodeWithCalculator):
133
132
  config: MetaDynamicsConfig = zntrack.deps()
134
133
  data: list[ase.Atoms] = zntrack.deps()
135
134
  data_idx: int = zntrack.params(-1)
136
- bias_cvs: list[MetaDBiasCV] = zntrack.deps(default_factory=list)
135
+ bias_cvs: list[MetadBias] = zntrack.deps(default_factory=list)
137
136
  actions: list[PlumedGenerator] = zntrack.deps(default_factory=list)
138
137
  timestep: float = zntrack.params(1.0) # in fs, default is 1 fs
139
138
  model: NodeWithCalculator = zntrack.deps()
@@ -240,10 +239,13 @@ class MetaDynamicsModel(zntrack.Node, NodeWithCalculator):
240
239
  )
241
240
 
242
241
  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)
242
+
243
+ # Add any additional actions (restraints, walls, print actions, etc.)
244
+ for action in self.actions:
245
+ action_lines = action.to_plumed(atoms)
246
+ plumed_lines.extend(action_lines)
247
+
248
+ # Add FLUSH if configured
247
249
  if self.config.flush is not None:
248
250
  plumed_lines.append(f"FLUSH STRIDE={self.config.flush}")
249
251
  return plumed_lines
hillclimber/opes.py ADDED
@@ -0,0 +1,342 @@
1
+ """OPES (On-the-fly Probability Enhanced Sampling) methods.
2
+
3
+ This module provides classes for OPES enhanced sampling, a modern alternative
4
+ to traditional metadynamics with improved convergence properties.
5
+ """
6
+
7
+ import dataclasses
8
+ from pathlib import Path
9
+
10
+ import ase.units
11
+ import zntrack
12
+
13
+ from hillclimber.calc import NonOverwritingPlumed
14
+ from hillclimber.interfaces import (
15
+ CollectiveVariable,
16
+ NodeWithCalculator,
17
+ PlumedGenerator,
18
+ )
19
+
20
+
21
+ @dataclasses.dataclass
22
+ class OPESBias:
23
+ """OPES bias configuration for a collective variable.
24
+
25
+ Parameters
26
+ ----------
27
+ cv : CollectiveVariable
28
+ The collective variable to bias.
29
+ sigma : float | str, optional
30
+ Initial kernel width. Use "ADAPTIVE" for automatic adaptation
31
+ (recommended). If numeric, specifies the initial width.
32
+ Default: "ADAPTIVE".
33
+
34
+ Resources
35
+ ---------
36
+ - https://www.plumed.org/doc-master/user-doc/html/OPES_METAD/
37
+
38
+ Notes
39
+ -----
40
+ The ADAPTIVE sigma option automatically adjusts kernel widths based on
41
+ CV fluctuations, which is usually the best choice. The automatic width
42
+ is measured every ADAPTIVE_SIGMA_STRIDE steps (default: 10×PACE).
43
+ """
44
+
45
+ cv: CollectiveVariable
46
+ sigma: float | str = "ADAPTIVE"
47
+
48
+
49
+ @dataclasses.dataclass
50
+ class OPESConfig:
51
+ """Configuration for OPES_METAD and OPES_METAD_EXPLORE.
52
+
53
+ OPES (On-the-fly Probability Enhanced Sampling) is a modern enhanced
54
+ sampling method that samples well-tempered target distributions.
55
+
56
+ Parameters
57
+ ----------
58
+ barrier : float
59
+ Highest free energy barrier to overcome (kJ/mol). This is the key
60
+ parameter that determines sampling efficiency.
61
+ pace : int, optional
62
+ Frequency of kernel deposition in MD steps (default: 500).
63
+ temp : float, optional
64
+ Temperature in Kelvin (default: 300.0). If -1, retrieved from MD engine.
65
+ explore_mode : bool, optional
66
+ If True, uses OPES_METAD_EXPLORE which estimates target distribution
67
+ directly (better exploration, slower reweighting convergence).
68
+ If False, uses OPES_METAD which estimates unbiased distribution
69
+ (faster convergence, less exploration). Default: False.
70
+ biasfactor : float, optional
71
+ Well-tempered gamma factor. If not specified, uses default behavior.
72
+ Set to inf for custom target distributions.
73
+ compression_threshold : float, optional
74
+ Merge kernels if closer than this threshold in sigma units (default: 1.0).
75
+ file : str, optional
76
+ File to store deposited kernels (default: "KERNELS").
77
+ adaptive_sigma_stride : int, optional
78
+ Steps between adaptive sigma measurements. If not set, uses 10×PACE.
79
+ sigma_min : float, optional
80
+ Minimum allowable sigma value for adaptive sigma.
81
+ state_wfile : str, optional
82
+ State file for writing exact restart information.
83
+ state_rfile : str, optional
84
+ State file for reading restart information.
85
+ state_wstride : int, optional
86
+ Frequency of STATE file writing in number of kernel depositions.
87
+ walkers_mpi : bool, optional
88
+ Enable multiple walker mode with MPI communication (default: False).
89
+ calc_work : bool, optional
90
+ Calculate and output accumulated work (default: False).
91
+ flush : int, optional
92
+ Frequency of flushing output files.
93
+
94
+ Resources
95
+ ---------
96
+ - https://www.plumed.org/doc-master/user-doc/html/OPES_METAD/
97
+ - https://www.plumed.org/doc-master/user-doc/html/OPES_METAD_EXPLORE/
98
+ - Invernizzi & Parrinello, J. Phys. Chem. Lett. 2020
99
+
100
+ Notes
101
+ -----
102
+ **When to use OPES_METAD vs OPES_METAD_EXPLORE:**
103
+
104
+ - OPES_METAD: Use when you want quick convergence of reweighted free energy.
105
+ Estimates unbiased distribution P(s).
106
+
107
+ - OPES_METAD_EXPLORE: Use for systems with unknown barriers or when testing
108
+ new CVs. Allows more exploration but slower reweighting convergence.
109
+ Estimates target distribution p^WT(s) directly.
110
+
111
+ Both methods converge to the same bias given enough time, but approach it
112
+ differently. OPES is more sensitive to degenerate CVs than standard METAD.
113
+ """
114
+
115
+ barrier: float # kJ/mol
116
+ pace: int = 500
117
+ temp: float = 300.0
118
+ explore_mode: bool = False # False=OPES_METAD, True=OPES_METAD_EXPLORE
119
+ biasfactor: float | None = None
120
+ compression_threshold: float = 1.0
121
+ file: str = "KERNELS"
122
+ adaptive_sigma_stride: int | None = None
123
+ sigma_min: float | None = None
124
+ state_wfile: str | None = None
125
+ state_rfile: str | None = None
126
+ state_wstride: int | None = None
127
+ walkers_mpi: bool = False
128
+ calc_work: bool = False
129
+ flush: int | None = None
130
+
131
+
132
+ class OPESModel(zntrack.Node, NodeWithCalculator):
133
+ """OPES (On-the-fly Probability Enhanced Sampling) model.
134
+
135
+ Implements OPES_METAD and OPES_METAD_EXPLORE enhanced sampling methods.
136
+ OPES samples well-tempered target distributions and provides better
137
+ convergence properties than traditional metadynamics.
138
+
139
+ Parameters
140
+ ----------
141
+ config : OPESConfig
142
+ Configuration for the OPES simulation.
143
+ data : list[ase.Atoms]
144
+ Input data for simulation.
145
+ data_idx : int, optional
146
+ Index of data to use (default: -1).
147
+ bias_cvs : list[OPESBias], optional
148
+ Collective variables to bias (default: []).
149
+ actions : list[PlumedGenerator], optional
150
+ Additional actions like restraints, walls, print (default: []).
151
+ timestep : float, optional
152
+ Timestep in fs (default: 1.0).
153
+ model : NodeWithCalculator
154
+ Underlying force field model.
155
+
156
+ Examples
157
+ --------
158
+ >>> import hillclimber as hc
159
+ >>>
160
+ >>> # Define collective variables
161
+ >>> phi = hc.TorsionCV(...)
162
+ >>> psi = hc.TorsionCV(...)
163
+ >>>
164
+ >>> # OPES configuration (standard mode)
165
+ >>> config = hc.OPESConfig(
166
+ ... barrier=40.0, # kJ/mol
167
+ ... pace=500,
168
+ ... temp=300.0,
169
+ ... explore_mode=False # Use OPES_METAD
170
+ ... )
171
+ >>>
172
+ >>> # Bias configuration with adaptive sigma
173
+ >>> bias1 = hc.OPESBias(cv=phi, sigma="ADAPTIVE")
174
+ >>> bias2 = hc.OPESBias(cv=psi, sigma="ADAPTIVE")
175
+ >>>
176
+ >>> # Create OPES model
177
+ >>> opes = hc.OPESModel(
178
+ ... config=config,
179
+ ... bias_cvs=[bias1, bias2],
180
+ ... data=data.frames,
181
+ ... model=force_field,
182
+ ... timestep=0.5
183
+ ... )
184
+ >>>
185
+ >>> # For exploration mode, set explore_mode=True
186
+ >>> explore_config = hc.OPESConfig(
187
+ ... barrier=40.0,
188
+ ... pace=500,
189
+ ... explore_mode=True # Use OPES_METAD_EXPLORE
190
+ ... )
191
+
192
+ Resources
193
+ ---------
194
+ - https://www.plumed.org/doc-master/user-doc/html/OPES_METAD/
195
+ - https://www.plumed.org/doc-master/user-doc/html/OPES_METAD_EXPLORE/
196
+ - https://www.plumed.org/doc-master/user-doc/html/masterclass-22-03.html
197
+ - Invernizzi & Parrinello, J. Phys. Chem. Lett. 2020
198
+
199
+ Notes
200
+ -----
201
+ **Output Components:**
202
+ OPES provides several diagnostic outputs (accessible via PrintAction):
203
+ - opes.bias: Instantaneous bias potential value
204
+ - opes.rct: Convergence indicator (should flatten at convergence)
205
+ - opes.zed: Normalization estimate (should stabilize)
206
+ - opes.neff: Effective sample size
207
+ - opes.nker: Number of compressed kernels
208
+ - opes.work: Accumulated work (if calc_work=True)
209
+
210
+ **Advantages over Metadynamics:**
211
+ - Better convergence properties
212
+ - Automatic variance adaptation
213
+ - Lower systematic error
214
+ - More sensitive to degenerate CVs (helps identify CV problems)
215
+ """
216
+
217
+ config: OPESConfig = zntrack.deps()
218
+ data: list[ase.Atoms] = zntrack.deps()
219
+ data_idx: int = zntrack.params(-1)
220
+ bias_cvs: list[OPESBias] = zntrack.deps(default_factory=list)
221
+ actions: list[PlumedGenerator] = zntrack.deps(default_factory=list)
222
+ timestep: float = zntrack.params(1.0)
223
+ model: NodeWithCalculator = zntrack.deps()
224
+
225
+ figures: Path = zntrack.outs_path(zntrack.nwd / "figures", independent=True)
226
+
227
+ def run(self):
228
+ self.figures.mkdir(parents=True, exist_ok=True)
229
+ for cv in self.bias_cvs:
230
+ img = cv.cv.get_img(self.data[self.data_idx])
231
+ img.save(self.figures / f"{cv.cv.prefix}.png")
232
+
233
+ def get_calculator(
234
+ self, *, directory: str | Path | None = None, **kwargs
235
+ ) -> NonOverwritingPlumed:
236
+ if directory is None:
237
+ raise ValueError("Directory must be specified for PLUMED input files.")
238
+ directory = Path(directory)
239
+ directory.mkdir(parents=True, exist_ok=True)
240
+
241
+ lines = self.to_plumed(self.data[self.data_idx])
242
+ # replace FILE= with f"FILE={directory}/" inside config
243
+ lines = [line.replace("FILE=", f"FILE={directory}/") for line in lines]
244
+
245
+ # Write plumed input file
246
+ with (directory / "plumed.dat").open("w") as file:
247
+ for line in lines:
248
+ file.write(line + "\n")
249
+
250
+ kT = ase.units.kB * self.config.temp
251
+
252
+ return NonOverwritingPlumed(
253
+ calc=self.model.get_calculator(directory=directory),
254
+ atoms=self.data[self.data_idx],
255
+ input=lines,
256
+ timestep=float(self.timestep * ase.units.fs),
257
+ kT=float(kT),
258
+ log=(directory / "plumed.log").as_posix(),
259
+ )
260
+
261
+ def to_plumed(self, atoms: ase.Atoms) -> list[str]:
262
+ """Generate PLUMED input string for the OPES model."""
263
+ # check for duplicate CV prefixes
264
+ cv_labels = set()
265
+ for bias_cv in self.bias_cvs:
266
+ if bias_cv.cv.prefix in cv_labels:
267
+ raise ValueError(f"Duplicate CV prefix found: {bias_cv.cv.prefix}")
268
+ cv_labels.add(bias_cv.cv.prefix)
269
+
270
+ plumed_lines = []
271
+ all_labels = []
272
+
273
+ sigmas = []
274
+
275
+ plumed_lines.append(
276
+ f"UNITS LENGTH=A TIME={1 / (1000 * ase.units.fs)} ENERGY={ase.units.mol / ase.units.kJ}"
277
+ )
278
+
279
+ for bias_cv in self.bias_cvs:
280
+ labels, cv_str = bias_cv.cv.to_plumed(atoms)
281
+ plumed_lines.extend(cv_str)
282
+ all_labels.extend(labels)
283
+
284
+ # Collect sigma values
285
+ if isinstance(bias_cv.sigma, str):
286
+ sigmas.append(bias_cv.sigma)
287
+ else:
288
+ sigmas.append(str(bias_cv.sigma))
289
+
290
+ # Determine which OPES method to use
291
+ method_name = "OPES_METAD_EXPLORE" if self.config.explore_mode else "OPES_METAD"
292
+
293
+ # Build OPES command
294
+ opes_parts = [
295
+ f"opes: {method_name}",
296
+ f"ARG={','.join(all_labels)}",
297
+ f"PACE={self.config.pace}",
298
+ f"BARRIER={self.config.barrier}",
299
+ f"TEMP={self.config.temp}",
300
+ ]
301
+
302
+ # Add SIGMA (required parameter)
303
+ # If all sigmas are the same, use single value; otherwise comma-separated
304
+ if len(set(sigmas)) == 1:
305
+ opes_parts.append(f"SIGMA={sigmas[0]}")
306
+ else:
307
+ opes_parts.append(f"SIGMA={','.join(sigmas)}")
308
+
309
+ # Add FILE and COMPRESSION_THRESHOLD
310
+ opes_parts.append(f"FILE={self.config.file}")
311
+ opes_parts.append(f"COMPRESSION_THRESHOLD={self.config.compression_threshold}")
312
+
313
+ # Optional parameters
314
+ if self.config.biasfactor is not None:
315
+ opes_parts.append(f"BIASFACTOR={self.config.biasfactor}")
316
+ if self.config.adaptive_sigma_stride is not None:
317
+ opes_parts.append(f"ADAPTIVE_SIGMA_STRIDE={self.config.adaptive_sigma_stride}")
318
+ if self.config.sigma_min is not None:
319
+ opes_parts.append(f"SIGMA_MIN={self.config.sigma_min}")
320
+ if self.config.state_wfile is not None:
321
+ opes_parts.append(f"STATE_WFILE={self.config.state_wfile}")
322
+ if self.config.state_rfile is not None:
323
+ opes_parts.append(f"STATE_RFILE={self.config.state_rfile}")
324
+ if self.config.state_wstride is not None:
325
+ opes_parts.append(f"STATE_WSTRIDE={self.config.state_wstride}")
326
+ if self.config.walkers_mpi:
327
+ opes_parts.append("WALKERS_MPI")
328
+ if self.config.calc_work:
329
+ opes_parts.append("CALC_WORK")
330
+
331
+ plumed_lines.append(" ".join(opes_parts))
332
+
333
+ # Add any additional actions (restraints, walls, print actions, etc.)
334
+ for action in self.actions:
335
+ action_lines = action.to_plumed(atoms)
336
+ plumed_lines.extend(action_lines)
337
+
338
+ # Add FLUSH if configured
339
+ if self.config.flush is not None:
340
+ plumed_lines.append(f"FLUSH STRIDE={self.config.flush}")
341
+
342
+ return plumed_lines
hillclimber/selectors.py CHANGED
@@ -7,6 +7,107 @@ import rdkit2ase
7
7
  from hillclimber.interfaces import AtomSelector
8
8
 
9
9
 
10
+ # --- Indexable Selector Wrappers ---
11
+
12
+
13
+ @dataclasses.dataclass
14
+ class _GroupIndexedSelector(AtomSelector):
15
+ """Selector with group-level indexing applied.
16
+
17
+ This is an internal class created when you index a selector at the group level.
18
+ For example: water_sel[0] or water_sel[0:2]
19
+ """
20
+ selector: AtomSelector
21
+ group_index: int | slice | list[int]
22
+
23
+ def __getitem__(self, idx: int | slice | list[int]) -> AtomSelector:
24
+ """Atom-level indexing.
25
+
26
+ After group indexing, this applies atom-level indexing.
27
+ For example: water_sel[0:2][1:3] selects atoms 1-2 from groups 0-1.
28
+ """
29
+ return _AtomIndexedSelector(self, idx)
30
+
31
+ def __add__(self, other: AtomSelector) -> AtomSelector:
32
+ """Combine two selectors."""
33
+ return _CombinedSelector([self, other])
34
+
35
+ def select(self, atoms: ase.Atoms) -> list[list[int]]:
36
+ """Apply group indexing to the underlying selector."""
37
+ groups = self.selector.select(atoms)
38
+
39
+ # Apply group indexing (supports negative indices)
40
+ if isinstance(self.group_index, int):
41
+ return [groups[self.group_index]] # Python handles negative indices
42
+ elif isinstance(self.group_index, slice):
43
+ return groups[self.group_index] # Python handles negative indices in slices
44
+ else: # list[int]
45
+ return [groups[i] for i in self.group_index] # Negative indices work here too
46
+
47
+
48
+ @dataclasses.dataclass
49
+ class _AtomIndexedSelector(AtomSelector):
50
+ """Selector with both group and atom-level indexing applied.
51
+
52
+ This is an internal class created when you apply two levels of indexing.
53
+ For example: water_sel[0][0] or water_sel[0:2][1:3]
54
+ """
55
+ group_selector: _GroupIndexedSelector
56
+ atom_index: int | slice | list[int]
57
+
58
+ def __getitem__(self, idx) -> AtomSelector:
59
+ """Prevent three-level indexing."""
60
+ raise ValueError("Cannot index beyond 2 levels (group, then atom)")
61
+
62
+ def __add__(self, other: AtomSelector) -> AtomSelector:
63
+ """Combine two selectors."""
64
+ return _CombinedSelector([self, other])
65
+
66
+ def select(self, atoms: ase.Atoms) -> list[list[int]]:
67
+ """Apply atom-level indexing to each group."""
68
+ groups = self.group_selector.select(atoms)
69
+
70
+ # Apply atom-level indexing to each group (supports negative indices)
71
+ result = []
72
+ for group in groups:
73
+ if isinstance(self.atom_index, int):
74
+ result.append([group[self.atom_index]]) # Negative indices work
75
+ elif isinstance(self.atom_index, slice):
76
+ result.append(group[self.atom_index]) # Negative indices in slices work
77
+ else: # list[int]
78
+ result.append([group[i] for i in self.atom_index]) # Negative indices work
79
+
80
+ return result
81
+
82
+
83
+ @dataclasses.dataclass
84
+ class _CombinedSelector(AtomSelector):
85
+ """Selector that combines multiple selectors.
86
+
87
+ This is an internal class created when you combine selectors with +.
88
+ For example: water_sel + ethanol_sel
89
+ """
90
+ selectors: list[AtomSelector]
91
+
92
+ def __getitem__(self, idx: int | slice | list[int]) -> AtomSelector:
93
+ """Group-level indexing on combined result."""
94
+ return _GroupIndexedSelector(self, idx)
95
+
96
+ def __add__(self, other: AtomSelector) -> AtomSelector:
97
+ """Combine with another selector."""
98
+ # Flatten if other is also a CombinedSelector
99
+ if isinstance(other, _CombinedSelector):
100
+ return _CombinedSelector(self.selectors + other.selectors)
101
+ return _CombinedSelector(self.selectors + [other])
102
+
103
+ def select(self, atoms: ase.Atoms) -> list[list[int]]:
104
+ """Concatenate all groups from all selectors."""
105
+ result = []
106
+ for selector in self.selectors:
107
+ result.extend(selector.select(atoms))
108
+ return result
109
+
110
+
10
111
  @dataclasses.dataclass
11
112
  class IndexSelector(AtomSelector):
12
113
  """Select atoms based on grouped indices.
@@ -23,6 +124,14 @@ class IndexSelector(AtomSelector):
23
124
  # mostly used for debugging
24
125
  indices: list[list[int]]
25
126
 
127
+ def __getitem__(self, idx: int | slice | list[int]) -> AtomSelector:
128
+ """Group-level indexing."""
129
+ return _GroupIndexedSelector(self, idx)
130
+
131
+ def __add__(self, other: AtomSelector) -> AtomSelector:
132
+ """Combine two selectors."""
133
+ return _CombinedSelector([self, other])
134
+
26
135
  def select(self, atoms: ase.Atoms) -> list[list[int]]:
27
136
  return self.indices
28
137
 
@@ -39,6 +148,14 @@ class SMILESSelector(AtomSelector):
39
148
 
40
149
  smiles: str
41
150
 
151
+ def __getitem__(self, idx: int | slice | list[int]) -> AtomSelector:
152
+ """Group-level indexing."""
153
+ return _GroupIndexedSelector(self, idx)
154
+
155
+ def __add__(self, other: AtomSelector) -> AtomSelector:
156
+ """Combine two selectors."""
157
+ return _CombinedSelector([self, other])
158
+
42
159
  def select(self, atoms: ase.Atoms) -> list[list[int]]:
43
160
  matches = rdkit2ase.match_substructure(atoms, smiles=self.smiles)
44
161
  return [list(match) for match in matches]
@@ -55,8 +172,8 @@ class SMARTSSelector(AtomSelector):
55
172
 
56
173
  Note
57
174
  ----
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.
175
+ The selector is applied only to the first trajectory frame.
176
+ Since indices can change during e.g. proton transfer, biasing specific groups (e.g. `[OH-]`) may fail.
60
177
  In such cases, select all `[OH2]` and `[OH-]` groups and use CoordinationNumber CVs.
61
178
  Account for this method with all changes in chemical structure.
62
179
 
@@ -90,6 +207,14 @@ class SMARTSSelector(AtomSelector):
90
207
  pattern: str
91
208
  hydrogens: tp.Literal["include", "exclude", "isolated"] = "exclude"
92
209
 
210
+ def __getitem__(self, idx: int | slice | list[int]) -> AtomSelector:
211
+ """Group-level indexing."""
212
+ return _GroupIndexedSelector(self, idx)
213
+
214
+ def __add__(self, other: AtomSelector) -> AtomSelector:
215
+ """Combine two selectors."""
216
+ return _CombinedSelector([self, other])
217
+
93
218
  def select(self, atoms: ase.Atoms) -> list[list[int]]:
94
219
  return rdkit2ase.select_atoms_grouped(
95
220
  rdkit2ase.ase2rdkit(atoms), self.pattern, self.hydrogens