hillclimber 0.1.0a2__py3-none-any.whl → 0.1.0a3__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/__init__.py CHANGED
@@ -1,4 +1,5 @@
1
1
  from hillclimber.actions import PrintAction
2
+ from hillclimber.analysis import sum_hills, read_colvar, plot_cv_time_series
2
3
  from hillclimber.biases import RestraintBias, UpperWallBias, LowerWallBias
3
4
  from hillclimber.cvs import DistanceCV, AngleCV, CoordinationNumberCV, TorsionCV, RadiusOfGyrationCV
4
5
  from hillclimber.metadynamics import MetadBias, MetaDynamicsConfig, MetaDynamicsModel
@@ -26,4 +27,7 @@ __all__ = [
26
27
  "RestraintBias",
27
28
  "UpperWallBias",
28
29
  "LowerWallBias",
30
+ "sum_hills",
31
+ "read_colvar",
32
+ "plot_cv_time_series",
29
33
  ]
@@ -0,0 +1,636 @@
1
+ """Analysis utilities for metadynamics simulations.
2
+
3
+ This module provides tools for analyzing metadynamics simulations,
4
+ including free energy surface reconstruction and other post-processing tasks.
5
+ """
6
+
7
+ import os
8
+ import re
9
+ import shutil
10
+ import subprocess
11
+ import typing as t
12
+ from pathlib import Path
13
+
14
+ import numpy as np
15
+
16
+
17
+ def _validate_multi_cv_params(
18
+ min_bounds: float | list[float] | None = None,
19
+ max_bounds: float | list[float] | None = None,
20
+ bin: int | list[int] | None = None,
21
+ spacing: float | list[float] | None = None,
22
+ sigma: float | list[float] | None = None,
23
+ idw: str | list[str] | None = None,
24
+ ) -> None:
25
+ """Validate that multi-CV parameters have consistent dimensions.
26
+
27
+ Parameters
28
+ ----------
29
+ min_bounds, max_bounds, bin, spacing, sigma, idw
30
+ Parameters from sum_hills that can be lists for multi-CV cases.
31
+
32
+ Raises
33
+ ------
34
+ ValueError
35
+ If list parameters have inconsistent lengths.
36
+ """
37
+ # Collect all list parameters and their lengths
38
+ list_params: dict[str, int] = {}
39
+
40
+ params_to_check = {
41
+ "min_bounds": min_bounds,
42
+ "max_bounds": max_bounds,
43
+ "bin": bin,
44
+ "spacing": spacing,
45
+ "sigma": sigma,
46
+ "idw": idw,
47
+ }
48
+
49
+ for name, value in params_to_check.items():
50
+ if isinstance(value, (list, tuple)):
51
+ list_params[name] = len(value)
52
+
53
+ # If no list parameters, nothing to validate (single CV case)
54
+ if not list_params:
55
+ return
56
+
57
+ # Check that all list parameters have the same length
58
+ lengths = set(list_params.values())
59
+ if len(lengths) > 1:
60
+ # Build a detailed error message
61
+ param_details = ", ".join(
62
+ f"{name}={length}" for name, length in list_params.items()
63
+ )
64
+ raise ValueError(
65
+ f"Inconsistent number of CVs in parameters. "
66
+ f"All list parameters must have the same length. "
67
+ f"Got: {param_details}"
68
+ )
69
+
70
+
71
+ def sum_hills(
72
+ hills_file: str | Path,
73
+ plumed_bin_path: str | Path | None = None,
74
+ # Boolean flags
75
+ negbias: bool = False,
76
+ nohistory: bool = False,
77
+ mintozero: bool = False,
78
+ # File/histogram options
79
+ histo: str | Path | None = None,
80
+ # Grid parameters
81
+ stride: int | None = None,
82
+ min_bounds: float | list[float] | None = None,
83
+ max_bounds: float | list[float] | None = None,
84
+ bin: int | list[int] | None = None,
85
+ spacing: float | list[float] | None = None,
86
+ # Variable selection
87
+ idw: str | list[str] | None = None,
88
+ # Output options
89
+ outfile: str | Path | None = None,
90
+ outhisto: str | Path | None = None,
91
+ # Integration parameters
92
+ kt: float | None = None,
93
+ sigma: float | list[float] | None = None,
94
+ # Format
95
+ fmt: str | None = None,
96
+ # Additional options
97
+ verbose: bool = True,
98
+ check: bool = True,
99
+ ) -> subprocess.CompletedProcess:
100
+ """Run PLUMED sum_hills to reconstruct free energy surfaces from metadynamics.
101
+
102
+ This function wraps the PLUMED ``sum_hills`` command-line tool, which analyzes
103
+ HILLS files from metadynamics simulations to reconstruct the free energy surface.
104
+
105
+ Parameters
106
+ ----------
107
+ hills_file : str or Path
108
+ Path to the HILLS file to analyze. This file is generated during
109
+ metadynamics simulations and contains the deposited Gaussian hills.
110
+ plumed_bin_path : str or Path, optional
111
+ Path to the PLUMED installation directory (containing ``bin/`` and ``lib/``
112
+ subdirectories). If None, searches for ``plumed`` in the system PATH.
113
+ When a full installation path is provided, the function will properly set
114
+ LD_LIBRARY_PATH to include the PLUMED libraries.
115
+ negbias : bool, default=False
116
+ Print the negative bias instead of the free energy.
117
+ nohistory : bool, default=False
118
+ To be used with ``stride``: splits the bias/histogram without previous history.
119
+ mintozero : bool, default=False
120
+ Translate all minimum values in bias/histogram to zero.
121
+ histo : str or Path, optional
122
+ Name of the file for histogram (a COLVAR/HILLS file is good).
123
+ stride : int, optional
124
+ Stride for integrating hills file. Default is 0 (never integrate).
125
+ min_bounds : float or list[float], optional
126
+ Lower bounds for the grid. For multi-dimensional CVs, provide a list with
127
+ one value per CV (e.g., ``[-3.14, -3.14]`` for two torsion angles).
128
+ max_bounds : float or list[float], optional
129
+ Upper bounds for the grid. For multi-dimensional CVs, provide a list with
130
+ one value per CV (e.g., ``[3.14, 3.14]`` for two torsion angles).
131
+ bin : int or list[int], optional
132
+ Number of bins for the grid. For multi-dimensional CVs, provide a list with
133
+ one value per CV (e.g., ``[250, 250]`` for two CVs with 250 bins each).
134
+ spacing : float or list[float], optional
135
+ Grid spacing, alternative to the number of bins. For multi-dimensional CVs,
136
+ provide a list with one value per CV.
137
+ idw : str or list[str], optional
138
+ Variables to be used for the free-energy/histogram. For multi-dimensional CVs,
139
+ provide a list with one variable name per CV (e.g., ``['phi', 'psi']``).
140
+ outfile : str or Path, optional
141
+ Output file for sum_hills. Default is ``fes.dat``.
142
+ outhisto : str or Path, optional
143
+ Output file for the histogram.
144
+ kt : float, optional
145
+ Temperature in energy units (kJ/mol) for integrating out variables.
146
+ sigma : float or list[float], optional
147
+ Sigma for binning (only needed when doing histogram). For multi-dimensional CVs,
148
+ provide a list with one value per CV.
149
+ fmt : str, optional
150
+ Output format specification.
151
+ verbose : bool, default=True
152
+ Print command output to stdout/stderr.
153
+ check : bool, default=True
154
+ Raise exception if command fails.
155
+
156
+ Returns
157
+ -------
158
+ subprocess.CompletedProcess
159
+ The completed process object from subprocess.run.
160
+
161
+ Raises
162
+ ------
163
+ FileNotFoundError
164
+ If the HILLS file, PLUMED executable, or PLUMED installation directory
165
+ cannot be found.
166
+ ValueError
167
+ If list-based parameters (``bin``, ``min_bounds``, ``max_bounds``, etc.)
168
+ have inconsistent lengths when using multiple CVs.
169
+ subprocess.CalledProcessError
170
+ If the PLUMED command fails and ``check=True``.
171
+
172
+ Examples
173
+ --------
174
+ Basic usage to reconstruct a 1D free energy surface:
175
+
176
+ >>> import hillclimber as hc
177
+ >>> hc.sum_hills("HILLS")
178
+
179
+ With custom grid resolution and output file:
180
+
181
+ >>> hc.sum_hills(
182
+ ... "HILLS",
183
+ ... bin=1000,
184
+ ... outfile="custom_fes.dat"
185
+ ... )
186
+
187
+ For a 2D free energy surface with explicit bounds:
188
+
189
+ >>> hc.sum_hills(
190
+ ... "HILLS",
191
+ ... bin=[100, 100],
192
+ ... min_bounds=[0.0, 0.0],
193
+ ... max_bounds=[10.0, 10.0],
194
+ ... outfile="fes_2d.dat"
195
+ ... )
196
+
197
+ For protein backbone torsion angles (phi and psi):
198
+
199
+ >>> hc.sum_hills(
200
+ ... "HILLS",
201
+ ... bin=[250, 250],
202
+ ... min_bounds=[-3.14, -3.14],
203
+ ... max_bounds=[3.14, 3.14],
204
+ ... idw=["phi", "psi"],
205
+ ... outfile="ramachandran.dat"
206
+ ... )
207
+
208
+ Resources
209
+ ---------
210
+ - https://www.plumed.org/doc-master/user-doc/html/sum_hills.html
211
+
212
+ Notes
213
+ -----
214
+ The HILLS file is automatically generated during metadynamics simulations
215
+ when using the METAD action. Each line in the file represents a deposited
216
+ Gaussian hill with its position, width (sigma), and height.
217
+
218
+ The free energy surface is reconstructed by summing all deposited hills:
219
+ F(s) = -V(s) where V(s) is the bias potential.
220
+
221
+ **Multi-CV Consistency:**
222
+ When using multiple collective variables (CVs), all list-based parameters
223
+ must have the same length. For example, if analyzing two CVs (phi and psi),
224
+ then ``bin``, ``min_bounds``, ``max_bounds``, and ``idw`` (if provided as lists)
225
+ must all have exactly 2 elements. The function will raise a ``ValueError``
226
+ if inconsistent list lengths are detected.
227
+ """
228
+ # Convert to Path object
229
+ hills_file = Path(hills_file)
230
+
231
+ # Verify HILLS file exists
232
+ if not hills_file.exists():
233
+ raise FileNotFoundError(f"HILLS file not found: {hills_file}")
234
+
235
+ # Find PLUMED executable and set up environment
236
+ env = os.environ.copy()
237
+
238
+ if plumed_bin_path is None:
239
+ # Try to find plumed in system PATH
240
+ plumed_exec = shutil.which("plumed")
241
+ if plumed_exec is None:
242
+ raise FileNotFoundError(
243
+ "PLUMED executable not found in system PATH. "
244
+ "Please install PLUMED or specify the installation path with plumed_bin_path="
245
+ )
246
+ else:
247
+ # Use provided PLUMED installation path
248
+ plumed_bin_path = Path(plumed_bin_path)
249
+ plumed_exec = plumed_bin_path / "bin" / "plumed"
250
+ lib_path = plumed_bin_path / "lib"
251
+
252
+ # Verify paths exist
253
+ if not plumed_exec.exists():
254
+ raise FileNotFoundError(
255
+ f"PLUMED executable not found at: {plumed_exec}\n"
256
+ f"Make sure plumed_bin_path points to the PLUMED installation directory "
257
+ f"containing bin/ and lib/ subdirectories."
258
+ )
259
+ if not lib_path.exists():
260
+ raise FileNotFoundError(f"PLUMED lib directory not found: {lib_path}")
261
+
262
+ # Set LD_LIBRARY_PATH for PLUMED libraries
263
+ current_ld_path = env.get("LD_LIBRARY_PATH", "")
264
+ if current_ld_path:
265
+ env["LD_LIBRARY_PATH"] = f"{lib_path}:{current_ld_path}"
266
+ else:
267
+ env["LD_LIBRARY_PATH"] = str(lib_path)
268
+
269
+ plumed_exec = str(plumed_exec)
270
+
271
+ # Validate multi-CV parameter consistency
272
+ _validate_multi_cv_params(
273
+ min_bounds=min_bounds,
274
+ max_bounds=max_bounds,
275
+ bin=bin,
276
+ spacing=spacing,
277
+ sigma=sigma,
278
+ idw=idw,
279
+ )
280
+
281
+ # Build command
282
+ cmd_parts = [plumed_exec, "sum_hills"]
283
+
284
+ # Add hills file
285
+ cmd_parts.extend(["--hills", str(hills_file)])
286
+
287
+ # Add boolean flags
288
+ if negbias:
289
+ cmd_parts.append("--negbias")
290
+ if nohistory:
291
+ cmd_parts.append("--nohistory")
292
+ if mintozero:
293
+ cmd_parts.append("--mintozero")
294
+
295
+ # Helper function to format list parameters
296
+ def format_param(value: t.Any) -> str:
297
+ if isinstance(value, (list, tuple)):
298
+ return ",".join(str(v) for v in value)
299
+ return str(value)
300
+
301
+ # Add optional parameters
302
+ if histo is not None:
303
+ cmd_parts.extend(["--histo", str(histo)])
304
+ if stride is not None:
305
+ cmd_parts.extend(["--stride", str(stride)])
306
+ if min_bounds is not None:
307
+ cmd_parts.extend(["--min", format_param(min_bounds)])
308
+ if max_bounds is not None:
309
+ cmd_parts.extend(["--max", format_param(max_bounds)])
310
+ if bin is not None:
311
+ cmd_parts.extend(["--bin", format_param(bin)])
312
+ if spacing is not None:
313
+ cmd_parts.extend(["--spacing", format_param(spacing)])
314
+ if idw is not None:
315
+ cmd_parts.extend(["--idw", format_param(idw)])
316
+ if outfile is not None:
317
+ cmd_parts.extend(["--outfile", str(outfile)])
318
+ if outhisto is not None:
319
+ cmd_parts.extend(["--outhisto", str(outhisto)])
320
+ if kt is not None:
321
+ cmd_parts.extend(["--kt", str(kt)])
322
+ if sigma is not None:
323
+ cmd_parts.extend(["--sigma", format_param(sigma)])
324
+ if fmt is not None:
325
+ cmd_parts.extend(["--fmt", str(fmt)])
326
+
327
+ # Run command
328
+ if verbose:
329
+ print(f"Running: {' '.join(cmd_parts)}")
330
+
331
+ result = subprocess.run(
332
+ cmd_parts,
333
+ env=env,
334
+ capture_output=not verbose,
335
+ text=True,
336
+ check=check,
337
+ )
338
+
339
+ if verbose and result.returncode == 0:
340
+ print("sum_hills completed successfully")
341
+
342
+ return result
343
+
344
+
345
+ def read_colvar(
346
+ colvar_file: str | Path,
347
+ ) -> dict[str, np.ndarray]:
348
+ """Read a PLUMED COLVAR file and parse its contents.
349
+
350
+ This function reads a COLVAR file produced by PLUMED, extracts the field names
351
+ from the header (which starts with ``#! FIELDS``), and returns the data as a
352
+ dictionary mapping field names to numpy arrays.
353
+
354
+ Parameters
355
+ ----------
356
+ colvar_file : str or Path
357
+ Path to the COLVAR file to read.
358
+
359
+ Returns
360
+ -------
361
+ dict[str, np.ndarray]
362
+ Dictionary mapping field names to 1D numpy arrays containing the data.
363
+ Keys correspond to the fields specified in the COLVAR header.
364
+
365
+ Raises
366
+ ------
367
+ FileNotFoundError
368
+ If the COLVAR file does not exist.
369
+ ValueError
370
+ If the COLVAR file does not contain a valid ``#! FIELDS`` header.
371
+
372
+ Examples
373
+ --------
374
+ >>> import hillclimber as hc
375
+ >>> data = hc.read_colvar("COLVAR")
376
+ >>> print(data.keys())
377
+ dict_keys(['time', 'phi', 'psi'])
378
+ >>> print(data['time'][:5])
379
+ [0. 1. 2. 3. 4.]
380
+
381
+ Notes
382
+ -----
383
+ The COLVAR file format from PLUMED starts with a header line:
384
+ ``#! FIELDS time cv1 cv2 ...``
385
+
386
+ All subsequent lines starting with ``#`` are treated as comments and ignored.
387
+ Data lines are parsed as whitespace-separated numeric values.
388
+
389
+ Resources
390
+ ---------
391
+ - https://www.plumed.org/doc-master/user-doc/html/colvar.html
392
+ """
393
+ colvar_file = Path(colvar_file)
394
+
395
+ if not colvar_file.exists():
396
+ raise FileNotFoundError(f"COLVAR file not found: {colvar_file}")
397
+
398
+ # Read the file
399
+ with open(colvar_file, "r") as f:
400
+ lines = f.readlines()
401
+
402
+ # Find and parse the header
403
+ field_names: list[str] | None = None
404
+ for line in lines:
405
+ if line.startswith("#! FIELDS"):
406
+ # Extract field names from the header
407
+ # Format: "#! FIELDS time phi psi ..."
408
+ fields_match = re.match(r"#!\s*FIELDS\s+(.+)", line)
409
+ if fields_match:
410
+ field_names = fields_match.group(1).split()
411
+ break
412
+
413
+ if field_names is None:
414
+ raise ValueError(
415
+ f"COLVAR file {colvar_file} does not contain a valid '#! FIELDS' header"
416
+ )
417
+
418
+ # Parse data lines (skip comments)
419
+ data_lines = []
420
+ for line in lines:
421
+ # Skip comments and empty lines
422
+ if line.startswith("#") or not line.strip():
423
+ continue
424
+ # Parse numeric data
425
+ values = line.split()
426
+ if len(values) == len(field_names):
427
+ data_lines.append([float(v) for v in values])
428
+
429
+ # Convert to numpy array
430
+ data_array = np.array(data_lines)
431
+
432
+ # Create dictionary mapping field names to columns
433
+ result = {name: data_array[:, i] for i, name in enumerate(field_names)}
434
+
435
+ return result
436
+
437
+
438
+ def plot_cv_time_series(
439
+ colvar_file: str | Path,
440
+ cv_names: list[str] | None = None,
441
+ time_unit: str = "ps",
442
+ exclude_patterns: list[str] | None = None,
443
+ figsize: tuple[float, float] = (8, 5),
444
+ kde_width: str = "25%",
445
+ colors: list[str] | None = None,
446
+ alpha: float = 0.5,
447
+ marker: str = "x",
448
+ marker_size: float = 10,
449
+ ) -> tuple[t.Any, t.Any]:
450
+ """Plot collective variables over time with KDE distributions.
451
+
452
+ This function creates a visualization showing CV evolution over time as scatter
453
+ plots, with kernel density estimation (KDE) plots displayed on the right side
454
+ to show the distribution of each CV.
455
+
456
+ Parameters
457
+ ----------
458
+ colvar_file : str or Path
459
+ Path to the COLVAR file to plot.
460
+ cv_names : list[str], optional
461
+ List of CV names to plot. If None, automatically detects CVs by excluding
462
+ common non-CV fields like 'time', 'sigma_*', 'height', 'biasf'.
463
+ time_unit : str, default='ps'
464
+ Unit label for the time axis.
465
+ exclude_patterns : list[str], optional
466
+ Additional regex patterns for field names to exclude from auto-detection.
467
+ Default excludes: 'time', 'sigma_.*', 'height', 'biasf'.
468
+ figsize : tuple[float, float], default=(8, 5)
469
+ Figure size in inches (width, height).
470
+ kde_width : str, default='25%'
471
+ Width of the KDE subplot as a percentage of the main plot width.
472
+ colors : list[str], optional
473
+ List of colors to use for each CV. If None, uses default color cycle.
474
+ alpha : float, default=0.5
475
+ Transparency for scatter points.
476
+ marker : str, default='x'
477
+ Marker style for scatter points.
478
+ marker_size : float, default=10
479
+ Size of scatter markers.
480
+
481
+ Returns
482
+ -------
483
+ fig : matplotlib.figure.Figure
484
+ The matplotlib figure object.
485
+ axes : tuple
486
+ Tuple of (main_axis, kde_axis) matplotlib axes objects.
487
+
488
+ Raises
489
+ ------
490
+ ImportError
491
+ If matplotlib or seaborn is not installed.
492
+ FileNotFoundError
493
+ If the COLVAR file does not exist.
494
+
495
+ Examples
496
+ --------
497
+ Basic usage with auto-detected CVs:
498
+
499
+ >>> import hillclimber as hc
500
+ >>> fig, axes = hc.plot_cv_time_series("COLVAR")
501
+
502
+ Plot specific CVs:
503
+
504
+ >>> fig, axes = hc.plot_cv_time_series("COLVAR", cv_names=["phi", "psi"])
505
+
506
+ Customize appearance:
507
+
508
+ >>> fig, axes = hc.plot_cv_time_series(
509
+ ... "COLVAR",
510
+ ... figsize=(10, 6),
511
+ ... colors=["blue", "red"],
512
+ ... alpha=0.7
513
+ ... )
514
+
515
+ Notes
516
+ -----
517
+ This function requires matplotlib and seaborn to be installed.
518
+
519
+ The function automatically detects CVs by excluding common metadata fields
520
+ such as 'time', 'sigma_*', 'height', and 'biasf'. You can specify additional
521
+ exclusion patterns or explicitly provide the CV names to plot.
522
+
523
+ Resources
524
+ ---------
525
+ - https://www.plumed.org/doc-master/user-doc/html/colvar.html
526
+ """
527
+ try:
528
+ import matplotlib.pyplot as plt
529
+ import seaborn as sns
530
+ from mpl_toolkits.axes_grid1 import make_axes_locatable
531
+ except ImportError as e:
532
+ raise ImportError(
533
+ "matplotlib and seaborn are required for plotting. "
534
+ "Install them with: pip install matplotlib seaborn"
535
+ ) from e
536
+
537
+ # Read the COLVAR file
538
+ data = read_colvar(colvar_file)
539
+
540
+ # Auto-detect CVs if not specified
541
+ if cv_names is None:
542
+ # Default exclusion patterns
543
+ default_exclude = [
544
+ r"^time$",
545
+ r"^sigma_.*$",
546
+ r"^height$",
547
+ r"^biasf$",
548
+ ]
549
+ if exclude_patterns is not None:
550
+ default_exclude.extend(exclude_patterns)
551
+
552
+ # Filter field names
553
+ detected_cvs: list[str] = []
554
+ for field in data.keys():
555
+ # Check if field matches any exclusion pattern
556
+ exclude = False
557
+ for pattern in default_exclude:
558
+ if re.match(pattern, field):
559
+ exclude = True
560
+ break
561
+ if not exclude:
562
+ detected_cvs.append(field)
563
+
564
+ if not detected_cvs:
565
+ raise ValueError(
566
+ "No CVs detected in COLVAR file. "
567
+ "All fields were excluded by the exclusion patterns."
568
+ )
569
+ cv_names = detected_cvs
570
+
571
+ # Verify that all requested CVs exist
572
+ missing_cvs = [cv for cv in cv_names if cv not in data]
573
+ if missing_cvs:
574
+ raise ValueError(
575
+ f"CVs not found in COLVAR file: {missing_cvs}. "
576
+ f"Available fields: {list(data.keys())}"
577
+ )
578
+
579
+ # Get time data
580
+ if "time" not in data:
581
+ raise ValueError("COLVAR file must contain a 'time' field")
582
+ time = data["time"]
583
+
584
+ # Default colors if not provided
585
+ if colors is None:
586
+ colors = plt.cm.tab10.colors # type: ignore
587
+
588
+ # Set seaborn style
589
+ sns.set(style="whitegrid")
590
+
591
+ # Create figure
592
+ fig, ax = plt.subplots(figsize=figsize)
593
+
594
+ # Plot each CV
595
+ for i, cv_name in enumerate(cv_names):
596
+ color = colors[i % len(colors)]
597
+ cv_data = data[cv_name]
598
+ ax.scatter(
599
+ time,
600
+ cv_data,
601
+ c=[color],
602
+ label=cv_name,
603
+ marker=marker,
604
+ s=marker_size,
605
+ alpha=alpha,
606
+ )
607
+
608
+ ax.set_xlabel(f"Time / {time_unit}")
609
+ ax.set_ylabel("CV value")
610
+ ax.legend()
611
+
612
+ # Create KDE subplot on the right
613
+ divider = make_axes_locatable(ax)
614
+ ax_kde = divider.append_axes("right", size=kde_width, pad=0.1, sharey=ax)
615
+
616
+ # Plot KDE for each CV
617
+ for i, cv_name in enumerate(cv_names):
618
+ color = colors[i % len(colors)]
619
+ cv_data = data[cv_name]
620
+ sns.kdeplot(
621
+ y=cv_data,
622
+ ax=ax_kde,
623
+ color=color,
624
+ fill=True,
625
+ alpha=0.3,
626
+ linewidth=1.5,
627
+ label=cv_name,
628
+ )
629
+
630
+ # Clean up KDE axis
631
+ ax_kde.set_xlabel("Density")
632
+ ax_kde.yaxis.set_tick_params(labelleft=False)
633
+
634
+ plt.tight_layout()
635
+
636
+ return fig, (ax, ax_kde)
hillclimber/biases.py CHANGED
@@ -29,17 +29,17 @@ class RestraintBias(BiasProtocol):
29
29
  cv : CollectiveVariable
30
30
  The collective variable to restrain.
31
31
  kappa : float
32
- The force constant of the restraint in kJ/(mol·unit^2), where unit
33
- depends on the CV (e.g., Å for distances, radians for angles).
32
+ The force constant of the restraint in eV/unit^2, where unit
33
+ depends on the CV (e.g., eV/Ų for distances, eV/rad² for angles).
34
34
  at : float
35
- The center/target value of the restraint.
35
+ The center/target value of the restraint in CV units (e.g., Å for distances).
36
36
  label : str, optional
37
37
  A custom label for this restraint. If not provided, uses cv.prefix + "_restraint".
38
38
 
39
39
  Examples
40
40
  --------
41
41
  >>> import hillclimber as hc
42
- >>> # Restrain a distance around 2.5 Å
42
+ >>> # Restrain a distance around 2.5 Šwith force constant 200 eV/Ų
43
43
  >>> distance_cv = hc.DistanceCV(...)
44
44
  >>> restraint = hc.RestraintBias(cv=distance_cv, kappa=200.0, at=2.5)
45
45
 
@@ -49,10 +49,10 @@ class RestraintBias(BiasProtocol):
49
49
 
50
50
  Notes
51
51
  -----
52
- The force constant kappa is in kJ/(mol·unit^2). For typical CV units:
53
- - Distances (Å): kappa in kJ/(mol·Å^2)
54
- - Angles (radians): kappa in kJ/(mol·rad^2)
55
- - Dimensionless CVs: kappa in kJ/mol
52
+ Due to the UNITS line, kappa is in eV/unit^2 (ASE energy units). For typical CVs:
53
+ - Distances (Å): kappa in eV/Ų
54
+ - Angles (radians): kappa in eV/rad²
55
+ - Dimensionless CVs: kappa in eV
56
56
 
57
57
  The restraint creates a bias force: F = -kappa * (s - at)
58
58
  """
@@ -109,9 +109,9 @@ class UpperWallBias(BiasProtocol):
109
109
  cv : CollectiveVariable
110
110
  The collective variable to apply the wall to.
111
111
  at : float
112
- The position of the wall (threshold value).
112
+ The position of the wall (threshold value) in CV units.
113
113
  kappa : float
114
- The force constant of the wall in kJ/mol.
114
+ The force constant of the wall in eV.
115
115
  exp : int, optional
116
116
  The exponent of the wall potential. Default is 2 (harmonic).
117
117
  Higher values create steeper walls.
@@ -125,7 +125,7 @@ class UpperWallBias(BiasProtocol):
125
125
  Examples
126
126
  --------
127
127
  >>> import hillclimber as hc
128
- >>> # Prevent distance from exceeding 3.0 Å
128
+ >>> # Prevent distance from exceeding 3.0 Å with force constant 100 eV
129
129
  >>> distance_cv = hc.DistanceCV(...)
130
130
  >>> upper_wall = hc.UpperWallBias(cv=distance_cv, at=3.0, kappa=100.0, exp=2)
131
131
 
@@ -209,9 +209,9 @@ class LowerWallBias(BiasProtocol):
209
209
  cv : CollectiveVariable
210
210
  The collective variable to apply the wall to.
211
211
  at : float
212
- The position of the wall (threshold value).
212
+ The position of the wall (threshold value) in CV units.
213
213
  kappa : float
214
- The force constant of the wall in kJ/mol.
214
+ The force constant of the wall in eV.
215
215
  exp : int, optional
216
216
  The exponent of the wall potential. Default is 2 (harmonic).
217
217
  Higher values create steeper walls.
@@ -225,7 +225,7 @@ class LowerWallBias(BiasProtocol):
225
225
  Examples
226
226
  --------
227
227
  >>> import hillclimber as hc
228
- >>> # Prevent distance from going below 1.0 Å
228
+ >>> # Prevent distance from going below 1.0 Å with force constant 100 eV
229
229
  >>> distance_cv = hc.DistanceCV(...)
230
230
  >>> lower_wall = hc.LowerWallBias(cv=distance_cv, at=1.0, kappa=100.0, exp=2)
231
231
 
hillclimber/cvs.py CHANGED
@@ -21,7 +21,6 @@ from hillclimber.virtual_atoms import VirtualAtom
21
21
  GroupReductionStrategyType = Literal[
22
22
  "com", "cog", "first", "all", "com_per_group", "cog_per_group"
23
23
  ]
24
- MultiGroupStrategyType = Literal["first", "all_pairs", "corresponding", "first_to_all"]
25
24
  SiteIdentifier = Union[str, List[int]]
26
25
  ColorTuple = Tuple[float, float, float]
27
26
  AtomHighlightMap = Dict[int, ColorTuple]
@@ -132,22 +131,6 @@ class _BasePlumedCV(CollectiveVariable):
132
131
  if cv_keyword in cmd and cmd.strip().startswith((prefix, f"{prefix}_"))
133
132
  ]
134
133
 
135
- @staticmethod
136
- def _get_index_pairs(
137
- len1: int, len2: int, strategy: MultiGroupStrategyType
138
- ) -> List[Tuple[int, int]]:
139
- """Determines pairs of group indices based on the multi-group strategy."""
140
- if strategy == "first":
141
- return [(0, 0)] if len1 > 0 and len2 > 0 else []
142
- if strategy == "all_pairs":
143
- return [(i, j) for i in range(len1) for j in range(len2)]
144
- if strategy == "corresponding":
145
- n = min(len1, len2)
146
- return [(i, i) for i in range(n)]
147
- if strategy == "first_to_all":
148
- return [(0, j) for j in range(len2)] if len1 > 0 else []
149
- raise ValueError(f"Unknown multi-group strategy: {strategy}")
150
-
151
134
  @staticmethod
152
135
  def _create_virtual_site_command(
153
136
  group: List[int], strategy: Literal["com", "cog"], label: str
@@ -901,18 +884,25 @@ class TorsionCV(_BasePlumedCV):
901
884
  Calculates the torsional (dihedral) angle defined by four atoms. Each group
902
885
  provided by the selector must contain exactly four atoms.
903
886
 
904
- Attributes:
905
- atoms: Selector for one or more groups of 4 atoms.
906
- prefix: Label prefix for the generated PLUMED commands.
907
- multi_group: Strategy for handling multiple groups from the selector.
887
+ Parameters
888
+ ----------
889
+ atoms : AtomSelector
890
+ Selector for one or more groups of 4 atoms. Each group must contain exactly 4 atoms.
891
+ prefix : str
892
+ Label prefix for the generated PLUMED commands.
893
+ strategy : {"first", "all"}, default="first"
894
+ Strategy for handling multiple groups from the selector:
895
+ - "first": Process only the first group (creates 1 CV)
896
+ - "all": Process all groups independently (creates N CVs)
908
897
 
909
- Resources:
910
- - https://www.plumed.org/doc-master/user-doc/html/TORSION
898
+ Resources
899
+ ---------
900
+ - https://www.plumed.org/doc-master/user-doc/html/TORSION
911
901
  """
912
902
 
913
903
  atoms: AtomSelector
914
904
  prefix: str
915
- multi_group: MultiGroupStrategyType = "first"
905
+ strategy: Literal["first", "all"] = "first"
916
906
 
917
907
  def _get_atom_highlights(
918
908
  self, atoms: Atoms, **kwargs
@@ -955,10 +945,10 @@ class TorsionCV(_BasePlumedCV):
955
945
 
956
946
  def _generate_commands(self, groups: List[List[int]]) -> List[str]:
957
947
  """Generates all necessary PLUMED commands."""
958
- # For torsions, 'multi_group' determines how many groups to process.
959
- if self.multi_group in ["first", "first_to_all"] and groups:
948
+ # Determine which groups to process based on strategy
949
+ if self.strategy == "first" and groups:
960
950
  indices_to_process = [0]
961
- else: # "all_pairs" and "corresponding" imply processing all independent groups.
951
+ else: # "all" - process all groups independently
962
952
  indices_to_process = list(range(len(groups)))
963
953
 
964
954
  commands = []
@@ -978,19 +968,33 @@ class RadiusOfGyrationCV(_BasePlumedCV):
978
968
  Calculates the radius of gyration of a group of atoms. The radius of gyration
979
969
  is a measure of the size of a molecular system.
980
970
 
981
- Attributes:
982
- atoms: Selector for the atoms to include in the gyration calculation.
983
- prefix: Label prefix for the generated PLUMED commands.
984
- multi_group: Strategy for handling multiple groups from the selector.
985
- type: The type of gyration tensor to use ("RADIUS" for scalar Rg, "GTPC_1", etc.)
971
+ Parameters
972
+ ----------
973
+ atoms : AtomSelector
974
+ Selector for the atoms to include in the gyration calculation.
975
+ prefix : str
976
+ Label prefix for the generated PLUMED commands.
977
+ flatten : bool, default=False
978
+ How to handle multiple groups from the selector:
979
+ - True: Combine all groups into one and calculate single Rg (creates 1 CV)
980
+ - False: Keep groups separate, use strategy to determine which to process
981
+ strategy : {"first", "all"}, default="first"
982
+ Strategy for handling multiple groups when flatten=False:
983
+ - "first": Process only the first group (creates 1 CV)
984
+ - "all": Process all groups independently (creates N CVs)
985
+ type : str, default="RADIUS"
986
+ The type of gyration tensor to use.
987
+ Options: "RADIUS", "GTPC_1", "GTPC_2", "GTPC_3", "ASPHERICITY", "ACYLINDRICITY", "KAPPA2", etc.
986
988
 
987
- Resources:
988
- - https://www.plumed.org/doc-master/user-doc/html/GYRATION/
989
+ Resources
990
+ ---------
991
+ - https://www.plumed.org/doc-master/user-doc/html/GYRATION/
989
992
  """
990
993
 
991
994
  atoms: AtomSelector
992
995
  prefix: str
993
- multi_group: MultiGroupStrategyType = "first"
996
+ flatten: bool = False
997
+ strategy: Literal["first", "all"] = "first"
994
998
  type: str = "RADIUS" # Options: RADIUS, GTPC_1, GTPC_2, GTPC_3, ASPHERICITY, ACYLINDRICITY, KAPPA2, etc.
995
999
 
996
1000
  def _get_atom_highlights(
@@ -1021,18 +1025,29 @@ class RadiusOfGyrationCV(_BasePlumedCV):
1021
1025
 
1022
1026
  def _generate_commands(self, groups: List[List[int]]) -> List[str]:
1023
1027
  """Generates all necessary PLUMED commands."""
1024
- # For gyration, 'multi_group' determines how many groups to process.
1025
- if self.multi_group in ["first", "first_to_all"] and groups:
1026
- indices_to_process = [0]
1027
- else: # "all_pairs" and "corresponding" imply processing all independent groups.
1028
- indices_to_process = list(range(len(groups)))
1029
-
1030
1028
  commands = []
1031
- for i in indices_to_process:
1032
- label = self.prefix if len(indices_to_process) == 1 else f"{self.prefix}_{i}"
1033
- atom_list = ",".join(str(idx + 1) for idx in groups[i])
1034
- command = f"{label}: GYRATION ATOMS={atom_list}"
1029
+
1030
+ if self.flatten:
1031
+ # Combine all groups into single atom list
1032
+ flat_atoms = [idx for group in groups for idx in group]
1033
+ atom_list = ",".join(str(idx + 1) for idx in flat_atoms)
1034
+ command = f"{self.prefix}: GYRATION ATOMS={atom_list}"
1035
1035
  if self.type != "RADIUS":
1036
1036
  command += f" TYPE={self.type}"
1037
1037
  commands.append(command)
1038
+ else:
1039
+ # Keep groups separate and use strategy to determine which to process
1040
+ if self.strategy == "first" and groups:
1041
+ indices_to_process = [0]
1042
+ else: # "all" - process all groups independently
1043
+ indices_to_process = list(range(len(groups)))
1044
+
1045
+ for i in indices_to_process:
1046
+ label = self.prefix if len(indices_to_process) == 1 else f"{self.prefix}_{i}"
1047
+ atom_list = ",".join(str(idx + 1) for idx in groups[i])
1048
+ command = f"{label}: GYRATION ATOMS={atom_list}"
1049
+ if self.type != "RADIUS":
1050
+ command += f" TYPE={self.type}"
1051
+ commands.append(command)
1052
+
1038
1053
  return commands
@@ -1,4 +1,5 @@
1
1
  import dataclasses
2
+ import typing as t
2
3
  from pathlib import Path
3
4
 
4
5
  import ase.units
@@ -21,11 +22,14 @@ class MetadBias:
21
22
  cv : CollectiveVariable
22
23
  The collective variable to bias.
23
24
  sigma : float, optional
24
- The width of the Gaussian potential, by default None.
25
+ The width of the Gaussian potential in the same units as the CV
26
+ (e.g., Å for distances, radians for angles), by default None.
25
27
  grid_min : float | str, optional
26
- The minimum value of the grid, by default None.
28
+ The minimum value of the grid in CV units (or PLUMED expression like "-pi"),
29
+ by default None.
27
30
  grid_max : float | str, optional
28
- The maximum value of the grid, by default None.
31
+ The maximum value of the grid in CV units (or PLUMED expression like "pi"),
32
+ by default None.
29
33
  grid_bin : int, optional
30
34
  The number of bins in the grid, by default None.
31
35
 
@@ -47,22 +51,32 @@ class MetaDynamicsConfig:
47
51
 
48
52
  This contains only the global parameters that apply to all CVs.
49
53
 
54
+ Units
55
+ -----
56
+ hillclimber uses ASE units throughout. The UNITS line in the PLUMED input tells
57
+ PLUMED to interpret all values in ASE units:
58
+ - Distances: Ångström (Å)
59
+ - Energies: electronvolt (eV) - including HEIGHT, SIGMA for energy-based CVs, etc.
60
+ - Time: femtoseconds (fs)
61
+ - Temperature: Kelvin (K)
62
+
50
63
  Parameters
51
64
  ----------
52
65
  height : float, optional
53
- The height of the Gaussian potential in kJ/mol, by default 1.0.
66
+ The height of the Gaussian potential in eV, by default 1.0.
54
67
  pace : int, optional
55
- The frequency of Gaussian deposition, by default 500.
68
+ The frequency of Gaussian deposition in MD steps, by default 500.
56
69
  biasfactor : float, optional
57
70
  The bias factor for well-tempered metadynamics, by default None.
58
71
  temp : float, optional
59
72
  The temperature of the system in Kelvin, by default 300.0.
60
73
  file : str, optional
61
74
  The name of the hills file, by default "HILLS".
62
- adaptive : str, optional
63
- The adaptive scheme to use, by default "NONE".
75
+ adaptive : t.Literal["GEOM", "DIFF"] | None, optional
76
+ The adaptive scheme to use, by default None.
77
+ If None, no ADAPTIVE parameter is written to PLUMED.
64
78
  flush : int | None
65
- The frequency of flushing the output files.
79
+ The frequency of flushing the output files in MD steps.
66
80
  If None, uses the plumed default.
67
81
 
68
82
  Resources
@@ -76,7 +90,7 @@ class MetaDynamicsConfig:
76
90
  biasfactor: float | None = None
77
91
  temp: float = 300.0
78
92
  file: str = "HILLS"
79
- adaptive: str = "NONE" # NONE, DIFF, GEOM
93
+ adaptive: t.Literal["GEOM", "DIFF"] | None = None
80
94
  flush: int | None = None
81
95
 
82
96
 
@@ -187,8 +201,13 @@ class MetaDynamicsModel(zntrack.Node, NodeWithCalculator):
187
201
 
188
202
  sigmas, grid_mins, grid_maxs, grid_bins = [], [], [], []
189
203
 
204
+ # PLUMED UNITS line specifies conversion factors from ASE units to PLUMED's native units:
205
+ # - LENGTH=A: ASE uses Ångström (A), PLUMED native is nm → A is a valid PLUMED unit
206
+ # - TIME: ASE uses fs, PLUMED native is ps → 1 fs = 0.001 ps
207
+ # - ENERGY: ASE uses eV, PLUMED native is kJ/mol → 1 eV = 96.485 kJ/mol
208
+ # See: https://www.plumed.org/doc-master/user-doc/html/ (MD engine integration docs)
190
209
  plumed_lines.append(
191
- f"UNITS LENGTH=A TIME={1 / (1000 * ase.units.fs)} ENERGY={ase.units.mol / ase.units.kJ}"
210
+ f"UNITS LENGTH=A TIME={1/1000} ENERGY={ase.units.mol / ase.units.kJ}"
192
211
  )
193
212
 
194
213
  for bias_cv in self.bias_cvs:
@@ -196,17 +215,20 @@ class MetaDynamicsModel(zntrack.Node, NodeWithCalculator):
196
215
  plumed_lines.extend(cv_str)
197
216
  all_labels.extend(labels)
198
217
 
199
- # Collect per-CV parameters for later
200
- sigmas.append(str(bias_cv.sigma) if bias_cv.sigma is not None else None)
201
- grid_mins.append(
202
- str(bias_cv.grid_min) if bias_cv.grid_min is not None else None
203
- )
204
- grid_maxs.append(
205
- str(bias_cv.grid_max) if bias_cv.grid_max is not None else None
206
- )
207
- grid_bins.append(
208
- str(bias_cv.grid_bin) if bias_cv.grid_bin is not None else None
209
- )
218
+ # Collect per-CV parameters for later - repeat for each label
219
+ # PLUMED requires one parameter value per ARG, so if a CV generates
220
+ # multiple labels, we need to repeat the parameter values
221
+ for _ in labels:
222
+ sigmas.append(str(bias_cv.sigma) if bias_cv.sigma is not None else None)
223
+ grid_mins.append(
224
+ str(bias_cv.grid_min) if bias_cv.grid_min is not None else None
225
+ )
226
+ grid_maxs.append(
227
+ str(bias_cv.grid_max) if bias_cv.grid_max is not None else None
228
+ )
229
+ grid_bins.append(
230
+ str(bias_cv.grid_bin) if bias_cv.grid_bin is not None else None
231
+ )
210
232
 
211
233
  metad_parts = [
212
234
  "METAD",
@@ -215,16 +237,31 @@ class MetaDynamicsModel(zntrack.Node, NodeWithCalculator):
215
237
  f"PACE={self.config.pace}",
216
238
  f"TEMP={self.config.temp}",
217
239
  f"FILE={self.config.file}",
218
- f"ADAPTIVE={self.config.adaptive}",
219
240
  ]
241
+ if self.config.adaptive is not None:
242
+ metad_parts.append(f"ADAPTIVE={self.config.adaptive}")
220
243
  if self.config.biasfactor is not None:
221
244
  metad_parts.append(f"BIASFACTOR={self.config.biasfactor}")
222
245
 
223
246
  # Add SIGMA, GRID_MIN, GRID_MAX, GRID_BIN only if any value is set
224
247
  if any(v is not None for v in sigmas):
225
- metad_parts.append(
226
- f"SIGMA={','.join(v if v is not None else '0.0' for v in sigmas)}"
227
- )
248
+ # When using ADAPTIVE, PLUMED requires only one sigma value
249
+ if self.config.adaptive is not None:
250
+ # Validate that all sigma values are the same when adaptive is set
251
+ unique_sigmas = set(v for v in sigmas if v is not None)
252
+ if len(unique_sigmas) > 1:
253
+ raise ValueError(
254
+ f"When using ADAPTIVE={self.config.adaptive}, all CVs must have the same sigma value. "
255
+ f"Found different sigma values: {unique_sigmas}"
256
+ )
257
+ # Use the first non-None sigma value
258
+ sigma_value = next(v for v in sigmas if v is not None)
259
+ metad_parts.append(f"SIGMA={sigma_value}")
260
+ else:
261
+ # Standard mode: one sigma per CV
262
+ metad_parts.append(
263
+ f"SIGMA={','.join(v if v is not None else '0.0' for v in sigmas)}"
264
+ )
228
265
  if any(v is not None for v in grid_mins):
229
266
  metad_parts.append(
230
267
  f"GRID_MIN={','.join(v if v is not None else '0.0' for v in grid_mins)}"
@@ -240,10 +277,47 @@ class MetaDynamicsModel(zntrack.Node, NodeWithCalculator):
240
277
 
241
278
  plumed_lines.append(f"metad: {' '.join(metad_parts)}")
242
279
 
280
+ # Track defined commands to detect duplicates and conflicts
281
+ # Map label -> full command for labeled commands (e.g., "d: DISTANCE ...")
282
+ defined_commands = {}
283
+ for line in plumed_lines:
284
+ # Check if this is a labeled command (format: "label: ACTION ...")
285
+ if ": " in line:
286
+ label = line.split(": ", 1)[0]
287
+ defined_commands[label] = line
288
+
243
289
  # Add any additional actions (restraints, walls, print actions, etc.)
244
290
  for action in self.actions:
245
291
  action_lines = action.to_plumed(atoms)
246
- plumed_lines.extend(action_lines)
292
+
293
+ # Filter out duplicate CV definitions, but detect conflicts
294
+ filtered_lines = []
295
+ for line in action_lines:
296
+ # Check if this is a labeled command
297
+ if ": " in line:
298
+ label = line.split(": ", 1)[0]
299
+
300
+ # Check if this label was already defined
301
+ if label in defined_commands:
302
+ # If the command is identical, skip (deduplication)
303
+ if defined_commands[label] == line:
304
+ continue
305
+ # If the command is different, raise error (conflict)
306
+ else:
307
+ raise ValueError(
308
+ f"Conflicting definitions for label '{label}':\n"
309
+ f" Already defined: {defined_commands[label]}\n"
310
+ f" New definition: {line}"
311
+ )
312
+ else:
313
+ # New labeled command, track it
314
+ defined_commands[label] = line
315
+ filtered_lines.append(line)
316
+ else:
317
+ # Unlabeled command, always add
318
+ filtered_lines.append(line)
319
+
320
+ plumed_lines.extend(filtered_lines)
247
321
 
248
322
  # Add FLUSH if configured
249
323
  if self.config.flush is not None:
hillclimber/opes.py CHANGED
@@ -27,8 +27,9 @@ class OPESBias:
27
27
  cv : CollectiveVariable
28
28
  The collective variable to bias.
29
29
  sigma : float | str, optional
30
- Initial kernel width. Use "ADAPTIVE" for automatic adaptation
31
- (recommended). If numeric, specifies the initial width.
30
+ Initial kernel width in CV units (e.g., Å for distances, radians for angles).
31
+ Use "ADAPTIVE" for automatic adaptation (recommended).
32
+ If numeric, specifies the initial width.
32
33
  Default: "ADAPTIVE".
33
34
 
34
35
  Resources
@@ -53,10 +54,19 @@ class OPESConfig:
53
54
  OPES (On-the-fly Probability Enhanced Sampling) is a modern enhanced
54
55
  sampling method that samples well-tempered target distributions.
55
56
 
57
+ Units
58
+ -----
59
+ hillclimber uses ASE units throughout. The UNITS line in the PLUMED input tells
60
+ PLUMED to interpret all values in ASE units:
61
+ - Distances: Ångström (Å)
62
+ - Energies: electronvolt (eV) - including BARRIER, SIGMA_MIN, etc.
63
+ - Time: femtoseconds (fs)
64
+ - Temperature: Kelvin (K)
65
+
56
66
  Parameters
57
67
  ----------
58
68
  barrier : float
59
- Highest free energy barrier to overcome (kJ/mol). This is the key
69
+ Highest free energy barrier to overcome (eV). This is the key
60
70
  parameter that determines sampling efficiency.
61
71
  pace : int, optional
62
72
  Frequency of kernel deposition in MD steps (default: 500).
@@ -75,9 +85,9 @@ class OPESConfig:
75
85
  file : str, optional
76
86
  File to store deposited kernels (default: "KERNELS").
77
87
  adaptive_sigma_stride : int, optional
78
- Steps between adaptive sigma measurements. If not set, uses 10×PACE.
88
+ MD steps between adaptive sigma measurements. If not set, uses 10×PACE.
79
89
  sigma_min : float, optional
80
- Minimum allowable sigma value for adaptive sigma.
90
+ Minimum allowable sigma value for adaptive sigma in CV units.
81
91
  state_wfile : str, optional
82
92
  State file for writing exact restart information.
83
93
  state_rfile : str, optional
@@ -89,7 +99,7 @@ class OPESConfig:
89
99
  calc_work : bool, optional
90
100
  Calculate and output accumulated work (default: False).
91
101
  flush : int, optional
92
- Frequency of flushing output files.
102
+ Frequency of flushing output files in MD steps.
93
103
 
94
104
  Resources
95
105
  ---------
@@ -272,8 +282,13 @@ class OPESModel(zntrack.Node, NodeWithCalculator):
272
282
 
273
283
  sigmas = []
274
284
 
285
+ # PLUMED UNITS line specifies conversion factors from ASE units to PLUMED's native units:
286
+ # - LENGTH=A: ASE uses Ångström (A), PLUMED native is nm → A is a valid PLUMED unit
287
+ # - TIME: ASE uses fs, PLUMED native is ps → 1 fs = 0.001 ps
288
+ # - ENERGY: ASE uses eV, PLUMED native is kJ/mol → 1 eV = 96.485 kJ/mol
289
+ # See: https://www.plumed.org/doc-master/user-doc/html/ (MD engine integration docs)
275
290
  plumed_lines.append(
276
- f"UNITS LENGTH=A TIME={1 / (1000 * ase.units.fs)} ENERGY={ase.units.mol / ase.units.kJ}"
291
+ f"UNITS LENGTH=A TIME={1/1000} ENERGY={ase.units.mol / ase.units.kJ}"
277
292
  )
278
293
 
279
294
  for bias_cv in self.bias_cvs:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: hillclimber
3
- Version: 0.1.0a2
3
+ Version: 0.1.0a3
4
4
  Summary: Python interfaces for the plumed library with enhanced sampling.
5
5
  License-File: LICENSE
6
6
  Requires-Python: >=3.11
@@ -124,6 +124,64 @@ with project:
124
124
  project.build()
125
125
  ```
126
126
 
127
+ ## Units
128
+
129
+ hillclimber uses **ASE units** throughout the package:
130
+
131
+ - **Distances**: Ångström (Å)
132
+ - **Energies**: electronvolt (eV)
133
+ - **Time**: femtoseconds (fs)
134
+ - **Temperature**: Kelvin (K)
135
+ - **Mass**: atomic mass units (amu)
136
+
137
+ ### Unit Conversion with PLUMED
138
+
139
+ PLUMED internally uses different units (nm, kJ/mol, ps). hillclimber automatically handles the conversion by adding a `UNITS` line to the PLUMED input:
140
+
141
+ ```
142
+ UNITS LENGTH=A TIME=0.001 ENERGY=96.48533288249877
143
+ ```
144
+
145
+ This tells PLUMED to interpret all input parameters in ASE units:
146
+ - `LENGTH=A`: Distances are in Ångström
147
+ - `TIME=0.001`: Time values are in fs (1 fs = 0.001 ps)
148
+ - `ENERGY=96.485`: Energies are in eV (1 eV = 96.485 kJ/mol)
149
+
150
+ **All PLUMED parameters (HEIGHT, BARRIER, KAPPA, SIGMA, etc.) should be specified in ASE units (eV, Å, fs).** The UNITS line ensures PLUMED interprets them correctly.
151
+
152
+ ### Example with Units
153
+
154
+ ```python
155
+ config = hc.MetaDynamicsConfig(
156
+ height=0.5, # eV - Gaussian hill height
157
+ pace=150, # MD steps - deposition frequency
158
+ biasfactor=10.0, # Dimensionless - well-tempered factor
159
+ temp=300.0 # Kelvin - system temperature
160
+ )
161
+
162
+ bias = hc.MetadBias(
163
+ cv=distance_cv,
164
+ sigma=0.1, # Å - Gaussian width for distance CV
165
+ grid_min=0.0, # Å - minimum grid value
166
+ grid_max=5.0, # Å - maximum grid value
167
+ grid_bin=100 # Number of bins
168
+ )
169
+
170
+ # Restraint example
171
+ restraint = hc.RestraintBias(
172
+ cv=distance_cv,
173
+ kappa=200.0, # eV/Ų - force constant
174
+ at=2.5 # Å - restraint center
175
+ )
176
+
177
+ metad_model = hc.MetaDynamicsModel(
178
+ config=config,
179
+ bias_cvs=[bias],
180
+ actions=[restraint],
181
+ timestep=0.5 # fs - MD timestep
182
+ )
183
+ ```
184
+
127
185
  ## Collective Variables
128
186
 
129
187
  hillclimber supports multiple types of collective variables:
@@ -0,0 +1,17 @@
1
+ hillclimber/__init__.py,sha256=OoxVZ1pPvRVHY1VVShDBaEGIF06O5fDFxCSGUNGW3m4,1041
2
+ hillclimber/actions.py,sha256=7s78RWP-YnBbbxZyA65OwnyL60v8LnKHyNalui1ypAo,1514
3
+ hillclimber/analysis.py,sha256=2KrlSVRNdajZBFCm8AZBjTixTeZczbu5ya4laLXEZP0,20674
4
+ hillclimber/biases.py,sha256=TbEM19NUOuaTUchzEGNIM8M4TDmtlE5Ss8e9VBbHr5s,9448
5
+ hillclimber/calc.py,sha256=dqanaBF6BJwP6lHQqFqEIng-3bTN_DcddRV-gceboKs,665
6
+ hillclimber/cvs.py,sha256=AWC7Z3h0CyAO7_wq-WdLhNi3spH456Blik60_M99MDA,39934
7
+ hillclimber/interfaces.py,sha256=H4HKN1HldhNJeooqtS-HpJLrCFqpMPr80aPER4SKiao,3807
8
+ hillclimber/metadynamics.py,sha256=1Kn5VhlrGlcVY9g41dNCEbJX-7j5ZEStcM8UGgTkCfI,12636
9
+ hillclimber/nodes.py,sha256=XL9uEXd2HdW2mlbwriG_fMCkZAaz4uZBOI5edO42YDA,145
10
+ hillclimber/opes.py,sha256=F_1daa9xB-wsNyow6sIfxJG2nyOSyCc-vVRuP5ADWdE,13310
11
+ hillclimber/selectors.py,sha256=VoWMvTnKU9vr0dphqxGk1OdhrabbDkzq4GQkeprd6RQ,7931
12
+ hillclimber/virtual_atoms.py,sha256=GVXCJZlbx1cY_ST2G5NHQsGpdMkBLUz04aFm-cD--OA,12270
13
+ hillclimber-0.1.0a3.dist-info/METADATA,sha256=UOw07yd2AJiq8CY2a3myuqS9Udy8y_1nb6gMcHNd0QA,12980
14
+ hillclimber-0.1.0a3.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
15
+ hillclimber-0.1.0a3.dist-info/entry_points.txt,sha256=RsCL3TDKfieatIWP9JHjmTzMtgWERqwpuuuDPdQ4t5g,124
16
+ hillclimber-0.1.0a3.dist-info/licenses/LICENSE,sha256=FKf4VPZYbuyRVMVSrl6HO48bnw6ih8Uur5y-h_MJAcA,13576
17
+ hillclimber-0.1.0a3.dist-info/RECORD,,
@@ -1,16 +0,0 @@
1
- hillclimber/__init__.py,sha256=MpTyTiou1ACRu6snlCe3Aja3_znqakX-YPV1notCdvA,901
2
- hillclimber/actions.py,sha256=7s78RWP-YnBbbxZyA65OwnyL60v8LnKHyNalui1ypAo,1514
3
- hillclimber/biases.py,sha256=OfqKdGNiN2Yk4tP03nzJ9vxBWLjLRvcjgJAS8gZeFHw,9317
4
- hillclimber/calc.py,sha256=dqanaBF6BJwP6lHQqFqEIng-3bTN_DcddRV-gceboKs,665
5
- hillclimber/cvs.py,sha256=ag1gr51D1-NJb7tkdeUQdjYfWy0WBfcMQKE1f-thFAQ,39548
6
- hillclimber/interfaces.py,sha256=H4HKN1HldhNJeooqtS-HpJLrCFqpMPr80aPER4SKiao,3807
7
- hillclimber/metadynamics.py,sha256=NwNG5THSjmJInBDLFuIKp3cB8P-kE8BAaftZdnSk9J0,8719
8
- hillclimber/nodes.py,sha256=XL9uEXd2HdW2mlbwriG_fMCkZAaz4uZBOI5edO42YDA,145
9
- hillclimber/opes.py,sha256=NYY2zcBtBxXrFcWQKplrIkthgnW03WTh4hi9MtDsXqo,12483
10
- hillclimber/selectors.py,sha256=VoWMvTnKU9vr0dphqxGk1OdhrabbDkzq4GQkeprd6RQ,7931
11
- hillclimber/virtual_atoms.py,sha256=GVXCJZlbx1cY_ST2G5NHQsGpdMkBLUz04aFm-cD--OA,12270
12
- hillclimber-0.1.0a2.dist-info/METADATA,sha256=JZgeoAZPviWVz59IgjQsoWYnNuFoXbzIg-vJJDOxcTU,11254
13
- hillclimber-0.1.0a2.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
14
- hillclimber-0.1.0a2.dist-info/entry_points.txt,sha256=RsCL3TDKfieatIWP9JHjmTzMtgWERqwpuuuDPdQ4t5g,124
15
- hillclimber-0.1.0a2.dist-info/licenses/LICENSE,sha256=FKf4VPZYbuyRVMVSrl6HO48bnw6ih8Uur5y-h_MJAcA,13576
16
- hillclimber-0.1.0a2.dist-info/RECORD,,