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
@@ -0,0 +1,1388 @@
1
+ """Fixed-geometry QM artifacts consumed by MCPB.py.
2
+
3
+ The small-model geometry comes either from an explicit external PDB or from a
4
+ completed ASH QM/MM refinement. mdprep evaluates the Cartesian Hessian required
5
+ by Seminario at those fixed coordinates with PySCF, GFN2-xTB, g-xTB, or
6
+ MACE-POLAR-1. The
7
+ large MCPB model is evaluated with a fixed-geometry, electrostatically embedded
8
+ PySCF SCF. Only the QM density is sampled on the ESP grid: environment charges
9
+ polarize that density but are never fitted as charge centers.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import csv
15
+ import hashlib
16
+ import json
17
+ from dataclasses import dataclass
18
+ from pathlib import Path
19
+ from time import perf_counter
20
+ from typing import TYPE_CHECKING
21
+
22
+ import numpy as np
23
+
24
+ from mdprep.charges.esp_grid import (
25
+ EspGrid,
26
+ generate_merz_kollman_grid,
27
+ write_grid_xyz,
28
+ )
29
+ from mdprep.config.models import McpbPySCFConfig
30
+ from mdprep.metals.coordination import ResolvedMetalSite
31
+ from mdprep.qm.point_charges import PointCharge
32
+ from mdprep.qm.pyscf_esp import BOHR_PER_ANGSTROM, evaluate_ligand_esp
33
+ from mdprep.qm.pyscf_runner import (
34
+ PySCFResult,
35
+ PySCFRunnerError,
36
+ pyscf_version,
37
+ run_pyscf_analytic_hessian,
38
+ run_pyscf_scf,
39
+ )
40
+ from mdprep.structure.classify import STANDARD_PROTEIN_RESIDUES, WATER_RESIDUES
41
+ from mdprep.structure.models import AtomRecord, PdbStructure, ResidueRecord
42
+ from mdprep.structure.pdb import infer_element, read_pdb
43
+ from mdprep.structure.writer import write_pdb
44
+
45
+ if TYPE_CHECKING:
46
+ from mdprep.refinement.workflow import RefinementResult
47
+
48
+
49
+ class McpbPySCFError(ValueError):
50
+ """Raised when PySCF cannot produce validated MCPB.py QM artifacts."""
51
+
52
+
53
+ @dataclass(frozen=True)
54
+ class McpbEmbeddingSelection:
55
+ point_charges: tuple[PointCharge, ...]
56
+ excluded_qm_residue_count: int
57
+ candidate_atom_count: int
58
+ excluded_by_minimum_distance: int
59
+ excluded_by_cutoff: int
60
+ minimum_distance_angstrom: float | None
61
+ maximum_distance_angstrom: float | None
62
+ net_embedding_charge: float
63
+ categories: dict[str, int]
64
+
65
+ @property
66
+ def charge_array(self) -> np.ndarray:
67
+ return np.asarray([item.charge for item in self.point_charges], dtype=float)
68
+
69
+ @property
70
+ def coordinate_array(self) -> np.ndarray:
71
+ return np.asarray(
72
+ [[item.x, item.y, item.z] for item in self.point_charges],
73
+ dtype=float,
74
+ )
75
+
76
+ def to_dict(self) -> dict[str, object]:
77
+ return {
78
+ "point_charge_count": len(self.point_charges),
79
+ "excluded_qm_residue_count": self.excluded_qm_residue_count,
80
+ "candidate_atom_count": self.candidate_atom_count,
81
+ "excluded_by_minimum_distance": self.excluded_by_minimum_distance,
82
+ "excluded_by_cutoff": self.excluded_by_cutoff,
83
+ "minimum_distance_angstrom": self.minimum_distance_angstrom,
84
+ "maximum_distance_angstrom": self.maximum_distance_angstrom,
85
+ "net_embedding_charge": self.net_embedding_charge,
86
+ "categories": self.categories,
87
+ "embedding_operator": "pyscf.qmmm.mm_charge",
88
+ "interpretation": (
89
+ "These MM charges polarize the MCPB large-model QM density. They are "
90
+ "not RESP centers and are not written to MCPB ligand/residue mol2 files."
91
+ ),
92
+ }
93
+
94
+
95
+ @dataclass(frozen=True)
96
+ class SmallModelResult:
97
+ mcpb_generated_pdb_path: Path
98
+ user_optimized_pdb_path: Path
99
+ qmmm_refinement_source_pdb_path: Path | None
100
+ evaluated_pdb_path: Path
101
+ evaluated_xyz_path: Path
102
+ checkpoint_path: Path | None
103
+ fchk_path: Path
104
+ calculation_report_path: Path
105
+ scf_stdout_path: Path
106
+ scf_stderr_path: Path
107
+ atom_count: int
108
+ energy_hartree: float
109
+ geometry_status: str
110
+ scientific_warning: str | None
111
+ geometry_optimization_performed: bool
112
+ scf_calculation_count: int | None
113
+ hessian_antisymmetry_max: float
114
+ hessian_backend: str
115
+ backend_version: str
116
+ scf_runtime_seconds: float | None
117
+ hessian_runtime_seconds: float
118
+ total_runtime_seconds: float
119
+ external_command_records: tuple[dict[str, object], ...]
120
+
121
+ def to_dict(self) -> dict[str, object]:
122
+ return {
123
+ "mcpb_generated_pdb_path": str(self.mcpb_generated_pdb_path),
124
+ "user_optimized_pdb_path": str(self.user_optimized_pdb_path),
125
+ "qmmm_refinement_source_pdb_path": (
126
+ str(self.qmmm_refinement_source_pdb_path)
127
+ if self.qmmm_refinement_source_pdb_path is not None
128
+ else None
129
+ ),
130
+ "evaluated_pdb_path": str(self.evaluated_pdb_path),
131
+ "evaluated_xyz_path": str(self.evaluated_xyz_path),
132
+ "checkpoint_path": (
133
+ str(self.checkpoint_path) if self.checkpoint_path is not None else None
134
+ ),
135
+ "fchk_path": str(self.fchk_path),
136
+ "calculation_report_path": str(self.calculation_report_path),
137
+ "scf_stdout_path": str(self.scf_stdout_path),
138
+ "scf_stderr_path": str(self.scf_stderr_path),
139
+ "atom_count": self.atom_count,
140
+ "energy_hartree": self.energy_hartree,
141
+ "geometry_status": self.geometry_status,
142
+ "scientific_warning": self.scientific_warning,
143
+ "geometry_optimization_performed": self.geometry_optimization_performed,
144
+ "scf_calculation_count": self.scf_calculation_count,
145
+ "hessian_antisymmetry_max": self.hessian_antisymmetry_max,
146
+ "hessian_backend": self.hessian_backend,
147
+ "backend_version": self.backend_version,
148
+ "scf_runtime_seconds": self.scf_runtime_seconds,
149
+ "hessian_runtime_seconds": self.hessian_runtime_seconds,
150
+ "total_runtime_seconds": self.total_runtime_seconds,
151
+ "external_commands": list(self.external_command_records),
152
+ "mcpb_generated_pdb_sha256": _sha256(self.mcpb_generated_pdb_path),
153
+ "user_optimized_pdb_sha256": _sha256(self.user_optimized_pdb_path),
154
+ "qmmm_refinement_source_pdb_sha256": (
155
+ _sha256(self.qmmm_refinement_source_pdb_path)
156
+ if self.qmmm_refinement_source_pdb_path is not None
157
+ else None
158
+ ),
159
+ "fchk_sha256": _sha256(self.fchk_path),
160
+ }
161
+
162
+
163
+ @dataclass(frozen=True)
164
+ class LargeModelResult:
165
+ input_pdb_path: Path
166
+ checkpoint_path: Path
167
+ mk_log_path: Path
168
+ grid_xyz_path: Path
169
+ esp_values_path: Path
170
+ point_charge_csv_path: Path
171
+ point_charge_xyz_path: Path
172
+ point_charge_report_path: Path
173
+ scf_stdout_path: Path
174
+ scf_stderr_path: Path
175
+ atom_count: int
176
+ energy_hartree: float
177
+ grid_point_count: int
178
+ embedding: McpbEmbeddingSelection
179
+ scf_algorithm: str
180
+ initial_guess: str
181
+ initial_density_checkpoint_path: str | None
182
+ initial_density_checkpoint_sha256: str | None
183
+ initial_density_checkpoint_max_displacement_angstrom: float | None
184
+ initial_density_checkpoint_rms_displacement_angstrom: float | None
185
+ initial_density_checkpoint_projected: bool
186
+ adiis_precondition_cycles: int | None
187
+ adiis_precondition_dft_grid_level: int | None
188
+ precondition_cycles_completed: int
189
+ level_shift_mode: str
190
+ level_shift_hartree: float
191
+ damping_factor: float
192
+ diis_space: int
193
+ density_fitting: bool
194
+ auxiliary_basis: str | None
195
+ spin_square: float | None
196
+ effective_multiplicity: float | None
197
+
198
+ def to_dict(self) -> dict[str, object]:
199
+ return {
200
+ "input_pdb_path": str(self.input_pdb_path),
201
+ "checkpoint_path": str(self.checkpoint_path),
202
+ "mk_log_path": str(self.mk_log_path),
203
+ "grid_xyz_path": str(self.grid_xyz_path),
204
+ "esp_values_path": str(self.esp_values_path),
205
+ "point_charge_csv_path": str(self.point_charge_csv_path),
206
+ "point_charge_xyz_path": str(self.point_charge_xyz_path),
207
+ "point_charge_report_path": str(self.point_charge_report_path),
208
+ "scf_stdout_path": str(self.scf_stdout_path),
209
+ "scf_stderr_path": str(self.scf_stderr_path),
210
+ "atom_count": self.atom_count,
211
+ "energy_hartree": self.energy_hartree,
212
+ "grid_point_count": self.grid_point_count,
213
+ "embedding": self.embedding.to_dict(),
214
+ "scf_algorithm": self.scf_algorithm,
215
+ "initial_guess": self.initial_guess,
216
+ "initial_density_checkpoint_path": self.initial_density_checkpoint_path,
217
+ "initial_density_checkpoint_sha256": (
218
+ self.initial_density_checkpoint_sha256
219
+ ),
220
+ "initial_density_checkpoint_max_displacement_angstrom": (
221
+ self.initial_density_checkpoint_max_displacement_angstrom
222
+ ),
223
+ "initial_density_checkpoint_rms_displacement_angstrom": (
224
+ self.initial_density_checkpoint_rms_displacement_angstrom
225
+ ),
226
+ "initial_density_checkpoint_projected": (
227
+ self.initial_density_checkpoint_projected
228
+ ),
229
+ "adiis_precondition_cycles": self.adiis_precondition_cycles,
230
+ "adiis_precondition_dft_grid_level": (
231
+ self.adiis_precondition_dft_grid_level
232
+ ),
233
+ "precondition_cycles_completed": self.precondition_cycles_completed,
234
+ "level_shift_mode": self.level_shift_mode,
235
+ "level_shift_hartree": self.level_shift_hartree,
236
+ "damping_factor": self.damping_factor,
237
+ "diis_space": self.diis_space,
238
+ "density_fitting": self.density_fitting,
239
+ "auxiliary_basis": self.auxiliary_basis,
240
+ "spin_square": self.spin_square,
241
+ "effective_multiplicity": self.effective_multiplicity,
242
+ "mk_log_sha256": _sha256(self.mk_log_path),
243
+ }
244
+
245
+
246
+ @dataclass(frozen=True)
247
+ class McpbPySCFResult:
248
+ method: str
249
+ basis: str
250
+ dft_grid_level: int
251
+ small_model_charge: int
252
+ small_model_multiplicity: int
253
+ large_model_charge: int
254
+ large_model_multiplicity: int
255
+ small: SmallModelResult
256
+ large: LargeModelResult
257
+ report_path: Path
258
+
259
+ def to_dict(self) -> dict[str, object]:
260
+ geometry_description = {
261
+ "user_optimized": "user-supplied optimized MCPB small-model geometry",
262
+ "qmmm_refined": "completed ASH/GFN2-xTB QM/MM-refined geometry",
263
+ "test_unoptimized": (
264
+ "explicitly unoptimized MCPB small-model geometry for workflow testing"
265
+ ),
266
+ }[self.small.geometry_status]
267
+ hessian_description = {
268
+ "pyscf": "PySCF analytic Hessian",
269
+ "gfn2_xtb": "GFN2-xTB numerical Hessian from analytic gradients",
270
+ "gxtb": "g-xTB numerical Hessian from analytic gradients",
271
+ "mace_polar1": "MACE-POLAR-1 analytical Hessian",
272
+ }[self.small.hessian_backend]
273
+ backend = (
274
+ "PySCF"
275
+ if self.small.hessian_backend == "pyscf"
276
+ else f"PySCF+{self.small.hessian_backend}"
277
+ )
278
+ return {
279
+ "backend": backend,
280
+ "method": self.method,
281
+ "basis": self.basis,
282
+ "dft_grid_level": self.dft_grid_level,
283
+ "small_model_hessian_backend": self.small.hessian_backend,
284
+ "large_model_esp_backend": "PySCF",
285
+ "small_model_charge": self.small_model_charge,
286
+ "small_model_multiplicity": self.small_model_multiplicity,
287
+ "large_model_charge": self.large_model_charge,
288
+ "large_model_multiplicity": self.large_model_multiplicity,
289
+ "small_model": self.small.to_dict(),
290
+ "large_model": self.large.to_dict(),
291
+ "report_path": str(self.report_path),
292
+ "scientific_warning": self.small.scientific_warning,
293
+ "scientific_workflow": (
294
+ f"{geometry_description}; one fixed-geometry "
295
+ f"small-model {hessian_description}; MCPB large-model "
296
+ "electrostatically embedded fixed-geometry single-point SCF; "
297
+ "QM-only ESP passed to MCPB.py two-stage RESP."
298
+ ),
299
+ }
300
+
301
+
302
+ def run_mcpb_pyscf(
303
+ site: ResolvedMetalSite,
304
+ *,
305
+ group_name: str,
306
+ work_dir: str | Path,
307
+ provisional_prmtop: str | Path,
308
+ provisional_inpcrd: str | Path,
309
+ provisional_structure: PdbStructure,
310
+ refinement_result: "RefinementResult | None" = None,
311
+ ) -> McpbPySCFResult:
312
+ """Run and validate both PySCF calculations needed by one MCPB site."""
313
+
314
+ if site.config.mcpb is None or site.config.mcpb.pyscf is None:
315
+ raise McpbPySCFError(
316
+ f"Metal site {site.config.id!r} has no mcpb.pyscf configuration."
317
+ )
318
+ mcpb = site.config.mcpb
319
+ config = mcpb.pyscf
320
+ work = Path(work_dir)
321
+ work.mkdir(parents=True, exist_ok=True)
322
+ small_pdb = work / f"{group_name}_small.pdb"
323
+ large_pdb = work / f"{group_name}_large.pdb"
324
+ for path in (small_pdb, large_pdb):
325
+ if not path.is_file() or path.stat().st_size == 0:
326
+ raise McpbPySCFError(f"MCPB.py step 1 did not produce model PDB: {path}")
327
+
328
+ qmmm_refinement_source: Path | None = None
329
+ if config.geometry_source == "qmmm_refinement":
330
+ if refinement_result is None:
331
+ raise McpbPySCFError(
332
+ f"Metal site {site.config.id!r} requests geometry_source: "
333
+ "qmmm_refinement, but no completed refinement result reached the metal stage."
334
+ )
335
+ qmmm_refinement_source = refinement_result.refined_hydrogenated_pdb_path
336
+ if not qmmm_refinement_source.is_file():
337
+ raise McpbPySCFError(
338
+ "The completed refinement PDB recorded for MCPB geometry provenance is "
339
+ f"missing: {qmmm_refinement_source}"
340
+ )
341
+ small = _run_small_model(
342
+ small_pdb,
343
+ output_fchk=work / f"{group_name}_small_opt.fchk",
344
+ charge=mcpb.small_model_charge,
345
+ multiplicity=mcpb.small_model_spin,
346
+ config=config,
347
+ user_optimized_pdb=(
348
+ Path(config.optimized_small_model_pdb)
349
+ if config.optimized_small_model_pdb is not None
350
+ else None
351
+ ),
352
+ qmmm_refinement_source_pdb=qmmm_refinement_source,
353
+ work_dir=work / "pyscf_small",
354
+ )
355
+ large = _run_large_model(
356
+ site,
357
+ large_pdb,
358
+ output_mk_log=work / f"{group_name}_large_mk.log",
359
+ charge=mcpb.large_model_charge,
360
+ multiplicity=mcpb.large_model_spin,
361
+ config=config,
362
+ provisional_prmtop=Path(provisional_prmtop),
363
+ provisional_inpcrd=Path(provisional_inpcrd),
364
+ provisional_structure=provisional_structure,
365
+ work_dir=work / "pyscf_large",
366
+ )
367
+ report_path = work / "pyscf_mcpb_report.json"
368
+ result = McpbPySCFResult(
369
+ method=config.method,
370
+ basis=config.basis,
371
+ dft_grid_level=config.dft_grid_level,
372
+ small_model_charge=mcpb.small_model_charge,
373
+ small_model_multiplicity=mcpb.small_model_spin,
374
+ large_model_charge=mcpb.large_model_charge,
375
+ large_model_multiplicity=mcpb.large_model_spin,
376
+ small=small,
377
+ large=large,
378
+ report_path=report_path,
379
+ )
380
+ report_path.write_text(
381
+ json.dumps(result.to_dict(), indent=2, sort_keys=True) + "\n",
382
+ encoding="utf-8",
383
+ )
384
+ return result
385
+
386
+
387
+ def _run_small_model(
388
+ input_pdb: Path,
389
+ *,
390
+ output_fchk: Path,
391
+ charge: int,
392
+ multiplicity: int,
393
+ config: McpbPySCFConfig,
394
+ user_optimized_pdb: Path | None,
395
+ qmmm_refinement_source_pdb: Path | None = None,
396
+ work_dir: Path,
397
+ ) -> SmallModelResult:
398
+ generated_structure = read_pdb(input_pdb)
399
+ generated_elements, _ = _elements_and_coordinates(generated_structure)
400
+ if config.geometry_source == "qmmm_refinement":
401
+ if qmmm_refinement_source_pdb is None:
402
+ raise McpbPySCFError(
403
+ "geometry_source: qmmm_refinement requires completed refinement provenance"
404
+ )
405
+ optimized_path = input_pdb.resolve()
406
+ _, optimized_coordinates = _elements_and_coordinates(generated_structure)
407
+ geometry_status = "qmmm_refined"
408
+ geometry_label = "QM/MM-refined MCPB geometry"
409
+ evaluated_stem = "small_qmmm_refined_fixed_geometry"
410
+ else:
411
+ if user_optimized_pdb is None:
412
+ raise McpbPySCFError(
413
+ "geometry_source: external_pdb requires optimized_small_model_pdb"
414
+ )
415
+ optimized_path = user_optimized_pdb.resolve()
416
+ if optimized_path == input_pdb.resolve():
417
+ raise McpbPySCFError(
418
+ "mcpb.pyscf.optimized_small_model_pdb points to MCPB.py's unoptimized "
419
+ f"step-1 model ({input_pdb}). Supply a separate, user-optimized PDB."
420
+ )
421
+ if not optimized_path.is_file() or optimized_path.stat().st_size == 0:
422
+ raise McpbPySCFError(
423
+ "The required user-optimized MCPB small-model geometry does not exist or is "
424
+ f"empty: {optimized_path}. Optimize {input_pdb} externally without changing "
425
+ "atom order or identity, set mcpb.pyscf.optimized_small_model_pdb to that "
426
+ "PDB, and rerun."
427
+ )
428
+ optimized_structure = read_pdb(optimized_path)
429
+ optimized_elements, optimized_coordinates = _elements_and_coordinates(
430
+ optimized_structure
431
+ )
432
+ _validate_user_optimized_small_model(
433
+ generated_structure,
434
+ generated_elements=generated_elements,
435
+ user_structure=optimized_structure,
436
+ user_elements=optimized_elements,
437
+ )
438
+ assert config.small_model_geometry_status is not None
439
+ geometry_status = config.small_model_geometry_status
440
+ geometry_label = "MCPB user-supplied fixed geometry"
441
+ evaluated_stem = "small_user_optimized_fixed_geometry"
442
+ scientific_warning = (
443
+ "TEST ONLY [UNOPTIMIZED_MCPB_GEOMETRY]: the small-model coordinates were "
444
+ "explicitly accepted without external geometry optimization. Derived bonded "
445
+ "parameters and the final topology are not production-quality."
446
+ if geometry_status == "test_unoptimized"
447
+ else None
448
+ )
449
+ spin = multiplicity - 1
450
+ work_dir.mkdir(parents=True, exist_ok=True)
451
+
452
+ evaluated_structure = _structure_with_coordinates(
453
+ generated_structure,
454
+ optimized_coordinates,
455
+ path=work_dir / f"{evaluated_stem}.pdb",
456
+ )
457
+ write_pdb(evaluated_structure, evaluated_structure.path)
458
+ evaluated_xyz = work_dir / f"{evaluated_stem}.xyz"
459
+ _write_xyz(
460
+ generated_elements,
461
+ optimized_coordinates,
462
+ evaluated_xyz,
463
+ geometry_label,
464
+ )
465
+
466
+ total_started = perf_counter()
467
+ checkpoint: Path | None
468
+ scf_runtime: float | None
469
+ external_command_records: tuple[dict[str, object], ...]
470
+ if config.hessian_backend == "pyscf":
471
+ checkpoint = work_dir / "small_fixed_geometry.chk"
472
+ scf_started = perf_counter()
473
+ try:
474
+ scf = run_pyscf_scf(
475
+ elements=generated_elements,
476
+ coordinates=optimized_coordinates,
477
+ charge=charge,
478
+ spin=spin,
479
+ method=config.method,
480
+ basis=config.basis,
481
+ max_cycle=config.max_cycle,
482
+ conv_tol=config.conv_tol,
483
+ checkpoint_path=checkpoint,
484
+ work_dir=work_dir,
485
+ num_threads=config.num_threads,
486
+ max_memory_mb=config.max_memory_mb,
487
+ dft_grid_level=config.dft_grid_level,
488
+ scf_algorithm=config.scf_algorithm,
489
+ initial_guess=config.initial_guess,
490
+ adiis_precondition_cycles=config.adiis_precondition_cycles,
491
+ adiis_precondition_dft_grid_level=(
492
+ config.adiis_precondition_dft_grid_level
493
+ ),
494
+ level_shift_mode=config.level_shift_mode,
495
+ level_shift_hartree=config.level_shift_hartree,
496
+ damping_factor=config.damping_factor,
497
+ diis_space=config.diis_space,
498
+ )
499
+ except PySCFRunnerError as exc:
500
+ raise McpbPySCFError(
501
+ f"Fixed-geometry MCPB small-model SCF failed: {exc}"
502
+ ) from exc
503
+ scf_runtime = perf_counter() - scf_started
504
+ stdout_path = work_dir / "small_fixed_geometry_scf.stdout.txt"
505
+ stderr_path = work_dir / "small_fixed_geometry_scf.stderr.txt"
506
+ stdout_path.write_text(scf.stdout, encoding="utf-8")
507
+ stderr_path.write_text(scf.stderr, encoding="utf-8")
508
+ (work_dir / "small_hessian_progress.json").write_text(
509
+ json.dumps(
510
+ {
511
+ "hessian_backend": "pyscf",
512
+ "backend_version": pyscf_version(),
513
+ "method": config.method,
514
+ "basis": config.basis,
515
+ "fixed_geometry_scf_complete": True,
516
+ "energy_hartree": scf.energy_hartree,
517
+ "scf_runtime_seconds": scf_runtime,
518
+ "analytic_hessian_complete": False,
519
+ },
520
+ indent=2,
521
+ sort_keys=True,
522
+ )
523
+ + "\n",
524
+ encoding="utf-8",
525
+ )
526
+ hessian_started = perf_counter()
527
+ try:
528
+ hessian_raw = run_pyscf_analytic_hessian(
529
+ scf.mf,
530
+ num_threads=config.num_threads,
531
+ )
532
+ except PySCFRunnerError as exc:
533
+ raise McpbPySCFError(
534
+ f"PySCF analytic Hessian failed for {config.method}/{config.basis}: {exc}"
535
+ ) from exc
536
+ hessian_runtime = perf_counter() - hessian_started
537
+ energy_hartree = scf.energy_hartree
538
+ backend_version = pyscf_version()
539
+ scf_calculation_count: int | None = 1
540
+ external_command_records = ()
541
+ calculation_name = "fixed_geometry_single_point_scf_and_analytic_hessian"
542
+ hessian_note = (
543
+ "The PySCF analytic Hessian is required for Seminario force constants; "
544
+ "all nuclei remained fixed at the selected coordinates."
545
+ )
546
+ hessian_backend = "pyscf"
547
+ elif config.hessian_backend in {"xtb", "gxtb"}:
548
+ xtb_config = config.xtb if config.hessian_backend == "xtb" else config.gxtb
549
+ assert xtb_config is not None
550
+ from mdprep.metals.gxtb_hessian import GxtbHessianError, run_gxtb_hessian
551
+
552
+ try:
553
+ gxtb_result = run_gxtb_hessian(
554
+ xyz_path=evaluated_xyz,
555
+ atom_count=len(generated_elements),
556
+ charge=charge,
557
+ spin=spin,
558
+ config=xtb_config,
559
+ work_dir=work_dir,
560
+ )
561
+ except GxtbHessianError as exc:
562
+ raise McpbPySCFError(f"xTB fixed-geometry Hessian failed: {exc}") from exc
563
+ if (
564
+ geometry_status != "test_unoptimized"
565
+ and gxtb_result.raw_hessian_asymmetry_warning_count > 0
566
+ ):
567
+ raise McpbPySCFError(
568
+ "xTB reported "
569
+ f"{gxtb_result.raw_hessian_asymmetry_warning_count} raw numerical "
570
+ "Hessian-asymmetry warnings for a production geometry. Tighten "
571
+ "the xTB accuracy or provide a better-converged geometry."
572
+ )
573
+ checkpoint = None
574
+ scf_runtime = None
575
+ hessian_runtime = gxtb_result.command_result.runtime_seconds
576
+ hessian_raw = gxtb_result.hessian_hartree_per_bohr2
577
+ energy_hartree = gxtb_result.energy_hartree
578
+ backend_version = (
579
+ f"{xtb_config.release_tag}; {gxtb_result.version_text}"
580
+ if xtb_config.release_tag
581
+ else gxtb_result.version_text
582
+ )
583
+ scf_calculation_count = None
584
+ stdout_path = gxtb_result.stdout_path
585
+ stderr_path = gxtb_result.stderr_path
586
+ gxtb_metadata = gxtb_result.to_dict()
587
+ external_command_records = (
588
+ dict(gxtb_metadata["version_command"]), # type: ignore[arg-type]
589
+ dict(gxtb_metadata["hessian_command"]), # type: ignore[arg-type]
590
+ )
591
+ hessian_backend = "gfn2_xtb" if xtb_config.model == "gfn2" else "gxtb"
592
+ calculation_name = f"fixed_geometry_{hessian_backend}_numerical_hessian"
593
+ hessian_note = (
594
+ f"The {hessian_backend} Hessian was evaluated at the selected coordinates by "
595
+ "numerical differentiation of analytic gradients; no geometry "
596
+ "optimization was performed."
597
+ )
598
+ else:
599
+ mace_config = config.mace_polar1
600
+ assert mace_config is not None
601
+ from mdprep.metals.mace_polar1_hessian import (
602
+ MacePolar1HessianError,
603
+ run_mace_polar1_hessian,
604
+ )
605
+
606
+ try:
607
+ mace_result = run_mace_polar1_hessian(
608
+ xyz_path=evaluated_xyz,
609
+ atom_count=len(generated_elements),
610
+ charge=charge,
611
+ multiplicity=multiplicity,
612
+ config=mace_config,
613
+ work_dir=work_dir,
614
+ )
615
+ except MacePolar1HessianError as exc:
616
+ raise McpbPySCFError(
617
+ f"MACE-POLAR-1 fixed-geometry Hessian failed: {exc}"
618
+ ) from exc
619
+ checkpoint = None
620
+ scf_runtime = None
621
+ hessian_runtime = mace_result.command_result.runtime_seconds
622
+ hessian_raw = mace_result.hessian_hartree_per_bohr2
623
+ energy_hartree = mace_result.energy_hartree
624
+ mace_version = mace_result.versions.get("mace_torch") or "unknown"
625
+ backend_version = f"mace-torch {mace_version}; {mace_result.model}"
626
+ scf_calculation_count = None
627
+ stdout_path = mace_result.stdout_path
628
+ stderr_path = mace_result.stderr_path
629
+ mace_metadata = mace_result.to_dict()
630
+ external_command_records = (
631
+ dict(mace_metadata["probe_command"]), # type: ignore[arg-type]
632
+ dict(mace_metadata["hessian_command"]), # type: ignore[arg-type]
633
+ )
634
+ hessian_backend = "mace_polar1"
635
+ calculation_name = "fixed_geometry_mace_polar1_analytical_hessian"
636
+ hessian_note = (
637
+ "The float64 MACE-POLAR-1 analytical Hessian was evaluated at the "
638
+ "selected charge, multiplicity, and coordinates and checked against a "
639
+ "deterministic central difference of MACE forces; no geometry optimization "
640
+ "was performed."
641
+ )
642
+
643
+ hessian = _cartesian_hessian_matrix(hessian_raw, len(generated_elements))
644
+ antisymmetry = float(np.max(np.abs(hessian - hessian.T)))
645
+ if antisymmetry > 1.0e-5:
646
+ raise McpbPySCFError(
647
+ f"Cartesian Hessian is not symmetric (max antisymmetry {antisymmetry:.3e})."
648
+ )
649
+ hessian = 0.5 * (hessian + hessian.T)
650
+ optimized_bohr = optimized_coordinates.reshape(-1) * BOHR_PER_ANGSTROM
651
+ _write_mcpb_fchk(
652
+ output_fchk,
653
+ coordinates_bohr=optimized_bohr,
654
+ hessian_hartree_per_bohr2=hessian,
655
+ )
656
+ _validate_fchk_counts(output_fchk, atom_count=len(generated_elements))
657
+ total_runtime = perf_counter() - total_started
658
+ calculation_report = work_dir / "single_point_hessian.json"
659
+ calculation_report.write_text(
660
+ json.dumps(
661
+ {
662
+ "calculation": calculation_name,
663
+ "hessian_backend": hessian_backend,
664
+ "backend_version": backend_version,
665
+ "geometry_optimization_performed": False,
666
+ "geometry_source": config.geometry_source,
667
+ "geometry_status": geometry_status,
668
+ "qmmm_refinement_source_pdb": (
669
+ str(qmmm_refinement_source_pdb)
670
+ if qmmm_refinement_source_pdb is not None
671
+ else None
672
+ ),
673
+ "scientific_warning": scientific_warning,
674
+ "mcpb_generated_pdb": str(input_pdb),
675
+ "user_optimized_pdb": str(optimized_path),
676
+ "evaluated_pdb": str(evaluated_structure.path),
677
+ "atom_identity_validation": "exact",
678
+ "atom_count": len(generated_elements),
679
+ "molecular_charge": charge,
680
+ "multiplicity": multiplicity,
681
+ "spin": spin,
682
+ "energy_hartree": energy_hartree,
683
+ "scf_calculation_count": scf_calculation_count,
684
+ "hessian_antisymmetry_max": antisymmetry,
685
+ "scf_runtime_seconds": scf_runtime,
686
+ "hessian_runtime_seconds": hessian_runtime,
687
+ "total_runtime_seconds": total_runtime,
688
+ "external_commands": list(external_command_records),
689
+ "note": hessian_note,
690
+ },
691
+ indent=2,
692
+ sort_keys=True,
693
+ )
694
+ + "\n",
695
+ encoding="utf-8",
696
+ )
697
+ return SmallModelResult(
698
+ mcpb_generated_pdb_path=input_pdb,
699
+ user_optimized_pdb_path=optimized_path,
700
+ qmmm_refinement_source_pdb_path=qmmm_refinement_source_pdb,
701
+ evaluated_pdb_path=evaluated_structure.path,
702
+ evaluated_xyz_path=evaluated_xyz,
703
+ checkpoint_path=checkpoint,
704
+ fchk_path=output_fchk,
705
+ calculation_report_path=calculation_report,
706
+ scf_stdout_path=stdout_path,
707
+ scf_stderr_path=stderr_path,
708
+ atom_count=len(generated_elements),
709
+ energy_hartree=energy_hartree,
710
+ geometry_status=geometry_status,
711
+ scientific_warning=scientific_warning,
712
+ geometry_optimization_performed=False,
713
+ scf_calculation_count=scf_calculation_count,
714
+ hessian_antisymmetry_max=antisymmetry,
715
+ hessian_backend=hessian_backend,
716
+ backend_version=backend_version,
717
+ scf_runtime_seconds=scf_runtime,
718
+ hessian_runtime_seconds=hessian_runtime,
719
+ total_runtime_seconds=total_runtime,
720
+ external_command_records=external_command_records,
721
+ )
722
+
723
+
724
+ def _run_large_model(
725
+ site: ResolvedMetalSite,
726
+ input_pdb: Path,
727
+ *,
728
+ output_mk_log: Path,
729
+ charge: int,
730
+ multiplicity: int,
731
+ config: McpbPySCFConfig,
732
+ provisional_prmtop: Path,
733
+ provisional_inpcrd: Path,
734
+ provisional_structure: PdbStructure,
735
+ work_dir: Path,
736
+ ) -> LargeModelResult:
737
+ structure = read_pdb(input_pdb)
738
+ elements, coordinates = _elements_and_coordinates(structure)
739
+ work_dir.mkdir(parents=True, exist_ok=True)
740
+ embedding = extract_mcpb_embedding_charges(
741
+ site,
742
+ prmtop=provisional_prmtop,
743
+ inpcrd=provisional_inpcrd,
744
+ provisional_structure=provisional_structure,
745
+ qm_coordinates=coordinates,
746
+ cutoff_angstrom=config.embedding_cutoff_angstrom,
747
+ minimum_distance_angstrom=config.embedding_min_distance_angstrom,
748
+ )
749
+ if not embedding.point_charges:
750
+ raise McpbPySCFError(
751
+ "The configured MCPB PySCF electrostatic embedding selected zero MM point "
752
+ "charges; use workflow: complete with reviewed external artifacts if a gas-phase "
753
+ "large model is intended."
754
+ )
755
+ point_csv = work_dir / "mm_point_charges.csv"
756
+ point_xyz = work_dir / "mm_point_charges.xyz"
757
+ point_report = work_dir / "mm_point_charges.json"
758
+ _write_embedding_files(
759
+ embedding,
760
+ csv_path=point_csv,
761
+ xyz_path=point_xyz,
762
+ report_path=point_report,
763
+ )
764
+ checkpoint = work_dir / "large_embedded.chk"
765
+ try:
766
+ scf_result = run_pyscf_scf(
767
+ elements=elements,
768
+ coordinates=coordinates,
769
+ charge=charge,
770
+ spin=multiplicity - 1,
771
+ method=config.method,
772
+ basis=config.basis,
773
+ max_cycle=config.max_cycle,
774
+ conv_tol=config.conv_tol,
775
+ mm_charges=embedding.charge_array,
776
+ mm_coordinates=embedding.coordinate_array,
777
+ checkpoint_path=checkpoint,
778
+ initial_density_checkpoint_path=(
779
+ config.large_model_restart_checkpoint
780
+ ),
781
+ expected_initial_density_checkpoint_sha256=(
782
+ config.large_model_restart_checkpoint_sha256
783
+ ),
784
+ initial_density_checkpoint_max_displacement_angstrom=(
785
+ config.large_model_restart_max_displacement_angstrom
786
+ ),
787
+ work_dir=work_dir,
788
+ num_threads=config.num_threads,
789
+ max_memory_mb=config.max_memory_mb,
790
+ dft_grid_level=config.dft_grid_level,
791
+ scf_algorithm=config.scf_algorithm,
792
+ initial_guess=config.initial_guess,
793
+ adiis_precondition_cycles=config.adiis_precondition_cycles,
794
+ adiis_precondition_dft_grid_level=(
795
+ config.adiis_precondition_dft_grid_level
796
+ ),
797
+ level_shift_mode=config.level_shift_mode,
798
+ level_shift_hartree=config.level_shift_hartree,
799
+ damping_factor=config.damping_factor,
800
+ diis_space=config.diis_space,
801
+ density_fitting=config.large_model_density_fitting,
802
+ auxiliary_basis=config.large_model_auxbasis,
803
+ )
804
+ except PySCFRunnerError as exc:
805
+ raise McpbPySCFError(f"Embedded MCPB large-model SCF failed: {exc}") from exc
806
+ if not scf_result.electrostatic_embedding_applied:
807
+ raise McpbPySCFError(
808
+ "MCPB large-model PySCF result did not record electrostatic embedding."
809
+ )
810
+
811
+ grid_config = config.grid
812
+ grid = generate_merz_kollman_grid(
813
+ elements=elements,
814
+ coordinates=coordinates,
815
+ vdw_scale_factors=grid_config.vdw_scale_factors,
816
+ point_density_per_square_angstrom=(
817
+ grid_config.point_density_per_square_angstrom
818
+ ),
819
+ exclude_inside_vdw_scale=grid_config.exclude_inside_vdw_scale,
820
+ max_points=grid_config.max_points,
821
+ )
822
+ try:
823
+ esp_values = evaluate_ligand_esp(
824
+ mol=scf_result.mol,
825
+ mf=scf_result.mf,
826
+ grid_coordinates_angstrom=grid.points,
827
+ batch_size=config.esp_batch_size,
828
+ )
829
+ atomic_esp_values = _evaluate_atomic_esp_without_self(scf_result)
830
+ except Exception as exc:
831
+ raise McpbPySCFError(f"PySCF large-model ESP evaluation failed: {exc}") from exc
832
+ grid_path = work_dir / "large_model_esp_grid.xyz"
833
+ values_path = work_dir / "large_model_esp_values.txt"
834
+ write_grid_xyz(grid, grid_path)
835
+ values_path.write_text(
836
+ "".join(f"{float(value):.12e}\n" for value in esp_values),
837
+ encoding="utf-8",
838
+ )
839
+ write_mcpb_gaussian_esp_adapter(
840
+ output_mk_log,
841
+ atomic_coordinates_angstrom=coordinates,
842
+ atomic_esp_values=atomic_esp_values,
843
+ fit_coordinates_angstrom=grid.points,
844
+ fit_esp_values=esp_values,
845
+ )
846
+ _validate_mk_adapter(
847
+ output_mk_log,
848
+ atom_count=len(elements),
849
+ fit_count=len(grid.points),
850
+ )
851
+ stdout_path = work_dir / "large_embedded_scf.stdout.txt"
852
+ stderr_path = work_dir / "large_embedded_scf.stderr.txt"
853
+ stdout_path.write_text(scf_result.stdout, encoding="utf-8")
854
+ stderr_path.write_text(scf_result.stderr, encoding="utf-8")
855
+ return LargeModelResult(
856
+ input_pdb_path=input_pdb,
857
+ checkpoint_path=checkpoint,
858
+ mk_log_path=output_mk_log,
859
+ grid_xyz_path=grid_path,
860
+ esp_values_path=values_path,
861
+ point_charge_csv_path=point_csv,
862
+ point_charge_xyz_path=point_xyz,
863
+ point_charge_report_path=point_report,
864
+ scf_stdout_path=stdout_path,
865
+ scf_stderr_path=stderr_path,
866
+ atom_count=len(elements),
867
+ energy_hartree=scf_result.energy_hartree,
868
+ grid_point_count=len(grid.points),
869
+ embedding=embedding,
870
+ scf_algorithm=scf_result.scf_algorithm,
871
+ initial_guess=scf_result.initial_guess,
872
+ initial_density_checkpoint_path=(
873
+ scf_result.initial_density_checkpoint_path
874
+ ),
875
+ initial_density_checkpoint_sha256=(
876
+ scf_result.initial_density_checkpoint_sha256
877
+ ),
878
+ initial_density_checkpoint_max_displacement_angstrom=(
879
+ scf_result.initial_density_checkpoint_max_displacement_angstrom
880
+ ),
881
+ initial_density_checkpoint_rms_displacement_angstrom=(
882
+ scf_result.initial_density_checkpoint_rms_displacement_angstrom
883
+ ),
884
+ initial_density_checkpoint_projected=(
885
+ scf_result.initial_density_checkpoint_projected
886
+ ),
887
+ adiis_precondition_cycles=scf_result.adiis_precondition_cycles,
888
+ adiis_precondition_dft_grid_level=(
889
+ scf_result.adiis_precondition_dft_grid_level
890
+ ),
891
+ precondition_cycles_completed=scf_result.precondition_cycles_completed,
892
+ level_shift_mode=scf_result.level_shift_mode,
893
+ level_shift_hartree=scf_result.level_shift_hartree,
894
+ damping_factor=scf_result.damping_factor,
895
+ diis_space=scf_result.diis_space,
896
+ density_fitting=scf_result.density_fitting,
897
+ auxiliary_basis=scf_result.auxiliary_basis,
898
+ spin_square=scf_result.spin_square,
899
+ effective_multiplicity=scf_result.effective_multiplicity,
900
+ )
901
+
902
+
903
+ def extract_mcpb_embedding_charges(
904
+ site: ResolvedMetalSite,
905
+ *,
906
+ prmtop: str | Path,
907
+ inpcrd: str | Path,
908
+ provisional_structure: PdbStructure,
909
+ qm_coordinates: np.ndarray,
910
+ cutoff_angstrom: float | None,
911
+ minimum_distance_angstrom: float,
912
+ ) -> McpbEmbeddingSelection:
913
+ """Select MM charges while excluding every residue represented in the QM model."""
914
+
915
+ try:
916
+ import parmed
917
+ except Exception as exc:
918
+ raise McpbPySCFError(
919
+ "ParmEd is required to extract the MCPB electrostatic environment."
920
+ ) from exc
921
+ try:
922
+ topology = parmed.load_file(str(prmtop), str(inpcrd))
923
+ except Exception as exc:
924
+ raise McpbPySCFError(
925
+ f"Could not load the pre-MCPB Amber topology with ParmEd: {exc}"
926
+ ) from exc
927
+ if topology.coordinates is None:
928
+ raise McpbPySCFError("Pre-MCPB Amber topology contains no coordinates.")
929
+ if len(topology.residues) != len(provisional_structure.residues):
930
+ raise McpbPySCFError(
931
+ "Pre-MCPB topology/residue mapping is inconsistent: "
932
+ f"{len(topology.residues)} topology residues versus "
933
+ f"{len(provisional_structure.residues)} restored PDB residues."
934
+ )
935
+ excluded_keys = {
936
+ (
937
+ ion.residue.id.chain_id,
938
+ ion.residue.id.resid,
939
+ ion.residue.id.icode,
940
+ )
941
+ for ion in site.ions
942
+ }
943
+ excluded_keys.update(
944
+ (
945
+ bond.coordinator_residue.id.chain_id,
946
+ bond.coordinator_residue.id.resid,
947
+ bond.coordinator_residue.id.icode,
948
+ )
949
+ for bond in site.bonds
950
+ )
951
+ excluded_keys.update(
952
+ (residue.id.chain_id, residue.id.resid, residue.id.icode)
953
+ for residue in site.additional_residues
954
+ )
955
+ topology_coordinates = np.asarray(topology.coordinates, dtype=float)
956
+ qm_coords = np.asarray(qm_coordinates, dtype=float)
957
+ charges: list[PointCharge] = []
958
+ distances: list[float] = []
959
+ categories: dict[str, int] = {}
960
+ candidate_count = 0
961
+ too_close = 0
962
+ beyond_cutoff = 0
963
+ excluded_residue_count = 0
964
+ for topology_residue, pdb_residue in zip(
965
+ topology.residues,
966
+ provisional_structure.residues,
967
+ strict=True,
968
+ ):
969
+ topology_names = [str(atom.name) for atom in topology_residue.atoms]
970
+ pdb_names = [atom.name for atom in pdb_residue.atoms]
971
+ if topology_names != pdb_names:
972
+ raise McpbPySCFError(
973
+ "Pre-MCPB topology atom mapping changed for "
974
+ f"{pdb_residue.id.display()}: topology names {topology_names}, "
975
+ f"PDB names {pdb_names}."
976
+ )
977
+ key = (
978
+ pdb_residue.id.chain_id,
979
+ pdb_residue.id.resid,
980
+ pdb_residue.id.icode,
981
+ )
982
+ if key in excluded_keys:
983
+ excluded_residue_count += 1
984
+ continue
985
+ category = _residue_category(pdb_residue)
986
+ for topology_atom in topology_residue.atoms:
987
+ candidate_count += 1
988
+ index = int(topology_atom.idx)
989
+ coordinate = topology_coordinates[index]
990
+ distance = float(
991
+ np.min(np.linalg.norm(qm_coords - coordinate[None, :], axis=1))
992
+ )
993
+ if distance < minimum_distance_angstrom:
994
+ too_close += 1
995
+ continue
996
+ if cutoff_angstrom is not None and distance > cutoff_angstrom:
997
+ beyond_cutoff += 1
998
+ continue
999
+ charges.append(
1000
+ PointCharge(
1001
+ x=float(coordinate[0]),
1002
+ y=float(coordinate[1]),
1003
+ z=float(coordinate[2]),
1004
+ charge=float(topology_atom.charge),
1005
+ residue_name=pdb_residue.id.resname,
1006
+ residue_number=pdb_residue.id.resid,
1007
+ atom_name=str(topology_atom.name),
1008
+ category=category,
1009
+ )
1010
+ )
1011
+ distances.append(distance)
1012
+ categories[category] = categories.get(category, 0) + 1
1013
+ return McpbEmbeddingSelection(
1014
+ point_charges=tuple(charges),
1015
+ excluded_qm_residue_count=excluded_residue_count,
1016
+ candidate_atom_count=candidate_count,
1017
+ excluded_by_minimum_distance=too_close,
1018
+ excluded_by_cutoff=beyond_cutoff,
1019
+ minimum_distance_angstrom=min(distances) if distances else None,
1020
+ maximum_distance_angstrom=max(distances) if distances else None,
1021
+ net_embedding_charge=float(sum(item.charge for item in charges)),
1022
+ categories=categories,
1023
+ )
1024
+
1025
+
1026
+ def write_mcpb_gaussian_esp_adapter(
1027
+ path: str | Path,
1028
+ *,
1029
+ atomic_coordinates_angstrom: np.ndarray,
1030
+ atomic_esp_values: np.ndarray,
1031
+ fit_coordinates_angstrom: np.ndarray,
1032
+ fit_esp_values: np.ndarray,
1033
+ ) -> None:
1034
+ """Write the exact Gaussian text subset parsed by MCPB.py's ESP reader."""
1035
+
1036
+ atomic_coordinates = np.asarray(atomic_coordinates_angstrom, dtype=float)
1037
+ atomic_values = np.asarray(atomic_esp_values, dtype=float)
1038
+ fit_coordinates = np.asarray(fit_coordinates_angstrom, dtype=float)
1039
+ fit_values = np.asarray(fit_esp_values, dtype=float)
1040
+ if atomic_coordinates.shape != (len(atomic_values), 3):
1041
+ raise McpbPySCFError("Atomic ESP adapter arrays have inconsistent shapes.")
1042
+ if fit_coordinates.shape != (len(fit_values), 3) or not len(fit_values):
1043
+ raise McpbPySCFError("Fit ESP adapter arrays have inconsistent or empty shapes.")
1044
+ if not all(
1045
+ np.all(np.isfinite(values))
1046
+ for values in (
1047
+ atomic_coordinates,
1048
+ atomic_values,
1049
+ fit_coordinates,
1050
+ fit_values,
1051
+ )
1052
+ ):
1053
+ raise McpbPySCFError("ESP adapter contains non-finite values.")
1054
+ lines = [
1055
+ " PySCF electrostatically embedded MCPB large-model ESP adapter",
1056
+ " Electrostatic Properties Using The SCF Density",
1057
+ ]
1058
+ for index, coordinate in enumerate(atomic_coordinates, start=1):
1059
+ prefix = f" Atomic Center {index:5d}"
1060
+ lines.append(
1061
+ f"{prefix:<32}{coordinate[0]:10.6f}{coordinate[1]:10.6f}"
1062
+ f"{coordinate[2]:10.6f}"
1063
+ )
1064
+ for index, coordinate in enumerate(fit_coordinates, start=1):
1065
+ prefix = f" ESP Fit Center {index:5d}"
1066
+ lines.append(
1067
+ f"{prefix:<32}{coordinate[0]:10.6f}{coordinate[1]:10.6f}"
1068
+ f"{coordinate[2]:10.6f}"
1069
+ )
1070
+ lines.extend(
1071
+ [
1072
+ " Electrostatic Properties (Atomic Units)",
1073
+ " -----------------------------------------------------------------",
1074
+ " Center Type Electrostatic Potential",
1075
+ " -----------------------------------------------------------------",
1076
+ " Values below are evaluated from the polarized PySCF QM density.",
1077
+ " MM embedding charges are excluded from the reported ESP.",
1078
+ ]
1079
+ )
1080
+ lines.extend(
1081
+ f" Atom {index:6d} Potential {value: .12E}"
1082
+ for index, value in enumerate(atomic_values, start=1)
1083
+ )
1084
+ lines.extend(
1085
+ f" Fit {index:6d} Potential {value: .12E}"
1086
+ for index, value in enumerate(fit_values, start=1)
1087
+ )
1088
+ lines.append("")
1089
+ Path(path).write_text("\n".join(lines), encoding="utf-8")
1090
+
1091
+
1092
+ def _evaluate_atomic_esp_without_self(result: PySCFResult) -> np.ndarray:
1093
+ mol = result.mol
1094
+ mf = result.mf
1095
+ atom_coordinates = np.asarray(mol.atom_coords(), dtype=float)
1096
+ nuclear_charges = np.asarray(mol.atom_charges(), dtype=float)
1097
+ density = mf.make_rdm1()
1098
+ if isinstance(density, tuple) or (
1099
+ hasattr(density, "ndim") and density.ndim == 3
1100
+ ):
1101
+ total_density = np.asarray(density[0]) + np.asarray(density[1])
1102
+ else:
1103
+ total_density = np.asarray(density)
1104
+ values: list[float] = []
1105
+ for index, origin in enumerate(atom_coordinates):
1106
+ distances = np.linalg.norm(atom_coordinates - origin[None, :], axis=1)
1107
+ mask = np.arange(len(atom_coordinates)) != index
1108
+ nuclear = float(np.sum(nuclear_charges[mask] / distances[mask]))
1109
+ mol.set_rinv_origin(origin)
1110
+ electronic = -float(
1111
+ np.einsum("ij,ij->", total_density, mol.intor("int1e_rinv"))
1112
+ )
1113
+ values.append(nuclear + electronic)
1114
+ result_array = np.asarray(values, dtype=float)
1115
+ if not np.all(np.isfinite(result_array)):
1116
+ raise McpbPySCFError("Atomic ESP evaluation produced non-finite values.")
1117
+ return result_array
1118
+
1119
+
1120
+ def _cartesian_hessian_matrix(raw: np.ndarray, atom_count: int) -> np.ndarray:
1121
+ expected_matrix = (atom_count * 3, atom_count * 3)
1122
+ if raw.shape == expected_matrix:
1123
+ matrix = raw
1124
+ elif raw.shape == (atom_count, atom_count, 3, 3):
1125
+ matrix = raw.transpose(0, 2, 1, 3).reshape(expected_matrix)
1126
+ else:
1127
+ raise McpbPySCFError(
1128
+ f"PySCF Hessian shape {raw.shape} is incompatible with {atom_count} atoms."
1129
+ )
1130
+ if not np.all(np.isfinite(matrix)):
1131
+ raise McpbPySCFError("PySCF Cartesian Hessian contains non-finite values.")
1132
+ return np.asarray(matrix, dtype=float)
1133
+
1134
+
1135
+ def _write_mcpb_fchk(
1136
+ path: Path,
1137
+ *,
1138
+ coordinates_bohr: np.ndarray,
1139
+ hessian_hartree_per_bohr2: np.ndarray,
1140
+ ) -> None:
1141
+ coordinates = np.asarray(coordinates_bohr, dtype=float).reshape(-1)
1142
+ hessian = np.asarray(hessian_hartree_per_bohr2, dtype=float)
1143
+ if hessian.shape != (len(coordinates), len(coordinates)):
1144
+ raise McpbPySCFError(
1145
+ "Formatted-checkpoint coordinate and Hessian dimensions do not agree."
1146
+ )
1147
+ lower = np.asarray(
1148
+ [hessian[row, column] for row in range(len(coordinates)) for column in range(row + 1)],
1149
+ dtype=float,
1150
+ )
1151
+ lines = [
1152
+ "mdprep PySCF MCPB formatted-checkpoint adapter",
1153
+ "Generated for MCPB.py Seminario parsing",
1154
+ f"Current cartesian coordinates R N= {len(coordinates):12d}",
1155
+ *_format_fchk_values(coordinates),
1156
+ f"Cartesian Force Constants R N= {len(lower):12d}",
1157
+ *_format_fchk_values(lower),
1158
+ "",
1159
+ ]
1160
+ path.write_text("\n".join(lines), encoding="utf-8")
1161
+
1162
+
1163
+ def _format_fchk_values(values: np.ndarray) -> list[str]:
1164
+ result: list[str] = []
1165
+ for start in range(0, len(values), 5):
1166
+ result.append(
1167
+ "".join(f"{float(value):16.8E}" for value in values[start : start + 5])
1168
+ )
1169
+ return result
1170
+
1171
+
1172
+ def _validate_fchk_counts(path: Path, *, atom_count: int) -> None:
1173
+ text = path.read_text(encoding="utf-8")
1174
+ coordinate_count = 3 * atom_count
1175
+ hessian_count = coordinate_count * (coordinate_count + 1) // 2
1176
+ if (
1177
+ "Current cartesian coordinates" not in text
1178
+ or f"{coordinate_count:12d}" not in text
1179
+ or "Cartesian Force Constants" not in text
1180
+ or f"{hessian_count:12d}" not in text
1181
+ ):
1182
+ raise McpbPySCFError(
1183
+ f"Formatted-checkpoint validation failed for {path}."
1184
+ )
1185
+
1186
+
1187
+ def _validate_mk_adapter(path: Path, *, atom_count: int, fit_count: int) -> None:
1188
+ text = path.read_text(encoding="utf-8")
1189
+ if text.count(" Atomic Center") != atom_count:
1190
+ raise McpbPySCFError("MCPB MK adapter atomic-center count is incorrect.")
1191
+ if text.count(" ESP Fit Center") != fit_count:
1192
+ raise McpbPySCFError("MCPB MK adapter fit-center count is incorrect.")
1193
+ if sum(1 for line in text.splitlines() if " Atom " in f" {line} ") != atom_count:
1194
+ raise McpbPySCFError("MCPB MK adapter atomic-ESP count is incorrect.")
1195
+ if sum(1 for line in text.splitlines() if line.startswith(" Fit ")) != fit_count:
1196
+ raise McpbPySCFError("MCPB MK adapter fit-ESP count is incorrect.")
1197
+
1198
+
1199
+ def _elements_and_coordinates(
1200
+ structure: PdbStructure,
1201
+ ) -> tuple[list[str], np.ndarray]:
1202
+ elements: list[str] = []
1203
+ coordinates: list[list[float]] = []
1204
+ for atom in structure.atoms:
1205
+ element = atom.element or infer_element(
1206
+ atom.name,
1207
+ resname=atom.resname,
1208
+ record_name=atom.record_name,
1209
+ atom_field=(atom.original_line[12:16] if atom.original_line else None),
1210
+ )
1211
+ if not element:
1212
+ raise McpbPySCFError(
1213
+ f"Could not infer element for MCPB model atom {atom.atom_identity}."
1214
+ )
1215
+ elements.append(str(element).capitalize())
1216
+ coordinates.append([atom.x, atom.y, atom.z])
1217
+ if not elements:
1218
+ raise McpbPySCFError(f"MCPB model {structure.path} contains zero atoms.")
1219
+ values = np.asarray(coordinates, dtype=float)
1220
+ if not np.all(np.isfinite(values)):
1221
+ raise McpbPySCFError(f"MCPB model {structure.path} has non-finite coordinates.")
1222
+ return elements, values
1223
+
1224
+
1225
+ def _validate_user_optimized_small_model(
1226
+ generated_structure: PdbStructure,
1227
+ *,
1228
+ generated_elements: list[str],
1229
+ user_structure: PdbStructure,
1230
+ user_elements: list[str],
1231
+ ) -> None:
1232
+ """Require a coordinate-only replacement of MCPB.py's small model."""
1233
+
1234
+ generated_count = len(generated_structure.atoms)
1235
+ user_count = len(user_structure.atoms)
1236
+ if user_count != generated_count:
1237
+ raise McpbPySCFError(
1238
+ "User-optimized MCPB small-model atom count does not match MCPB.py step 1: "
1239
+ f"expected {generated_count}, found {user_count}. Geometry optimization must "
1240
+ "not add, remove, or reorder atoms."
1241
+ )
1242
+ mismatches: list[str] = []
1243
+ for index, (generated, user, generated_element, user_element) in enumerate(
1244
+ zip(
1245
+ generated_structure.atoms,
1246
+ user_structure.atoms,
1247
+ generated_elements,
1248
+ user_elements,
1249
+ strict=True,
1250
+ ),
1251
+ start=1,
1252
+ ):
1253
+ expected = (
1254
+ generated_element,
1255
+ generated.name.strip(),
1256
+ generated.resname.strip(),
1257
+ generated.chain_id,
1258
+ generated.resid,
1259
+ generated.icode,
1260
+ )
1261
+ observed = (
1262
+ user_element,
1263
+ user.name.strip(),
1264
+ user.resname.strip(),
1265
+ user.chain_id,
1266
+ user.resid,
1267
+ user.icode,
1268
+ )
1269
+ if observed != expected:
1270
+ mismatches.append(
1271
+ f"atom {index}: expected {expected!r}, found {observed!r}"
1272
+ )
1273
+ if len(mismatches) == 5:
1274
+ break
1275
+ if mismatches:
1276
+ raise McpbPySCFError(
1277
+ "User-optimized MCPB small-model identity/order does not match MCPB.py "
1278
+ "step 1. Only coordinates may differ. First mismatches: "
1279
+ + "; ".join(mismatches)
1280
+ )
1281
+
1282
+
1283
+ def _structure_with_coordinates(
1284
+ structure: PdbStructure,
1285
+ coordinates: np.ndarray,
1286
+ *,
1287
+ path: Path,
1288
+ ) -> PdbStructure:
1289
+ from dataclasses import replace
1290
+
1291
+ atoms = [
1292
+ replace(atom, x=float(coord[0]), y=float(coord[1]), z=float(coord[2]))
1293
+ for atom, coord in zip(structure.atoms, coordinates, strict=True)
1294
+ ]
1295
+ by_key: dict[tuple[str, str, int, str | None], list[AtomRecord]] = {}
1296
+ for atom in atoms:
1297
+ by_key.setdefault(atom.residue_key, []).append(atom)
1298
+ residues = [
1299
+ ResidueRecord(
1300
+ id=residue.id,
1301
+ atoms=by_key[(
1302
+ residue.id.chain_id,
1303
+ residue.id.resname,
1304
+ residue.id.resid,
1305
+ residue.id.icode,
1306
+ )],
1307
+ record_names=residue.record_names,
1308
+ original_index=residue.original_index,
1309
+ )
1310
+ for residue in structure.residues
1311
+ ]
1312
+ return PdbStructure(
1313
+ path=path,
1314
+ atoms=atoms,
1315
+ residues=residues,
1316
+ model_count=1,
1317
+ used_model=1,
1318
+ warnings=list(structure.warnings),
1319
+ conect_bonds=set(structure.conect_bonds),
1320
+ ter_after_serials=set(structure.ter_after_serials),
1321
+ )
1322
+
1323
+
1324
+ def _write_xyz(
1325
+ elements: list[str],
1326
+ coordinates: np.ndarray,
1327
+ path: Path,
1328
+ comment: str,
1329
+ ) -> None:
1330
+ lines = [str(len(elements)), comment]
1331
+ lines.extend(
1332
+ f"{element:<2} {coord[0]: .10f} {coord[1]: .10f} {coord[2]: .10f}"
1333
+ for element, coord in zip(elements, coordinates, strict=True)
1334
+ )
1335
+ path.write_text("\n".join(lines) + "\n", encoding="utf-8")
1336
+
1337
+
1338
+ def _write_embedding_files(
1339
+ selection: McpbEmbeddingSelection,
1340
+ *,
1341
+ csv_path: Path,
1342
+ xyz_path: Path,
1343
+ report_path: Path,
1344
+ ) -> None:
1345
+ with csv_path.open("w", encoding="utf-8", newline="") as handle:
1346
+ writer = csv.DictWriter(
1347
+ handle,
1348
+ fieldnames=[
1349
+ "x",
1350
+ "y",
1351
+ "z",
1352
+ "charge",
1353
+ "residue_name",
1354
+ "residue_number",
1355
+ "atom_name",
1356
+ "category",
1357
+ ],
1358
+ )
1359
+ writer.writeheader()
1360
+ for item in selection.point_charges:
1361
+ writer.writerow(item.to_dict())
1362
+ lines = [str(len(selection.point_charges)), "MCPB PySCF MM embedding charges"]
1363
+ lines.extend(
1364
+ f"X {item.x:.8f} {item.y:.8f} {item.z:.8f} {item.charge:.8f}"
1365
+ for item in selection.point_charges
1366
+ )
1367
+ xyz_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
1368
+ report_path.write_text(
1369
+ json.dumps(selection.to_dict(), indent=2, sort_keys=True) + "\n",
1370
+ encoding="utf-8",
1371
+ )
1372
+
1373
+
1374
+ def _residue_category(residue: ResidueRecord) -> str:
1375
+ name = residue.id.resname.upper()
1376
+ if name in WATER_RESIDUES:
1377
+ return "water"
1378
+ if name in STANDARD_PROTEIN_RESIDUES:
1379
+ return "protein"
1380
+ return "ligand_or_other"
1381
+
1382
+
1383
+ def _sha256(path: Path) -> str:
1384
+ digest = hashlib.sha256()
1385
+ with path.open("rb") as handle:
1386
+ for block in iter(lambda: handle.read(1024 * 1024), b""):
1387
+ digest.update(block)
1388
+ return digest.hexdigest()