bfflearn 0.1.0.dev1__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.
Files changed (55) hide show
  1. bff/__init__.py +30 -0
  2. bff/bayes/__init__.py +11 -0
  3. bff/bayes/gaussian_process.py +285 -0
  4. bff/bayes/kernels.py +71 -0
  5. bff/bayes/learning.py +484 -0
  6. bff/bayes/likelihoods.py +142 -0
  7. bff/bayes/posterior.py +62 -0
  8. bff/bayes/priors.py +179 -0
  9. bff/bayes/results.py +327 -0
  10. bff/bayes/utils.py +333 -0
  11. bff/cli.py +254 -0
  12. bff/data.py +93 -0
  13. bff/domain/__init__.py +12 -0
  14. bff/domain/bias.py +149 -0
  15. bff/domain/specs.py +347 -0
  16. bff/domain/trainset.py +201 -0
  17. bff/io/__init__.py +12 -0
  18. bff/io/colvars.py +186 -0
  19. bff/io/commands.py +20 -0
  20. bff/io/cp2k.py +330 -0
  21. bff/io/logs.py +213 -0
  22. bff/io/mdp.py +98 -0
  23. bff/io/plumed.py +184 -0
  24. bff/io/progress.py +74 -0
  25. bff/io/schedulers.py +118 -0
  26. bff/io/utils.py +208 -0
  27. bff/mcmc/__init__.py +3 -0
  28. bff/mcmc/convergence.py +168 -0
  29. bff/mcmc/proposal.py +254 -0
  30. bff/mcmc/sampler.py +481 -0
  31. bff/plotting.py +362 -0
  32. bff/qoi/__init__.py +9 -0
  33. bff/qoi/analysis.py +209 -0
  34. bff/qoi/data.py +169 -0
  35. bff/qoi/hbonds.py +255 -0
  36. bff/qoi/rdf.py +108 -0
  37. bff/qoi/routines.py +272 -0
  38. bff/tools.py +168 -0
  39. bff/topology.py +581 -0
  40. bff/workflows/__init__.py +0 -0
  41. bff/workflows/configs.py +1147 -0
  42. bff/workflows/learn.py +77 -0
  43. bff/workflows/md.py +326 -0
  44. bff/workflows/prepare.py +788 -0
  45. bff/workflows/qoi.py +260 -0
  46. bff/workflows/runsims.py +620 -0
  47. bff/workflows/simulate.py +28 -0
  48. bff/workflows/train.py +69 -0
  49. bff/workflows/validate.py +29 -0
  50. bfflearn-0.1.0.dev1.dist-info/METADATA +913 -0
  51. bfflearn-0.1.0.dev1.dist-info/RECORD +55 -0
  52. bfflearn-0.1.0.dev1.dist-info/WHEEL +5 -0
  53. bfflearn-0.1.0.dev1.dist-info/entry_points.txt +2 -0
  54. bfflearn-0.1.0.dev1.dist-info/licenses/LICENSE +674 -0
  55. bfflearn-0.1.0.dev1.dist-info/top_level.txt +1 -0
bff/io/logs.py ADDED
@@ -0,0 +1,213 @@
1
+ import logging
2
+ from typing import Optional
3
+
4
+ import numpy as np
5
+
6
+ from ..mcmc.sampler import Sampler
7
+
8
+
9
+ class Logger:
10
+ """
11
+ A logging utility that supports logging to a file
12
+ or console without duplicate output.
13
+ """
14
+
15
+ def __init__(
16
+ self,
17
+ name: str,
18
+ fn_log: Optional[str] = None,
19
+ width: Optional[int] = 100,
20
+ verbose: bool = True,
21
+ mode: str = "a",
22
+ ) -> None:
23
+
24
+ """
25
+ Parameters
26
+ ----------
27
+ name : str
28
+ Name of the logger.
29
+ fn_log : Optional[str], optional
30
+ Filename to log messages to. If None, logs to console.
31
+ width : Optional[int], default=100
32
+ Width for message formatting.
33
+ verbose : bool, default=True
34
+ If False, suppresses logging output.
35
+ mode : str, default="a"
36
+ File open mode used when ``fn_log`` is provided.
37
+ """
38
+
39
+ self.fn_log = fn_log
40
+ self.width = width
41
+ self.verbose = verbose
42
+
43
+ self.logger = logging.getLogger(name)
44
+ self.logger.setLevel(logging.INFO)
45
+
46
+ # Remove old handlers if re-running in Jupyter
47
+ if self.logger.hasHandlers():
48
+ self.logger.handlers.clear()
49
+
50
+ formatter = logging.Formatter("%(message)s")
51
+
52
+ if fn_log:
53
+ handler = logging.FileHandler(fn_log, mode=mode)
54
+ else:
55
+ handler = logging.StreamHandler()
56
+ handler.terminator = ""
57
+ handler.setFormatter(formatter)
58
+ self.logger.addHandler(handler)
59
+
60
+ self.logger.propagate = False # Prevent double logging
61
+
62
+ def info(self, message: str, level: int, overwrite: bool = False) -> None:
63
+ """
64
+ Logs a message to a file or console, optionally overwriting the console output.
65
+
66
+ Parameters
67
+ ----------
68
+ message : str
69
+ The message to log.
70
+ overwrite : bool, default=False
71
+ If True, the message overwrites the previous console output (stdout only).
72
+ """
73
+
74
+ # determine width of the line automatically if not set
75
+ width = len(message) + 10 if self.width is None else self.width
76
+
77
+ if self.verbose:
78
+ if level < 1:
79
+ start = ''
80
+ elif level == 1:
81
+ start = '> '
82
+ else:
83
+ start = ' ' * 2 * (level - 1) + '- '
84
+ message = start + f"{message}".ljust(width)
85
+ if self.fn_log:
86
+ self.logger.info(message) # Always log to file if specified
87
+
88
+ else:
89
+ terminator = '\r' if overwrite else '\n'
90
+ self.logger.info(message + terminator) # Log to logger (console)
91
+
92
+
93
+ def print_progress_mcmc(
94
+ sampler: Sampler,
95
+ p0: np.ndarray,
96
+ *,
97
+ total_steps: int,
98
+ restart: bool = True,
99
+ fn_checkpoint: Optional[str] = None,
100
+ logger: Optional[Logger] = None,
101
+ **kwargs
102
+ ) -> None:
103
+ """
104
+ Monitors and logs the convergence progress of an MCMC sampler.
105
+
106
+ Parameters
107
+ ----------
108
+ sampler : Sampler
109
+ The MCMC sampler with methods
110
+ `sample`, `get_autocorr_time`, and attribute `iteration`.
111
+ p0 : array-like
112
+ Initial parameter vector for the MCMC posterior sampling.
113
+ total_steps : int
114
+ Total number of MCMC iterations including warmup.
115
+ stride : int, default=100
116
+ Frequency of autocorrelation time checks and printing.
117
+ min_chain_length : int, default=100
118
+ Factor to multiply the autocorrelation time with in order to
119
+ get the minimum chain length to have proper sampling.
120
+ rtol : float, default=0.01
121
+ Relative tolerance of the autocorrelation time fluctuations
122
+ for the MCMC sampling to be considered converged.
123
+ logger : Logger, optional
124
+ Logger instance for logging progress messages.
125
+ **kwargs
126
+ Additional arguments passed to the sampler's `sample` method.
127
+ """
128
+
129
+ logger = logger or Logger("mcmc-progress")
130
+
131
+ rhat_tol = kwargs.get("rhat_tol", 1.01)
132
+ ess_target = kwargs.get("ess_min", 100)
133
+ total_digits = len(str(total_steps))
134
+ warmup_digits = len(str(kwargs.get("warmup", total_steps)))
135
+ sampling_digits = len(str(max(total_steps - kwargs.get("warmup", 0), 0)))
136
+ ess_digits = max(len(str(int(ess_target))), 1)
137
+
138
+ def _fmt_progress(current: int, total: int, width: int, label: str) -> str:
139
+ return f"{label}: {current:>{width}d}/{total:<{width}d}"
140
+
141
+ if fn_checkpoint is not None:
142
+ logger.info(f"Checkpoint: {fn_checkpoint}", level=2)
143
+
144
+ logger.info(
145
+ (
146
+ f"Posterior sampling: it. {0:>{total_digits}d}/"
147
+ f"{total_steps:<{total_digits}d}"
148
+ ),
149
+ level=1,
150
+ overwrite=True,
151
+ )
152
+
153
+ line = ""
154
+ for state in sampler.run(
155
+ p0,
156
+ total_steps=total_steps,
157
+ restart=restart,
158
+ fn_checkpoint=fn_checkpoint,
159
+ **kwargs,
160
+ ):
161
+ total_steps = state.total_steps
162
+ sampling_steps = total_steps - state.warmup
163
+ if state.phase == "warmup":
164
+ phase_progress = _fmt_progress(
165
+ state.step,
166
+ state.warmup,
167
+ warmup_digits,
168
+ "warmup",
169
+ )
170
+ else:
171
+ sampling_step = max(state.step - state.warmup, 0)
172
+ phase_progress = _fmt_progress(
173
+ sampling_step,
174
+ sampling_steps,
175
+ sampling_digits,
176
+ "sampling",
177
+ )
178
+
179
+ if state.phase == "sampling" and state.convergence is not None:
180
+ line = (
181
+ f"Posterior sampling: it. "
182
+ f"{state.step:>{total_digits}d}/{total_steps:<{total_digits}d} | "
183
+ f"{phase_progress} | "
184
+ f"R-hat max: {state.convergence.max_rhat:.4f}/{rhat_tol:.4f} | "
185
+ f"ESS min: {state.convergence.min_ess:>{ess_digits}.0f}/"
186
+ f"{ess_target:<{ess_digits}.0f}"
187
+ )
188
+ if state.acceptance_rate is not None:
189
+ line += f" | acc: {state.acceptance_rate:.3f}"
190
+ if state.it_per_sec is not None:
191
+ line += f" | {state.it_per_sec:>3.0f} it/s"
192
+ else:
193
+ line = (
194
+ f"Posterior sampling: it. "
195
+ f"{state.step:>{total_digits}d}/{total_steps:<{total_digits}d} | "
196
+ f"{phase_progress}"
197
+ )
198
+ if state.acceptance_rate is not None:
199
+ line += f" | acc: {state.acceptance_rate:.3f}"
200
+ if state.it_per_sec is not None:
201
+ line += f" | {state.it_per_sec:>3.0f} it/s"
202
+ logger.info(line, level=1, overwrite=True)
203
+
204
+ if line and logger.fn_log is None:
205
+ logger.info(line, level=1)
206
+ logger.info("", level=0)
207
+ if sampler.converged:
208
+ logger.info("Posterior sampling: Done.", level=1)
209
+ else:
210
+ logger.info(
211
+ "Posterior sampling: Failed to converge within the maximum iterations.",
212
+ level=1,
213
+ )
bff/io/mdp.py ADDED
@@ -0,0 +1,98 @@
1
+ from collections import OrderedDict
2
+ from pathlib import Path
3
+ from typing import Mapping, Union
4
+
5
+ PathLike = Union[str, Path]
6
+
7
+
8
+ def read_mdp(fn_mdp: PathLike) -> OrderedDict[str, str]:
9
+ """Read a GROMACS MDP file while preserving comments and blank lines.
10
+
11
+ Parameters
12
+ ----------
13
+ fn_mdp
14
+ Path to the input MDP file.
15
+
16
+ Returns
17
+ -------
18
+ collections.OrderedDict
19
+ Ordered mapping of MDP keys to values. Comment and blank lines are
20
+ stored under synthetic keys starting with ``C`` and ``B``.
21
+ """
22
+ content: OrderedDict[str, str] = OrderedDict()
23
+ i_comment = 0
24
+ i_blank = 0
25
+ with open(fn_mdp, "r") as handle:
26
+ for line in handle:
27
+ if line.startswith(";"):
28
+ content[f"C{i_comment:03d}"] = line
29
+ i_comment += 1
30
+ elif line.strip() == "":
31
+ content[f"B{i_blank:03d}"] = line
32
+ i_blank += 1
33
+ else:
34
+ key, value = line.split("=", 1)
35
+ content[key.strip()] = value.strip()
36
+ return content
37
+
38
+
39
+ def write_mdp(content: Mapping[str, str], fn_out: PathLike) -> None:
40
+ """Write an MDP mapping back to disk.
41
+
42
+ Parameters
43
+ ----------
44
+ content
45
+ Ordered MDP mapping produced by :func:`read_mdp`.
46
+ fn_out
47
+ Output MDP path.
48
+ """
49
+ with open(fn_out, "w") as handle:
50
+ for key, value in content.items():
51
+ if key.startswith("C") or key.startswith("B"):
52
+ handle.write(value)
53
+ else:
54
+ handle.write(f"{key:<25} = {value}\n")
55
+
56
+
57
+ def patch_mdp(
58
+ fn_mdp: PathLike,
59
+ updates: Mapping[str, str],
60
+ fn_out: PathLike,
61
+ ) -> None:
62
+ """Write a modified MDP file with a small set of updated parameters.
63
+
64
+ Parameters
65
+ ----------
66
+ fn_mdp
67
+ Input MDP file.
68
+ updates
69
+ Mapping of MDP keys to replacement values.
70
+ fn_out
71
+ Output MDP file.
72
+ """
73
+ content = read_mdp(fn_mdp)
74
+ for key, value in updates.items():
75
+ content[str(key)] = str(value)
76
+ write_mdp(content, fn_out)
77
+
78
+
79
+ def get_n_frames_target(fn_mdp: PathLike) -> tuple[int | None, int | None]:
80
+ """Extract the expected number of saved trajectory frames.
81
+
82
+ Parameters
83
+ ----------
84
+ fn_mdp
85
+ Input MDP file.
86
+
87
+ Returns
88
+ -------
89
+ tuple
90
+ Number of saved frames and trajectory stride. If ``nsteps`` is zero or
91
+ missing, both entries are returned as ``None``.
92
+ """
93
+ mdp_data = read_mdp(fn_mdp)
94
+ n_steps = int(mdp_data.get("nsteps", 0))
95
+ if n_steps <= 0:
96
+ return None, None
97
+ stride = int(mdp_data["nstxout-compressed"])
98
+ return int(n_steps / stride), stride
bff/io/plumed.py ADDED
@@ -0,0 +1,184 @@
1
+ import os
2
+ import re
3
+ import shutil
4
+ import sys
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+
8
+ import MDAnalysis as mda
9
+
10
+ PathLike = str | Path
11
+
12
+
13
+ @dataclass(frozen=True, slots=True)
14
+ class PlumedDistanceBias:
15
+ """Parsed distance bias from a PLUMED file.
16
+
17
+ Parameters
18
+ ----------
19
+ name
20
+ Name of the distance collective variable.
21
+ atom_indices
22
+ One-based atom indices defining the distance.
23
+ center
24
+ Optional restraint center in nm.
25
+ """
26
+
27
+ name: str
28
+ atom_indices: tuple[int, int]
29
+ center: float | None = None
30
+
31
+
32
+ def _parse_arguments(text: str) -> dict[str, str]:
33
+ """Parse key-value arguments from one PLUMED line."""
34
+ pairs = re.findall(r"([A-Za-z_]+)=([^\s]+)", text)
35
+ return {key.upper(): value for key, value in pairs}
36
+
37
+
38
+ def find_plumed_kernel() -> Path | None:
39
+ """Locate a PLUMED kernel library if one is available.
40
+
41
+ Returns
42
+ -------
43
+ pathlib.Path or None
44
+ Path to a loadable PLUMED kernel library, or ``None`` if one could
45
+ not be located from the current environment.
46
+ """
47
+ env_value = os.environ.get("PLUMED_KERNEL")
48
+ if env_value:
49
+ env_path = Path(env_value).expanduser().resolve()
50
+ if env_path.exists():
51
+ return env_path
52
+
53
+ candidate_dirs = [
54
+ Path(sys.prefix) / "lib",
55
+ Path(sys.prefix).resolve().parent / "lib",
56
+ ]
57
+
58
+ plumed_executable = shutil.which("plumed")
59
+ if plumed_executable is not None:
60
+ candidate_dirs.append(Path(plumed_executable).resolve().parents[1] / "lib")
61
+
62
+ seen: set[Path] = set()
63
+ for lib_dir in candidate_dirs:
64
+ lib_dir = lib_dir.resolve()
65
+ if lib_dir in seen or not lib_dir.exists():
66
+ continue
67
+ seen.add(lib_dir)
68
+ candidates = sorted(lib_dir.glob("libplumedKernel*"))
69
+ if candidates:
70
+ return candidates[0]
71
+
72
+ return None
73
+
74
+
75
+ def ensure_plumed_kernel() -> Path:
76
+ """Ensure that a PLUMED kernel is available for biased MD runs.
77
+
78
+ Returns
79
+ -------
80
+ pathlib.Path
81
+ Path to the PLUMED kernel library.
82
+
83
+ Raises
84
+ ------
85
+ RuntimeError
86
+ If no PLUMED kernel can be located.
87
+ """
88
+ kernel = find_plumed_kernel()
89
+ if kernel is None:
90
+ raise RuntimeError(
91
+ "PLUMED biasing requires a loadable PLUMED kernel. "
92
+ "Set the PLUMED_KERNEL environment variable or install PLUMED "
93
+ "so that libplumedKernel is discoverable."
94
+ )
95
+ return kernel
96
+
97
+
98
+ def parse_distance_biases(fn_plumed: PathLike) -> list[PlumedDistanceBias]:
99
+ """Parse simple distance restraints from a PLUMED input file.
100
+
101
+ Parameters
102
+ ----------
103
+ fn_plumed
104
+ Path to the PLUMED input file.
105
+
106
+ Returns
107
+ -------
108
+ list of PlumedDistanceBias
109
+ Parsed distance collective variables with optional restraint centers.
110
+ """
111
+ lines = Path(fn_plumed).read_text().splitlines()
112
+ distances: dict[str, tuple[int, int]] = {}
113
+ centers: dict[str, float] = {}
114
+
115
+ for raw_line in lines:
116
+ line = raw_line.split("#", maxsplit=1)[0].strip()
117
+ if not line or ":" not in line:
118
+ continue
119
+
120
+ name, expression = [part.strip() for part in line.split(":", maxsplit=1)]
121
+ keyword = expression.split(maxsplit=1)[0].upper()
122
+ args = _parse_arguments(expression)
123
+
124
+ if keyword == "DISTANCE" and "ATOMS" in args:
125
+ atom_indices = tuple(int(value) for value in args["ATOMS"].split(","))
126
+ if len(atom_indices) == 2:
127
+ distances[name] = atom_indices # type: ignore[assignment]
128
+ continue
129
+
130
+ if keyword in {"RESTRAINT", "UPPER_WALLS", "LOWER_WALLS"}:
131
+ arg_name = args.get("ARG")
132
+ if arg_name is None:
133
+ continue
134
+ center_value = args.get("AT")
135
+ if center_value is None:
136
+ continue
137
+ try:
138
+ centers[arg_name] = float(center_value.split(",")[0])
139
+ except ValueError:
140
+ continue
141
+
142
+ return [
143
+ PlumedDistanceBias(
144
+ name=name,
145
+ atom_indices=atom_indices,
146
+ center=centers.get(name),
147
+ )
148
+ for name, atom_indices in distances.items()
149
+ ]
150
+
151
+
152
+ def resolve_distance_bias_metadata(
153
+ fn_plumed: PathLike,
154
+ fn_system: PathLike,
155
+ ) -> tuple[tuple[str, ...], tuple[float, ...]]:
156
+ """Resolve atom-name pairs and centers from a PLUMED file.
157
+
158
+ Parameters
159
+ ----------
160
+ fn_plumed
161
+ Path to the PLUMED input file.
162
+ fn_system
163
+ Structure file used to map atom indices to atom names.
164
+
165
+ Returns
166
+ -------
167
+ tuple
168
+ Tuple of ``(pair_labels, centers)`` where labels are atom-name pairs
169
+ and centers are restraint centers in nm.
170
+ """
171
+ universe = mda.Universe(str(fn_system))
172
+ distances = parse_distance_biases(fn_plumed)
173
+
174
+ labels: list[str] = []
175
+ centers: list[float] = []
176
+ for distance in distances:
177
+ index_1, index_2 = distance.atom_indices
178
+ atom_1 = universe.atoms[index_1 - 1].name
179
+ atom_2 = universe.atoms[index_2 - 1].name
180
+ labels.append(f"{atom_1} {atom_2}")
181
+ if distance.center is not None:
182
+ centers.append(distance.center)
183
+
184
+ return tuple(labels), tuple(centers)
bff/io/progress.py ADDED
@@ -0,0 +1,74 @@
1
+ import time
2
+ from typing import Any, Iterable, Iterator, TypeVar
3
+
4
+ T = TypeVar("T")
5
+
6
+
7
+ def format_time(seconds: float) -> str:
8
+ """Format seconds as hours, minutes, and seconds."""
9
+ hours, remainder = divmod(seconds, 3600)
10
+ minutes, seconds = divmod(remainder, 60)
11
+
12
+ if hours > 0:
13
+ return f"{int(hours)}h {int(minutes)}m {int(seconds)}s"
14
+ if minutes > 0:
15
+ return f"{int(minutes)}m {int(seconds)}s"
16
+ return f"{int(seconds)}s"
17
+
18
+
19
+ def iter_progress(
20
+ iterable: Iterable[T],
21
+ *,
22
+ total: int,
23
+ logger: Any,
24
+ label: str,
25
+ stride: int = 1,
26
+ ) -> Iterator[T]:
27
+ """Yield items while logging coarse progress updates."""
28
+ if stride < 1:
29
+ raise ValueError("'stride' must be a positive integer.")
30
+ if total < 0:
31
+ raise ValueError("'total' must be non-negative.")
32
+ if total == 0:
33
+ return
34
+
35
+ start_time = time.time()
36
+ pad = len(str(total))
37
+ template = (
38
+ f"{label}: " + "{i:>{pad}}/{total} "
39
+ "({percent:3.0f}%) | {elapsed} < {eta}"
40
+ )
41
+
42
+ logger.info(
43
+ template.format(
44
+ i=0,
45
+ pad=pad,
46
+ total=total,
47
+ percent=0,
48
+ elapsed="0s",
49
+ eta="0s",
50
+ ),
51
+ level=1,
52
+ overwrite=True,
53
+ )
54
+
55
+ for i, item in enumerate(iterable, start=1):
56
+ yield item
57
+
58
+ if (i % stride != 0) and (i != total):
59
+ continue
60
+
61
+ elapsed_time = time.time() - start_time
62
+ eta = 0.0 if i == 0 else (elapsed_time / i) * max(total - i, 0)
63
+ logger.info(
64
+ template.format(
65
+ i=i,
66
+ pad=pad,
67
+ total=total,
68
+ percent=100 * i / total if total else 100,
69
+ elapsed=format_time(elapsed_time),
70
+ eta=format_time(eta),
71
+ ),
72
+ level=1,
73
+ overwrite=True,
74
+ )
bff/io/schedulers.py ADDED
@@ -0,0 +1,118 @@
1
+ import subprocess
2
+ from pathlib import Path
3
+
4
+
5
+ class Slurm:
6
+ """
7
+ A class to generate and save SLURM job submission scripts.
8
+
9
+ Parameters
10
+ ----------
11
+ job_name : str
12
+ Name of the SLURM job.
13
+ nodes : int
14
+ Number of nodes to allocate.
15
+ ntasks_per_node : int
16
+ Number of tasks per node.
17
+ cpus_per_task : int
18
+ Number of CPUs per task.
19
+ time : str
20
+ Maximum wall time in the format 'HH:MM:SS'.
21
+ partition : str
22
+ Partition or queue name.
23
+ mem : str
24
+ Memory allocation (e.g., '4G', '16G').
25
+ output : str, optional
26
+ Path to the output file, default is './slurm-files/slurm-%x.%j.out'.
27
+
28
+ Attributes
29
+ ----------
30
+ commands : list
31
+ List of shell commands to execute in the SLURM script.
32
+ """
33
+
34
+ def __init__(self, **sbatch):
35
+ """
36
+ Initialize the class with SBATCH parameters.
37
+
38
+ Parameters
39
+ ----------
40
+ **sbatch : dict
41
+ Arbitrary keyword arguments representing SBATCH parameters.
42
+ Each key-value pair will be formatted as '#SBATCH --key=value'.
43
+ """
44
+
45
+ self.sbatch = [
46
+ f"#SBATCH --{key.replace('_', '-')}={value}"
47
+ for key, value in sbatch.items()
48
+ ]
49
+ self.commands = []
50
+ self.fname = None
51
+
52
+ def add_command(self, command: str) -> None:
53
+ """
54
+ Add a shell command to the script.
55
+
56
+ Parameters
57
+ ----------
58
+ command : str
59
+ The shell command to add.
60
+ """
61
+ self.commands.append(command)
62
+
63
+ def generate(self) -> str:
64
+ """
65
+ Generate the SLURM script content as a string.
66
+
67
+ Returns
68
+ -------
69
+ str
70
+ The complete SLURM script.
71
+ """
72
+ script = "#!/bin/bash\n"
73
+ if self.sbatch:
74
+ script += "\n".join(self.sbatch) + "\n"
75
+ if self.commands:
76
+ script += "\n" + "\n".join(self.commands) + "\n"
77
+ return script
78
+
79
+ def save(self, filename: str | Path) -> None:
80
+ """
81
+ Save the SLURM script to a file.
82
+
83
+ Parameters
84
+ ----------
85
+ filename : str
86
+ Path to save the SLURM script.
87
+ """
88
+ self.fname = Path(filename)
89
+ with open(self.fname, "w") as file:
90
+ file.write(self.generate())
91
+
92
+ def submit(self, filename: str | Path) -> int:
93
+ """
94
+ Submit the SLURM script to the queue.
95
+ """
96
+ self.save(filename)
97
+ out = subprocess.run(
98
+ ["sbatch", str(self.fname)],
99
+ capture_output=True,
100
+ text=True,
101
+ check=False,
102
+ )
103
+ if out.returncode != 0:
104
+ message = out.stderr.strip() or out.stdout.strip() or "unknown sbatch error"
105
+ raise RuntimeError(f"SLURM submission failed: {message}")
106
+
107
+ tokens = out.stdout.strip().split()
108
+ if not tokens:
109
+ raise RuntimeError(
110
+ "SLURM submission failed: could not parse job ID from sbatch output."
111
+ )
112
+ try:
113
+ return int(tokens[-1])
114
+ except ValueError as exc:
115
+ raise RuntimeError(
116
+ "SLURM submission failed: could not parse job ID from "
117
+ f"sbatch output {out.stdout!r}."
118
+ ) from exc