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.
- mdprep/__init__.py +7 -0
- mdprep/_selftest_data/basic_protein.yaml +33 -0
- mdprep/_selftest_data/ligand_sub.mol2 +11 -0
- mdprep/ambertools/__init__.py +2 -0
- mdprep/ambertools/antechamber.py +110 -0
- mdprep/ambertools/commands.py +33 -0
- mdprep/ambertools/mol2.py +415 -0
- mdprep/ambertools/parmchk2.py +89 -0
- mdprep/ambertools/resp.py +570 -0
- mdprep/charges/__init__.py +1 -0
- mdprep/charges/esp_grid.py +190 -0
- mdprep/cli.py +451 -0
- mdprep/config/__init__.py +9 -0
- mdprep/config/loader.py +146 -0
- mdprep/config/models.py +1100 -0
- mdprep/external/__init__.py +14 -0
- mdprep/external/discovery.py +14 -0
- mdprep/external/runner.py +55 -0
- mdprep/leap/__init__.py +2 -0
- mdprep/leap/builder.py +476 -0
- mdprep/leap/forcefields.py +93 -0
- mdprep/leap/ions.py +112 -0
- mdprep/leap/log_parser.py +170 -0
- mdprep/leap/report.py +99 -0
- mdprep/leap/residues.py +653 -0
- mdprep/leap/runner.py +65 -0
- mdprep/ligands/__init__.py +2 -0
- mdprep/ligands/extract.py +355 -0
- mdprep/ligands/identity.py +39 -0
- mdprep/ligands/pyscf_charges.py +242 -0
- mdprep/ligands/report.py +146 -0
- mdprep/ligands/workflow.py +838 -0
- mdprep/md/__init__.py +5 -0
- mdprep/md/roe_brooks.py +650 -0
- mdprep/metals/__init__.py +2 -0
- mdprep/metals/c4.py +157 -0
- mdprep/metals/coordination.py +313 -0
- mdprep/metals/gxtb_hessian.py +467 -0
- mdprep/metals/hydrogenate.py +344 -0
- mdprep/metals/mace_polar1_hessian.py +357 -0
- mdprep/metals/mace_polar1_worker.py +295 -0
- mdprep/metals/mcpb.py +949 -0
- mdprep/metals/mcpb_compat.py +125 -0
- mdprep/metals/nonbonded.py +294 -0
- mdprep/metals/parameter_comparison.py +563 -0
- mdprep/metals/pyscf_mcpb.py +1388 -0
- mdprep/metals/report.py +119 -0
- mdprep/metals/workflow.py +320 -0
- mdprep/protonation/__init__.py +15 -0
- mdprep/protonation/apply.py +968 -0
- mdprep/protonation/disulfide_states.py +141 -0
- mdprep/protonation/histidine_geometry.py +950 -0
- mdprep/protonation/histidine_xtb.py +390 -0
- mdprep/protonation/overrides.py +64 -0
- mdprep/protonation/pka_rules.py +165 -0
- mdprep/protonation/propka.py +174 -0
- mdprep/protonation/propka_parser.py +140 -0
- mdprep/protonation/report.py +187 -0
- mdprep/protonation/states.py +52 -0
- mdprep/protonation/temporary_hydrogenation.py +238 -0
- mdprep/protonation/xtb_parser.py +79 -0
- mdprep/protonation/xtb_runner.py +108 -0
- mdprep/qm/__init__.py +2 -0
- mdprep/qm/point_charges.py +233 -0
- mdprep/qm/pyscf_esp.py +70 -0
- mdprep/qm/pyscf_runner.py +570 -0
- mdprep/refinement/__init__.py +5 -0
- mdprep/refinement/ash.py +541 -0
- mdprep/refinement/coordinates.py +210 -0
- mdprep/refinement/mapping.py +163 -0
- mdprep/refinement/report.py +66 -0
- mdprep/refinement/selection.py +436 -0
- mdprep/refinement/workflow.py +188 -0
- mdprep/reports/__init__.py +8 -0
- mdprep/reports/structure_report.py +92 -0
- mdprep/structure/__init__.py +14 -0
- mdprep/structure/classify.py +92 -0
- mdprep/structure/disulfides.py +51 -0
- mdprep/structure/inspect.py +105 -0
- mdprep/structure/models.py +86 -0
- mdprep/structure/normalize.py +363 -0
- mdprep/structure/pdb.py +511 -0
- mdprep/structure/repair.py +566 -0
- mdprep/structure/selectors.py +204 -0
- mdprep/structure/writer.py +84 -0
- mdprep/validation/__init__.py +2 -0
- mdprep/validation/openmm_check.py +86 -0
- mdprep/validation/parmed_check.py +43 -0
- mdprep/validation/topology.py +239 -0
- mdprep/workflows/__init__.py +6 -0
- mdprep/workflows/init.py +242 -0
- mdprep/workflows/inspect.py +21 -0
- mdprep/workflows/prepare.py +763 -0
- mdprep/workflows/selftest.py +220 -0
- mdprep/workflows/validate.py +30 -0
- mdprep-0.2.0.dist-info/METADATA +264 -0
- mdprep-0.2.0.dist-info/RECORD +100 -0
- mdprep-0.2.0.dist-info/WHEEL +4 -0
- mdprep-0.2.0.dist-info/entry_points.txt +2 -0
- 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()
|