mdprep 0.2.0__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 (100) hide show
  1. mdprep/__init__.py +7 -0
  2. mdprep/_selftest_data/basic_protein.yaml +33 -0
  3. mdprep/_selftest_data/ligand_sub.mol2 +11 -0
  4. mdprep/ambertools/__init__.py +2 -0
  5. mdprep/ambertools/antechamber.py +110 -0
  6. mdprep/ambertools/commands.py +33 -0
  7. mdprep/ambertools/mol2.py +415 -0
  8. mdprep/ambertools/parmchk2.py +89 -0
  9. mdprep/ambertools/resp.py +570 -0
  10. mdprep/charges/__init__.py +1 -0
  11. mdprep/charges/esp_grid.py +190 -0
  12. mdprep/cli.py +451 -0
  13. mdprep/config/__init__.py +9 -0
  14. mdprep/config/loader.py +146 -0
  15. mdprep/config/models.py +1100 -0
  16. mdprep/external/__init__.py +14 -0
  17. mdprep/external/discovery.py +14 -0
  18. mdprep/external/runner.py +55 -0
  19. mdprep/leap/__init__.py +2 -0
  20. mdprep/leap/builder.py +476 -0
  21. mdprep/leap/forcefields.py +93 -0
  22. mdprep/leap/ions.py +112 -0
  23. mdprep/leap/log_parser.py +170 -0
  24. mdprep/leap/report.py +99 -0
  25. mdprep/leap/residues.py +653 -0
  26. mdprep/leap/runner.py +65 -0
  27. mdprep/ligands/__init__.py +2 -0
  28. mdprep/ligands/extract.py +355 -0
  29. mdprep/ligands/identity.py +39 -0
  30. mdprep/ligands/pyscf_charges.py +242 -0
  31. mdprep/ligands/report.py +146 -0
  32. mdprep/ligands/workflow.py +838 -0
  33. mdprep/md/__init__.py +5 -0
  34. mdprep/md/roe_brooks.py +650 -0
  35. mdprep/metals/__init__.py +2 -0
  36. mdprep/metals/c4.py +157 -0
  37. mdprep/metals/coordination.py +313 -0
  38. mdprep/metals/gxtb_hessian.py +467 -0
  39. mdprep/metals/hydrogenate.py +344 -0
  40. mdprep/metals/mace_polar1_hessian.py +357 -0
  41. mdprep/metals/mace_polar1_worker.py +295 -0
  42. mdprep/metals/mcpb.py +949 -0
  43. mdprep/metals/mcpb_compat.py +125 -0
  44. mdprep/metals/nonbonded.py +294 -0
  45. mdprep/metals/parameter_comparison.py +563 -0
  46. mdprep/metals/pyscf_mcpb.py +1388 -0
  47. mdprep/metals/report.py +119 -0
  48. mdprep/metals/workflow.py +320 -0
  49. mdprep/protonation/__init__.py +15 -0
  50. mdprep/protonation/apply.py +968 -0
  51. mdprep/protonation/disulfide_states.py +141 -0
  52. mdprep/protonation/histidine_geometry.py +950 -0
  53. mdprep/protonation/histidine_xtb.py +390 -0
  54. mdprep/protonation/overrides.py +64 -0
  55. mdprep/protonation/pka_rules.py +165 -0
  56. mdprep/protonation/propka.py +174 -0
  57. mdprep/protonation/propka_parser.py +140 -0
  58. mdprep/protonation/report.py +187 -0
  59. mdprep/protonation/states.py +52 -0
  60. mdprep/protonation/temporary_hydrogenation.py +238 -0
  61. mdprep/protonation/xtb_parser.py +79 -0
  62. mdprep/protonation/xtb_runner.py +108 -0
  63. mdprep/qm/__init__.py +2 -0
  64. mdprep/qm/point_charges.py +233 -0
  65. mdprep/qm/pyscf_esp.py +70 -0
  66. mdprep/qm/pyscf_runner.py +570 -0
  67. mdprep/refinement/__init__.py +5 -0
  68. mdprep/refinement/ash.py +541 -0
  69. mdprep/refinement/coordinates.py +210 -0
  70. mdprep/refinement/mapping.py +163 -0
  71. mdprep/refinement/report.py +66 -0
  72. mdprep/refinement/selection.py +436 -0
  73. mdprep/refinement/workflow.py +188 -0
  74. mdprep/reports/__init__.py +8 -0
  75. mdprep/reports/structure_report.py +92 -0
  76. mdprep/structure/__init__.py +14 -0
  77. mdprep/structure/classify.py +92 -0
  78. mdprep/structure/disulfides.py +51 -0
  79. mdprep/structure/inspect.py +105 -0
  80. mdprep/structure/models.py +86 -0
  81. mdprep/structure/normalize.py +363 -0
  82. mdprep/structure/pdb.py +511 -0
  83. mdprep/structure/repair.py +566 -0
  84. mdprep/structure/selectors.py +204 -0
  85. mdprep/structure/writer.py +84 -0
  86. mdprep/validation/__init__.py +2 -0
  87. mdprep/validation/openmm_check.py +86 -0
  88. mdprep/validation/parmed_check.py +43 -0
  89. mdprep/validation/topology.py +239 -0
  90. mdprep/workflows/__init__.py +6 -0
  91. mdprep/workflows/init.py +242 -0
  92. mdprep/workflows/inspect.py +21 -0
  93. mdprep/workflows/prepare.py +763 -0
  94. mdprep/workflows/selftest.py +220 -0
  95. mdprep/workflows/validate.py +30 -0
  96. mdprep-0.2.0.dist-info/METADATA +264 -0
  97. mdprep-0.2.0.dist-info/RECORD +100 -0
  98. mdprep-0.2.0.dist-info/WHEEL +4 -0
  99. mdprep-0.2.0.dist-info/entry_points.txt +2 -0
  100. mdprep-0.2.0.dist-info/licenses/LICENSE +21 -0
mdprep/md/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """OpenMM equilibration and production workflows."""
2
+
3
+ from mdprep.md.roe_brooks import RoeBrooksResult, run_roe_brooks
4
+
5
+ __all__ = ["RoeBrooksResult", "run_roe_brooks"]
@@ -0,0 +1,650 @@
1
+ """Manifest-driven Roe and Brooks equilibration protocol for OpenMM.
2
+
3
+ The implementation follows the staged protocol used by mdprep's development
4
+ system while making platform selection, convergence limits, random seeds, and
5
+ production reporting explicit and reproducible.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import csv
11
+ import json
12
+ import math
13
+ from dataclasses import dataclass
14
+ from pathlib import Path
15
+ from time import perf_counter
16
+ from typing import Any, Sequence
17
+
18
+ import numpy as np
19
+
20
+ from mdprep.config.models import MolecularDynamicsConfig
21
+
22
+
23
+ class RoeBrooksError(ValueError):
24
+ """Raised when the OpenMM MD protocol cannot complete safely."""
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class RoeBrooksResult:
29
+ output_dir: Path
30
+ platform: str
31
+ step10_elapsed_ps: float
32
+ production_steps: int
33
+ final_pdb: Path
34
+ final_checkpoint: Path
35
+ trajectory: Path
36
+ state_log: Path
37
+ report_path: Path
38
+
39
+ def to_dict(self) -> dict[str, object]:
40
+ return {
41
+ "protocol": "roe_brooks_2020",
42
+ "output_dir": str(self.output_dir),
43
+ "platform": self.platform,
44
+ "step10_elapsed_ps": self.step10_elapsed_ps,
45
+ "production_steps": self.production_steps,
46
+ "final_pdb": str(self.final_pdb),
47
+ "final_checkpoint": str(self.final_checkpoint),
48
+ "trajectory": str(self.trajectory),
49
+ "state_log": str(self.state_log),
50
+ "report_path": str(self.report_path),
51
+ }
52
+
53
+
54
+ BACKBONE_NAMES = {
55
+ "N", "CA", "C", "O", "OXT", "P", "OP1", "OP2", "O5'", "C5'",
56
+ "C4'", "O4'", "C3'", "O3'", "C2'", "C1'", "O2'",
57
+ }
58
+ WATER_RESNAMES = {"HOH", "WAT", "TIP3", "TP3", "SPC", "SPCE", "OPC"}
59
+ ION_RESNAMES = {
60
+ "NA", "CL", "K", "RB", "CS", "LI", "MG", "CA", "ZN", "MN", "FE",
61
+ "CU", "CO", "NI",
62
+ }
63
+ AUTO_PLATFORM_ORDER = ("CUDA", "HIP", "Metal", "OpenCL", "CPU", "Reference")
64
+
65
+
66
+ def steps_from_time(time_q: Any, timestep_q: Any) -> int:
67
+ return int(round(time_q / timestep_q))
68
+
69
+
70
+ def density_plateau_check(
71
+ times_ps: Sequence[float],
72
+ densities_g_ml: Sequence[float],
73
+ *,
74
+ window_ps: float,
75
+ report_interval_ps: float,
76
+ slope_threshold: float,
77
+ mean_difference_threshold: float,
78
+ minimum_points: int,
79
+ ) -> tuple[bool, dict[str, object]]:
80
+ """Evaluate the final point-based density window."""
81
+
82
+ times = np.asarray(times_ps, dtype=float)
83
+ densities = np.asarray(densities_g_ml, dtype=float)
84
+ if len(densities) < minimum_points:
85
+ return False, {"reason": f"need at least {minimum_points} points"}
86
+ window_points = max(
87
+ minimum_points,
88
+ int(round(window_ps / report_interval_ps)) + 1,
89
+ )
90
+ if len(densities) < window_points:
91
+ return False, {"reason": f"need at least {window_points} window points"}
92
+ window_times = times[-window_points:]
93
+ window_densities = densities[-window_points:]
94
+ slope, _ = np.polyfit(window_times, window_densities, 1)
95
+ midpoint = len(window_densities) // 2
96
+ mean_first = float(np.mean(window_densities[:midpoint]))
97
+ mean_second = float(np.mean(window_densities[midpoint:]))
98
+ mean_difference = abs(mean_second - mean_first)
99
+ info: dict[str, object] = {
100
+ "points": window_points,
101
+ "window_ps": float(window_times[-1] - window_times[0]),
102
+ "slope_g_ml_ps": float(slope),
103
+ "slope_threshold_g_ml_ps": slope_threshold,
104
+ "mean_difference_g_ml": mean_difference,
105
+ "mean_difference_threshold_g_ml": mean_difference_threshold,
106
+ "standard_deviation_g_ml": float(np.std(window_densities)),
107
+ "mean_first_half_g_ml": mean_first,
108
+ "mean_second_half_g_ml": mean_second,
109
+ }
110
+ converged = (
111
+ abs(float(slope)) < slope_threshold
112
+ and mean_difference < mean_difference_threshold
113
+ )
114
+ return converged, info
115
+
116
+
117
+ def platform_candidates(requested: str, available: Sequence[str]) -> list[str]:
118
+ """Return deterministic accelerator-first candidates for an OpenMM run."""
119
+
120
+ available_set = set(available)
121
+ if requested != "auto":
122
+ if requested not in available_set:
123
+ raise RoeBrooksError(
124
+ f"Requested OpenMM platform {requested!r} is unavailable; "
125
+ f"available platforms: {', '.join(available)}"
126
+ )
127
+ return [requested]
128
+ candidates = [name for name in AUTO_PLATFORM_ORDER if name in available_set]
129
+ if not candidates:
130
+ raise RoeBrooksError("OpenMM reports no usable computational platforms")
131
+ return candidates
132
+
133
+
134
+ def _platform_properties(platform: Any, config: MolecularDynamicsConfig) -> dict[str, str]:
135
+ names = set(platform.getPropertyNames())
136
+ properties: dict[str, str] = {}
137
+ if config.device_index is not None and "DeviceIndex" in names:
138
+ properties["DeviceIndex"] = str(config.device_index)
139
+ if "Precision" in names:
140
+ properties["Precision"] = config.precision
141
+ if config.cpu_threads is not None and "Threads" in names:
142
+ properties["Threads"] = str(config.cpu_threads)
143
+ return properties
144
+
145
+
146
+ def run_roe_brooks(
147
+ *,
148
+ prmtop_path: str | Path,
149
+ inpcrd_path: str | Path,
150
+ output_dir: str | Path,
151
+ config: MolecularDynamicsConfig,
152
+ ) -> RoeBrooksResult:
153
+ """Run equilibration and production from validated Amber inputs."""
154
+
155
+ if not config.enabled or config.production is None:
156
+ raise RoeBrooksError("The molecular_dynamics stage is not enabled")
157
+ try:
158
+ import openmm as mm
159
+ from openmm import Platform, unit
160
+ from openmm.app import (
161
+ AmberInpcrdFile,
162
+ AmberPrmtopFile,
163
+ CheckpointReporter,
164
+ DCDReporter,
165
+ HBonds,
166
+ PDBFile,
167
+ PME,
168
+ Simulation,
169
+ StateDataReporter,
170
+ )
171
+ except ImportError as exc:
172
+ raise RoeBrooksError(
173
+ "OpenMM is required for molecular_dynamics.enabled: true; install the "
174
+ "mdprep MD optional dependencies"
175
+ ) from exc
176
+
177
+ output = Path(output_dir)
178
+ output.mkdir(parents=True, exist_ok=True)
179
+ prmtop = AmberPrmtopFile(str(prmtop_path))
180
+ inpcrd = AmberInpcrdFile(str(inpcrd_path))
181
+ topology = prmtop.topology
182
+ positions = inpcrd.positions
183
+ box_vectors = inpcrd.boxVectors
184
+ if topology.getNumAtoms() != len(positions):
185
+ raise RoeBrooksError("prmtop and inpcrd atom counts differ")
186
+ if box_vectors is None:
187
+ raise RoeBrooksError("Roe--Brooks PME/NPT requires periodic box vectors")
188
+
189
+ temperature = config.temperature_kelvin * unit.kelvin
190
+ pressure = config.pressure_atmosphere * unit.atmosphere
191
+ started = perf_counter()
192
+
193
+ def build_system(*, constraints: Any, rigid_water: bool, barostat: bool) -> Any:
194
+ system = prmtop.createSystem(
195
+ nonbondedMethod=PME,
196
+ nonbondedCutoff=0.8 * unit.nanometer,
197
+ constraints=constraints,
198
+ rigidWater=rigid_water,
199
+ ewaldErrorTolerance=1.0e-4,
200
+ )
201
+ for force_index in range(system.getNumForces()):
202
+ force = system.getForce(force_index)
203
+ if isinstance(force, mm.NonbondedForce):
204
+ force.setUseDispersionCorrection(True)
205
+ if barostat:
206
+ barostat_force = mm.MonteCarloBarostat(pressure, temperature, 100)
207
+ barostat_force.setRandomNumberSeed(config.random_seed)
208
+ system.addForce(barostat_force)
209
+ return system
210
+
211
+ def add_restraints(
212
+ system: Any,
213
+ reference_positions: Any,
214
+ force_constant: float,
215
+ selection: str,
216
+ ) -> None:
217
+ k = (
218
+ force_constant
219
+ * unit.kilocalories_per_mole
220
+ / unit.angstrom**2
221
+ ).in_units_of(unit.kilojoules_per_mole / unit.nanometer**2)
222
+ force = mm.CustomExternalForce("0.5*k*((x-x0)^2+(y-y0)^2+(z-z0)^2)")
223
+ force.addGlobalParameter("k", k)
224
+ for parameter in ("x0", "y0", "z0"):
225
+ force.addPerParticleParameter(parameter)
226
+ reference_nm = reference_positions.value_in_unit(unit.nanometer)
227
+ for atom in topology.atoms():
228
+ name = atom.name.strip()
229
+ is_hydrogen = (
230
+ atom.element is not None and atom.element.symbol == "H"
231
+ ) or name.startswith("H")
232
+ residue_name = atom.residue.name.strip().upper()
233
+ if is_hydrogen or residue_name in WATER_RESNAMES | ION_RESNAMES:
234
+ continue
235
+ if selection == "backbone" and name not in BACKBONE_NAMES:
236
+ continue
237
+ if selection not in {"heavy", "backbone"}:
238
+ raise RoeBrooksError(f"Unknown restraint selection {selection!r}")
239
+ point = reference_nm[atom.index]
240
+ force.addParticle(atom.index, [point.x, point.y, point.z])
241
+ system.addForce(force)
242
+
243
+ available = [
244
+ Platform.getPlatform(index).getName()
245
+ for index in range(Platform.getNumPlatforms())
246
+ ]
247
+ trial_system = build_system(constraints=HBonds, rigid_water=True, barostat=False)
248
+ selected_platform = None
249
+ selected_properties: dict[str, str] = {}
250
+ platform_failures: dict[str, str] = {}
251
+ for candidate in platform_candidates(config.platform, available):
252
+ candidate_platform = Platform.getPlatformByName(candidate)
253
+ candidate_properties = _platform_properties(candidate_platform, config)
254
+ trial_integrator = mm.VerletIntegrator(1.0 * unit.femtoseconds)
255
+ try:
256
+ trial_simulation = Simulation(
257
+ topology,
258
+ trial_system,
259
+ trial_integrator,
260
+ candidate_platform,
261
+ candidate_properties,
262
+ )
263
+ trial_simulation.context.setPositions(positions)
264
+ trial_simulation.context.setPeriodicBoxVectors(*box_vectors)
265
+ trial_state = trial_simulation.context.getState(getEnergy=True)
266
+ energy = trial_state.getPotentialEnergy().value_in_unit(
267
+ unit.kilojoules_per_mole
268
+ )
269
+ if not math.isfinite(energy):
270
+ raise RoeBrooksError("initial energy is not finite")
271
+ except Exception as exc: # OpenMM plugins raise several exception types
272
+ platform_failures[candidate] = str(exc)
273
+ if config.platform != "auto":
274
+ raise RoeBrooksError(
275
+ f"Requested OpenMM platform {candidate!r} failed: {exc}"
276
+ ) from exc
277
+ continue
278
+ selected_platform = candidate_platform
279
+ selected_properties = candidate_properties
280
+ del trial_simulation, trial_integrator
281
+ break
282
+ if selected_platform is None:
283
+ raise RoeBrooksError(
284
+ "No OpenMM platform could initialize: "
285
+ + "; ".join(f"{key}: {value}" for key, value in platform_failures.items())
286
+ )
287
+
288
+ stage_records: list[dict[str, object]] = []
289
+
290
+ def make_simulation(
291
+ system: Any,
292
+ integrator: Any,
293
+ stage_positions: Any,
294
+ stage_box: Any,
295
+ *,
296
+ velocities: Any | None = None,
297
+ initialize_velocities: bool = False,
298
+ ) -> Any:
299
+ simulation = Simulation(
300
+ topology,
301
+ system,
302
+ integrator,
303
+ selected_platform,
304
+ selected_properties,
305
+ )
306
+ simulation.context.setPositions(stage_positions)
307
+ simulation.context.setPeriodicBoxVectors(*stage_box)
308
+ if velocities is not None:
309
+ simulation.context.setVelocities(velocities)
310
+ elif initialize_velocities:
311
+ simulation.context.setVelocitiesToTemperature(
312
+ temperature, config.random_seed
313
+ )
314
+ return simulation
315
+
316
+ def write_pdb(simulation: Any, name: str) -> Path:
317
+ path = output / name
318
+ state = simulation.context.getState(getPositions=True, enforcePeriodicBox=True)
319
+ with path.open("w", encoding="utf-8") as handle:
320
+ PDBFile.writeFile(topology, state.getPositions(), handle, keepIds=True)
321
+ return path
322
+
323
+ def record_stage(simulation: Any, label: str) -> Any:
324
+ state = simulation.context.getState(
325
+ getPositions=True,
326
+ getVelocities=True,
327
+ getEnergy=True,
328
+ enforcePeriodicBox=True,
329
+ )
330
+ potential = state.getPotentialEnergy().value_in_unit(unit.kilojoules_per_mole)
331
+ kinetic = state.getKineticEnergy().value_in_unit(unit.kilojoules_per_mole)
332
+ volume = state.getPeriodicBoxVolume().value_in_unit(unit.nanometer**3)
333
+ if not all(math.isfinite(value) for value in (potential, kinetic, volume)):
334
+ raise RoeBrooksError(f"{label} produced a non-finite state")
335
+ stage_records.append(
336
+ {
337
+ "stage": label,
338
+ "potential_energy_kj_mol": potential,
339
+ "kinetic_energy_kj_mol": kinetic,
340
+ "box_volume_nm3": volume,
341
+ }
342
+ )
343
+ return state
344
+
345
+ def save_restart(simulation: Any, stem: str) -> Path:
346
+ checkpoint = output / f"{stem}.chk"
347
+ state_path = output / f"{stem}.xml"
348
+ simulation.saveCheckpoint(str(checkpoint))
349
+ state = simulation.context.getState(
350
+ getPositions=True, getVelocities=True, getEnergy=True,
351
+ enforcePeriodicBox=True,
352
+ )
353
+ state_path.write_text(mm.XmlSerializer.serialize(state), encoding="utf-8")
354
+ return checkpoint
355
+
356
+ dt_min = 1.0 * unit.femtoseconds
357
+ dt_1fs = 1.0 * unit.femtoseconds
358
+ dt_2fs = 2.0 * unit.femtoseconds
359
+
360
+ # Steps 1--5. Positional forces are added before Context creation.
361
+ reference_positions = positions
362
+ current_positions = positions
363
+ current_box = box_vectors
364
+ system = build_system(constraints=None, rigid_water=False, barostat=False)
365
+ add_restraints(system, reference_positions, 5.0, "heavy")
366
+ simulation = make_simulation(
367
+ system, mm.VerletIntegrator(dt_min), current_positions, current_box
368
+ )
369
+ mm.LocalEnergyMinimizer.minimize(simulation.context, maxIterations=1000)
370
+ write_pdb(simulation, "step01.pdb")
371
+ state = record_stage(simulation, "step01")
372
+ current_positions = state.getPositions()
373
+ current_box = state.getPeriodicBoxVectors()
374
+
375
+ # Make the unconstrained minimum compatible with the SHAKE/rigid-water
376
+ # manifold before heating. Some accelerator and CPU constraint solvers
377
+ # otherwise fail on the first integration step for strained input water.
378
+ system = build_system(constraints=HBonds, rigid_water=True, barostat=False)
379
+ add_restraints(system, reference_positions, 5.0, "heavy")
380
+ simulation = make_simulation(
381
+ system, mm.VerletIntegrator(dt_min), current_positions, current_box
382
+ )
383
+ mm.LocalEnergyMinimizer.minimize(simulation.context, maxIterations=500)
384
+ write_pdb(simulation, "step01b.pdb")
385
+ state = record_stage(simulation, "step01b")
386
+ current_positions = state.getPositions()
387
+ current_box = state.getPeriodicBoxVectors()
388
+
389
+ system = build_system(constraints=HBonds, rigid_water=True, barostat=False)
390
+ add_restraints(system, reference_positions, 5.0, "heavy")
391
+ integrator = mm.LangevinMiddleIntegrator(temperature, 5.0 / unit.picosecond, dt_1fs)
392
+ integrator.setConstraintTolerance(1.0e-6)
393
+ integrator.setRandomNumberSeed(config.random_seed + 2)
394
+ simulation = make_simulation(
395
+ system, integrator, current_positions, current_box, initialize_velocities=True
396
+ )
397
+ simulation.step(steps_from_time(15.0 * unit.picoseconds, dt_1fs))
398
+ write_pdb(simulation, "step02.pdb")
399
+ save_restart(simulation, "after_step02")
400
+ state = record_stage(simulation, "step02")
401
+ current_positions, current_velocities = state.getPositions(), state.getVelocities()
402
+ current_box = state.getPeriodicBoxVectors()
403
+
404
+ for label, restraint_k in (("step03", 2.0), ("step04", 0.1)):
405
+ system = build_system(constraints=None, rigid_water=False, barostat=False)
406
+ add_restraints(system, reference_positions, restraint_k, "heavy")
407
+ simulation = make_simulation(
408
+ system, mm.VerletIntegrator(dt_min), current_positions, current_box
409
+ )
410
+ mm.LocalEnergyMinimizer.minimize(simulation.context, maxIterations=1000)
411
+ write_pdb(simulation, f"{label}.pdb")
412
+ state = record_stage(simulation, label)
413
+ current_positions, current_box = state.getPositions(), state.getPeriodicBoxVectors()
414
+
415
+ system = build_system(constraints=None, rigid_water=False, barostat=False)
416
+ simulation = make_simulation(
417
+ system, mm.VerletIntegrator(dt_min), current_positions, current_box
418
+ )
419
+ mm.LocalEnergyMinimizer.minimize(simulation.context, maxIterations=1000)
420
+ write_pdb(simulation, "step05.pdb")
421
+ state = record_stage(simulation, "step05")
422
+ current_positions, current_box = state.getPositions(), state.getPeriodicBoxVectors()
423
+
424
+ system = build_system(constraints=HBonds, rigid_water=True, barostat=False)
425
+ simulation = make_simulation(
426
+ system, mm.VerletIntegrator(dt_min), current_positions, current_box
427
+ )
428
+ mm.LocalEnergyMinimizer.minimize(simulation.context, maxIterations=500)
429
+ write_pdb(simulation, "step05b.pdb")
430
+ state = record_stage(simulation, "step05b")
431
+ current_positions, current_box = state.getPositions(), state.getPeriodicBoxVectors()
432
+ step5_reference = current_positions
433
+
434
+ system = build_system(constraints=HBonds, rigid_water=True, barostat=False)
435
+ add_restraints(system, step5_reference, 1.0, "heavy")
436
+ settle_dt = 0.25 * unit.femtoseconds
437
+ integrator = mm.LangevinMiddleIntegrator(
438
+ temperature, 20.0 / unit.picosecond, settle_dt
439
+ )
440
+ integrator.setConstraintTolerance(1.0e-6)
441
+ integrator.setRandomNumberSeed(config.random_seed + 5)
442
+ simulation = make_simulation(
443
+ system, integrator, current_positions, current_box, initialize_velocities=True
444
+ )
445
+ simulation.step(steps_from_time(0.2 * unit.picoseconds, settle_dt))
446
+ write_pdb(simulation, "step05c.pdb")
447
+ state = record_stage(simulation, "step05c")
448
+ current_positions, current_velocities = state.getPositions(), state.getVelocities()
449
+ current_box = state.getPeriodicBoxVectors()
450
+
451
+ def run_md_stage(
452
+ label: str,
453
+ *,
454
+ duration_ps: float,
455
+ timestep: Any,
456
+ restraint_k: float | None,
457
+ restraint_selection: str | None,
458
+ barostat: bool,
459
+ seed_offset: int,
460
+ ) -> None:
461
+ nonlocal current_positions, current_velocities, current_box
462
+ system = build_system(constraints=HBonds, rigid_water=True, barostat=barostat)
463
+ if restraint_k is not None and restraint_selection is not None:
464
+ add_restraints(system, step5_reference, restraint_k, restraint_selection)
465
+ integrator = mm.LangevinMiddleIntegrator(
466
+ temperature, 5.0 / unit.picosecond, timestep
467
+ )
468
+ integrator.setConstraintTolerance(1.0e-6)
469
+ integrator.setRandomNumberSeed(config.random_seed + seed_offset)
470
+ simulation = make_simulation(
471
+ system,
472
+ integrator,
473
+ current_positions,
474
+ current_box,
475
+ velocities=current_velocities,
476
+ )
477
+ simulation.step(steps_from_time(duration_ps * unit.picoseconds, timestep))
478
+ write_pdb(simulation, f"{label}.pdb")
479
+ state = record_stage(simulation, label)
480
+ current_positions, current_velocities = state.getPositions(), state.getVelocities()
481
+ current_box = state.getPeriodicBoxVectors()
482
+
483
+ run_md_stage(
484
+ "step06", duration_ps=5.0, timestep=dt_1fs, restraint_k=1.0,
485
+ restraint_selection="heavy", barostat=False, seed_offset=6,
486
+ )
487
+ run_md_stage(
488
+ "step07", duration_ps=5.0, timestep=dt_1fs, restraint_k=0.5,
489
+ restraint_selection="heavy", barostat=True, seed_offset=7,
490
+ )
491
+ run_md_stage(
492
+ "step08", duration_ps=10.0, timestep=dt_1fs, restraint_k=0.5,
493
+ restraint_selection="backbone",
494
+ barostat=config.step08_ensemble == "NPT", seed_offset=8,
495
+ )
496
+ run_md_stage(
497
+ "step09", duration_ps=10.0, timestep=dt_2fs, restraint_k=None,
498
+ restraint_selection=None, barostat=True, seed_offset=9,
499
+ )
500
+
501
+ # Step 10: unrestrained NPT until the configured density window converges.
502
+ system = build_system(constraints=HBonds, rigid_water=True, barostat=True)
503
+ integrator = mm.LangevinMiddleIntegrator(
504
+ temperature, 5.0 / unit.picosecond, dt_2fs
505
+ )
506
+ integrator.setConstraintTolerance(1.0e-6)
507
+ integrator.setRandomNumberSeed(config.random_seed + 10)
508
+ simulation = make_simulation(
509
+ system, integrator, current_positions, current_box,
510
+ velocities=current_velocities,
511
+ )
512
+ total_mass_da = sum(
513
+ system.getParticleMass(index).value_in_unit(unit.dalton)
514
+ for index in range(system.getNumParticles())
515
+ )
516
+ density_path = output / "density_step10.csv"
517
+ with density_path.open("w", newline="", encoding="utf-8") as handle:
518
+ csv.writer(handle).writerow(["time_ps", "density_g_ml", "box_volume_nm3"])
519
+ report_steps = max(
520
+ 1,
521
+ steps_from_time(config.density.report_interval_ps * unit.picoseconds, dt_2fs),
522
+ )
523
+ chunk_steps = max(
524
+ 1,
525
+ steps_from_time(config.density.increment_ns * unit.nanoseconds, dt_2fs),
526
+ )
527
+ maximum_steps = steps_from_time(
528
+ config.density.maximum_duration_ns * unit.nanoseconds, dt_2fs
529
+ )
530
+ elapsed_steps = 0
531
+ times: list[float] = []
532
+ densities: list[float] = []
533
+ convergence: dict[str, object] = {}
534
+ converged = False
535
+ while elapsed_steps < maximum_steps and not converged:
536
+ steps_left = min(chunk_steps, maximum_steps - elapsed_steps)
537
+ while steps_left > 0:
538
+ advance = min(report_steps, steps_left)
539
+ simulation.step(advance)
540
+ elapsed_steps += advance
541
+ steps_left -= advance
542
+ state = simulation.context.getState(enforcePeriodicBox=True)
543
+ volume = state.getPeriodicBoxVolume().value_in_unit(unit.nanometer**3)
544
+ density = (total_mass_da / 6.02214076e23) / (volume * 1.0e-21)
545
+ elapsed_ps = (elapsed_steps * dt_2fs).value_in_unit(unit.picoseconds)
546
+ if not all(math.isfinite(value) for value in (volume, density)):
547
+ raise RoeBrooksError("Step 10 produced a non-finite density or box volume")
548
+ times.append(elapsed_ps)
549
+ densities.append(density)
550
+ with density_path.open("a", newline="", encoding="utf-8") as handle:
551
+ csv.writer(handle).writerow([elapsed_ps, density, volume])
552
+ converged, convergence = density_plateau_check(
553
+ times,
554
+ densities,
555
+ window_ps=config.density.window_ps,
556
+ report_interval_ps=config.density.report_interval_ps,
557
+ slope_threshold=config.density.slope_threshold_g_ml_ps,
558
+ mean_difference_threshold=(
559
+ config.density.mean_difference_threshold_g_ml
560
+ ),
561
+ minimum_points=config.density.minimum_points,
562
+ )
563
+ if converged:
564
+ break
565
+ if not converged:
566
+ raise RoeBrooksError(
567
+ "Roe--Brooks step 10 did not reach the configured density plateau within "
568
+ f"{config.density.maximum_duration_ns} ns; last check: {convergence}"
569
+ )
570
+ step10_elapsed_ps = times[-1]
571
+ write_pdb(simulation, "step10.pdb")
572
+ save_restart(simulation, "after_step10")
573
+ record_stage(simulation, "step10")
574
+
575
+ production = config.production
576
+ trajectory = output / "production.dcd"
577
+ state_log = output / "production.log"
578
+ periodic_checkpoint = output / "production_periodic.chk"
579
+ simulation.reporters.append(
580
+ DCDReporter(str(trajectory), production.trajectory_interval_steps)
581
+ )
582
+ simulation.reporters.append(
583
+ StateDataReporter(
584
+ str(state_log),
585
+ production.state_interval_steps,
586
+ step=True,
587
+ time=True,
588
+ potentialEnergy=True,
589
+ kineticEnergy=True,
590
+ temperature=True,
591
+ density=True,
592
+ speed=True,
593
+ progress=True,
594
+ remainingTime=True,
595
+ totalSteps=simulation.currentStep + production.steps,
596
+ separator="\t",
597
+ )
598
+ )
599
+ simulation.reporters.append(
600
+ CheckpointReporter(str(periodic_checkpoint), production.checkpoint_interval_steps)
601
+ )
602
+ simulation.step(production.steps)
603
+ final_pdb = write_pdb(simulation, "production_last.pdb")
604
+ final_checkpoint = save_restart(simulation, "after_production")
605
+ final_state = record_stage(simulation, "production")
606
+ final_positions = final_state.getPositions(asNumpy=True).value_in_unit(unit.nanometer)
607
+ if not np.isfinite(final_positions).all():
608
+ raise RoeBrooksError("Production finished with non-finite coordinates")
609
+
610
+ report_path = output / "md_report.json"
611
+ report = {
612
+ "protocol": "roe_brooks_2020",
613
+ "prmtop": str(prmtop_path),
614
+ "inpcrd": str(inpcrd_path),
615
+ "atom_count": topology.getNumAtoms(),
616
+ "platform_requested": config.platform,
617
+ "platform_selected": selected_platform.getName(),
618
+ "platform_properties": selected_properties,
619
+ "platform_initialization_failures": platform_failures,
620
+ "random_seed": config.random_seed,
621
+ "temperature_kelvin": config.temperature_kelvin,
622
+ "pressure_atmosphere": config.pressure_atmosphere,
623
+ "step08_ensemble": config.step08_ensemble,
624
+ "step10_elapsed_ps": step10_elapsed_ps,
625
+ "step10_convergence": convergence,
626
+ "production": production.model_dump(mode="json"),
627
+ "stages": stage_records,
628
+ "runtime_seconds": perf_counter() - started,
629
+ "outputs": {
630
+ "trajectory": str(trajectory),
631
+ "state_log": str(state_log),
632
+ "periodic_checkpoint": str(periodic_checkpoint),
633
+ "final_checkpoint": str(final_checkpoint),
634
+ "final_pdb": str(final_pdb),
635
+ },
636
+ }
637
+ report_path.write_text(
638
+ json.dumps(report, indent=2, sort_keys=True) + "\n", encoding="utf-8"
639
+ )
640
+ return RoeBrooksResult(
641
+ output_dir=output,
642
+ platform=selected_platform.getName(),
643
+ step10_elapsed_ps=step10_elapsed_ps,
644
+ production_steps=production.steps,
645
+ final_pdb=final_pdb,
646
+ final_checkpoint=final_checkpoint,
647
+ trajectory=trajectory,
648
+ state_log=state_log,
649
+ report_path=report_path,
650
+ )
@@ -0,0 +1,2 @@
1
+ """Metal-site preparation workflows."""
2
+