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
mdprep/config/models.py
ADDED
|
@@ -0,0 +1,1100 @@
|
|
|
1
|
+
"""Pydantic models for mdprep YAML manifests."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from math import isfinite
|
|
6
|
+
from typing import Literal
|
|
7
|
+
|
|
8
|
+
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class StrictModel(BaseModel):
|
|
12
|
+
"""Base model that rejects undeclared manifest keys."""
|
|
13
|
+
|
|
14
|
+
model_config = ConfigDict(extra="forbid")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ResidueSelector(StrictModel):
|
|
18
|
+
chain: str
|
|
19
|
+
resname: str
|
|
20
|
+
resid: int
|
|
21
|
+
icode: str | None = None
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class LigandResidueSelector(StrictModel):
|
|
25
|
+
chain: str | None = None
|
|
26
|
+
resname: str
|
|
27
|
+
resid: int | None = None
|
|
28
|
+
icode: str | None = None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class AtomSelector(ResidueSelector):
|
|
32
|
+
"""Select one named atom in one explicitly identified residue."""
|
|
33
|
+
|
|
34
|
+
atom_name: str = Field(min_length=1)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class ProjectConfig(StrictModel):
|
|
38
|
+
name: str
|
|
39
|
+
input_structure: str
|
|
40
|
+
output_dir: str
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class StructureRepairConfig(StrictModel):
|
|
44
|
+
"""Explicit, reproducible repair of standard-polymer coordinates."""
|
|
45
|
+
|
|
46
|
+
backend: Literal["none", "pdbfixer"] = "none"
|
|
47
|
+
sequence_source: str | None = None
|
|
48
|
+
add_missing_heavy_atoms: bool = False
|
|
49
|
+
missing_residues: list[ResidueSelector] = Field(default_factory=list)
|
|
50
|
+
random_seed: int = Field(default=20260722, ge=0)
|
|
51
|
+
platform: Literal["Reference", "CPU"] = "Reference"
|
|
52
|
+
peptide_bond_min_angstrom: float = Field(default=1.10, gt=0)
|
|
53
|
+
peptide_bond_max_angstrom: float = Field(default=1.50, gt=0)
|
|
54
|
+
require_trans_peptide_bonds: bool = True
|
|
55
|
+
trans_peptide_min_abs_degrees: float = Field(default=120.0, ge=0, le=180)
|
|
56
|
+
|
|
57
|
+
@model_validator(mode="after")
|
|
58
|
+
def validate_repair_request(self) -> "StructureRepairConfig":
|
|
59
|
+
if self.peptide_bond_min_angstrom >= self.peptide_bond_max_angstrom:
|
|
60
|
+
raise ValueError(
|
|
61
|
+
"structure.repair.peptide_bond_min_angstrom must be smaller than "
|
|
62
|
+
"peptide_bond_max_angstrom"
|
|
63
|
+
)
|
|
64
|
+
configured = bool(
|
|
65
|
+
self.sequence_source
|
|
66
|
+
or self.add_missing_heavy_atoms
|
|
67
|
+
or self.missing_residues
|
|
68
|
+
)
|
|
69
|
+
if self.backend == "none" and configured:
|
|
70
|
+
raise ValueError(
|
|
71
|
+
"structure.repair settings require backend: pdbfixer"
|
|
72
|
+
)
|
|
73
|
+
if self.backend == "pdbfixer" and not (
|
|
74
|
+
self.add_missing_heavy_atoms or self.missing_residues
|
|
75
|
+
):
|
|
76
|
+
raise ValueError(
|
|
77
|
+
"structure.repair backend: pdbfixer must explicitly request "
|
|
78
|
+
"add_missing_heavy_atoms and/or list missing_residues"
|
|
79
|
+
)
|
|
80
|
+
if self.missing_residues and self.sequence_source is None:
|
|
81
|
+
raise ValueError(
|
|
82
|
+
"structure.repair.missing_residues requires sequence_source so "
|
|
83
|
+
"PDBFixer can validate residue identity and insertion position"
|
|
84
|
+
)
|
|
85
|
+
selectors = [item.model_dump_json() for item in self.missing_residues]
|
|
86
|
+
if len(selectors) != len(set(selectors)):
|
|
87
|
+
raise ValueError("structure.repair.missing_residues must be unique")
|
|
88
|
+
return self
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class StructureConfig(StrictModel):
|
|
92
|
+
keep_crystal_waters: bool = True
|
|
93
|
+
altloc_policy: Literal["highest_occupancy", "first", "fail"] = "highest_occupancy"
|
|
94
|
+
remove_unknown_heterogens: bool = False
|
|
95
|
+
preserve_chain_ids: bool = True
|
|
96
|
+
remove_input_hydrogens: bool = True
|
|
97
|
+
repair: StructureRepairConfig = Field(default_factory=StructureRepairConfig)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class ProteinConfig(StrictModel):
|
|
101
|
+
forcefield: Literal["ff14SB", "ff19SB"]
|
|
102
|
+
water_model: Literal["TIP3P", "OPC"]
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
ProtonationState = Literal[
|
|
106
|
+
"ASP",
|
|
107
|
+
"ASH",
|
|
108
|
+
"GLU",
|
|
109
|
+
"GLH",
|
|
110
|
+
"LYS",
|
|
111
|
+
"LYN",
|
|
112
|
+
"ARG",
|
|
113
|
+
"HIS",
|
|
114
|
+
"HID",
|
|
115
|
+
"HIE",
|
|
116
|
+
"HIP",
|
|
117
|
+
"CYS",
|
|
118
|
+
"CYM",
|
|
119
|
+
"CYX",
|
|
120
|
+
]
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class ProtonationOverride(StrictModel):
|
|
124
|
+
selector: ResidueSelector
|
|
125
|
+
state: ProtonationState
|
|
126
|
+
reason: str
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
class HistidineXtbConfig(StrictModel):
|
|
130
|
+
executable: str = "xtb"
|
|
131
|
+
model: Literal["gfn2", "gxtb"] = "gfn2"
|
|
132
|
+
mode: Literal["sp", "opt"] = "opt"
|
|
133
|
+
opt_level: Literal["loose", "normal", "tight"] = "loose"
|
|
134
|
+
solvent: str | None = "water"
|
|
135
|
+
cutoff_angstrom: float = Field(default=5.0, gt=0)
|
|
136
|
+
extra_args: list[str] = Field(default_factory=list)
|
|
137
|
+
energy_close_call_kcal_mol: float = Field(default=0.5, ge=0)
|
|
138
|
+
add_missing_protein_hydrogens: bool = True
|
|
139
|
+
temporary_hydrogen_random_seed: int = Field(default=20260722, ge=0)
|
|
140
|
+
add_missing_water_hydrogens: bool = True
|
|
141
|
+
water_oh_distance_angstrom: float = Field(default=0.9572, gt=0)
|
|
142
|
+
water_hoh_angle_degrees: float = Field(default=104.52, gt=0, lt=180)
|
|
143
|
+
scf_iterations: int = Field(default=500, ge=1)
|
|
144
|
+
electronic_temperature_kelvin: float | None = Field(default=1000.0, gt=0)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
class HistidineConfig(StrictModel):
|
|
148
|
+
neutral_tautomer_method: Literal["manual", "xtb"] = "xtb"
|
|
149
|
+
xtb: HistidineXtbConfig = Field(default_factory=HistidineXtbConfig)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
class PropkaConfig(StrictModel):
|
|
153
|
+
executable: str | None = None
|
|
154
|
+
fallback_executables: list[str] = Field(default_factory=lambda: ["propka3", "propka"])
|
|
155
|
+
extra_args: list[str] = Field(default_factory=list)
|
|
156
|
+
require_success: bool = True
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
class ProtonationConfig(StrictModel):
|
|
160
|
+
ph: float = 7.0
|
|
161
|
+
method: Literal["manual_only", "propka", "propka_xtb_his"]
|
|
162
|
+
propka: PropkaConfig = Field(default_factory=PropkaConfig)
|
|
163
|
+
overrides: list[ProtonationOverride] = Field(default_factory=list)
|
|
164
|
+
histidine: HistidineConfig = Field(default_factory=HistidineConfig)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
class DisulfidePair(StrictModel):
|
|
168
|
+
a: ResidueSelector
|
|
169
|
+
b: ResidueSelector
|
|
170
|
+
reason: str | None = None
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
class DisulfideConfig(StrictModel):
|
|
174
|
+
auto_detect: bool = True
|
|
175
|
+
detection_cutoff_angstrom: float = Field(default=2.2, gt=0)
|
|
176
|
+
force: list[DisulfidePair] = Field(default_factory=list)
|
|
177
|
+
forbid: list[DisulfidePair] = Field(default_factory=list)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
class QmEspGridConfig(StrictModel):
|
|
181
|
+
type: Literal["merz_kollman"] = "merz_kollman"
|
|
182
|
+
vdw_scale_factors: list[float] = Field(default_factory=lambda: [1.4, 1.6, 1.8, 2.0])
|
|
183
|
+
point_density_per_square_angstrom: float = Field(default=1.0, gt=0)
|
|
184
|
+
exclude_inside_vdw_scale: float = Field(default=1.4, gt=0)
|
|
185
|
+
# Amber RESP reads atom and ESP-point counts with a 2I5 format, so 99,999
|
|
186
|
+
# is the largest representable count. Grid generation fails instead of
|
|
187
|
+
# silently thinning a requested Merz-Kollman surface.
|
|
188
|
+
max_points: int = Field(default=99999, ge=10, le=99999)
|
|
189
|
+
|
|
190
|
+
@model_validator(mode="after")
|
|
191
|
+
def validate_layers(self) -> "QmEspGridConfig":
|
|
192
|
+
if not self.vdw_scale_factors:
|
|
193
|
+
raise ValueError("qmmesp.grid.vdw_scale_factors must contain at least one layer")
|
|
194
|
+
if any(not isfinite(scale) or scale <= 0 for scale in self.vdw_scale_factors):
|
|
195
|
+
raise ValueError("qmmesp.grid.vdw_scale_factors must all be finite and positive")
|
|
196
|
+
if len(set(self.vdw_scale_factors)) != len(self.vdw_scale_factors):
|
|
197
|
+
raise ValueError("qmmesp.grid.vdw_scale_factors must not contain duplicate layers")
|
|
198
|
+
if self.vdw_scale_factors != sorted(self.vdw_scale_factors):
|
|
199
|
+
raise ValueError("qmmesp.grid.vdw_scale_factors must be ordered from inner to outer")
|
|
200
|
+
return self
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
class RespFittingConfig(StrictModel):
|
|
204
|
+
"""Canonical Amber two-stage RESP settings.
|
|
205
|
+
|
|
206
|
+
QMMESP and gas-phase RESP calculations intentionally do not expose a
|
|
207
|
+
simplified in-process approximation. ``respgen`` supplies Amber's
|
|
208
|
+
standard stage-one/stage-two restraints and equivalence constraints, and
|
|
209
|
+
``resp`` performs both fits.
|
|
210
|
+
"""
|
|
211
|
+
|
|
212
|
+
backend: Literal["ambertools"] = "ambertools"
|
|
213
|
+
stage_2: Literal[True] = True
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
class QmmespEnvironmentConfig(StrictModel):
|
|
217
|
+
include_protein: bool = True
|
|
218
|
+
include_waters: bool = True
|
|
219
|
+
include_other_ligands: bool = True
|
|
220
|
+
exclude_self_ligand: bool = True
|
|
221
|
+
|
|
222
|
+
@model_validator(mode="after")
|
|
223
|
+
def require_target_exclusion(self) -> "QmmespEnvironmentConfig":
|
|
224
|
+
if not self.exclude_self_ligand:
|
|
225
|
+
raise ValueError("qmmesp.environment.exclude_self_ligand must be true; target ligand self-embedding is not allowed")
|
|
226
|
+
return self
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
class QmmespConfig(StrictModel):
|
|
230
|
+
qm_engine: Literal["pyscf"] = "pyscf"
|
|
231
|
+
method: str = "HF"
|
|
232
|
+
basis: str = "6-31G*"
|
|
233
|
+
# None matches the reference QMMESP workflow: every selected non-target
|
|
234
|
+
# atom in the provisional Amber system participates in electrostatic
|
|
235
|
+
# embedding. A finite cutoff is an explicit user approximation.
|
|
236
|
+
embedding_cutoff_angstrom: float | None = Field(default=None, gt=0)
|
|
237
|
+
scf_charge: int | None = None
|
|
238
|
+
scf_spin: int | None = Field(default=None, ge=0)
|
|
239
|
+
max_cycle: int = Field(default=100, ge=1)
|
|
240
|
+
conv_tol: float = Field(default=1.0e-9, gt=0)
|
|
241
|
+
num_threads: int = Field(default=1, ge=1)
|
|
242
|
+
max_memory_mb: int = Field(default=4000, ge=256)
|
|
243
|
+
grid: QmEspGridConfig = Field(default_factory=QmEspGridConfig)
|
|
244
|
+
resp_fitting: RespFittingConfig = Field(default_factory=RespFittingConfig)
|
|
245
|
+
environment: QmmespEnvironmentConfig = Field(default_factory=QmmespEnvironmentConfig)
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
class LigandConfig(StrictModel):
|
|
249
|
+
id: str
|
|
250
|
+
selector: LigandResidueSelector
|
|
251
|
+
net_charge: int
|
|
252
|
+
multiplicity: int = Field(default=1, ge=1)
|
|
253
|
+
expected_formula: str | None = Field(
|
|
254
|
+
default=None,
|
|
255
|
+
pattern=r"^(?:[A-Z][a-z]?[0-9]*)+$",
|
|
256
|
+
description=(
|
|
257
|
+
"Exact element formula expected in the input PDB, including explicit hydrogens "
|
|
258
|
+
"(for example C5H9NO2)."
|
|
259
|
+
),
|
|
260
|
+
)
|
|
261
|
+
atom_types: Literal["gaff", "gaff2"]
|
|
262
|
+
charge_method: Literal[
|
|
263
|
+
"am1bcc",
|
|
264
|
+
"gas_resp_pyscf",
|
|
265
|
+
"qmmesp_pyscf",
|
|
266
|
+
"mcpb_resp_pyscf",
|
|
267
|
+
"user_mol2",
|
|
268
|
+
]
|
|
269
|
+
user_mol2: str | None = None
|
|
270
|
+
user_frcmod: str | None = None
|
|
271
|
+
preserve_atom_names: bool = True
|
|
272
|
+
preserve_coordinates: bool = True
|
|
273
|
+
allow_atom_renaming: bool = False
|
|
274
|
+
allow_coordinate_changes: bool = False
|
|
275
|
+
qmmesp: QmmespConfig | None = None
|
|
276
|
+
|
|
277
|
+
@model_validator(mode="after")
|
|
278
|
+
def validate_charge_inputs(self) -> "LigandConfig":
|
|
279
|
+
if self.charge_method == "user_mol2" and not self.user_mol2:
|
|
280
|
+
raise ValueError("charge_method: user_mol2 requires user_mol2")
|
|
281
|
+
if self.charge_method in {"gas_resp_pyscf", "qmmesp_pyscf"} and self.qmmesp is None:
|
|
282
|
+
raise ValueError(f"charge_method: {self.charge_method} requires qmmesp")
|
|
283
|
+
if self.charge_method == "mcpb_resp_pyscf":
|
|
284
|
+
if self.selector.chain is None or self.selector.resid is None:
|
|
285
|
+
raise ValueError(
|
|
286
|
+
"charge_method: mcpb_resp_pyscf requires an exact ligand selector "
|
|
287
|
+
"with chain and resid"
|
|
288
|
+
)
|
|
289
|
+
if self.qmmesp is not None:
|
|
290
|
+
raise ValueError(
|
|
291
|
+
"charge_method: mcpb_resp_pyscf obtains its QM settings from "
|
|
292
|
+
"metals[].mcpb.pyscf and may not include ligand qmmesp settings"
|
|
293
|
+
)
|
|
294
|
+
if self.qmmesp is not None:
|
|
295
|
+
if self.qmmesp.scf_charge is not None and self.qmmesp.scf_charge != self.net_charge:
|
|
296
|
+
raise ValueError(
|
|
297
|
+
"qmmesp.scf_charge must equal ligand net_charge; RESP may not fit a different "
|
|
298
|
+
"electronic charge state"
|
|
299
|
+
)
|
|
300
|
+
expected_spin = self.multiplicity - 1
|
|
301
|
+
if self.qmmesp.scf_spin is not None and self.qmmesp.scf_spin != expected_spin:
|
|
302
|
+
raise ValueError(
|
|
303
|
+
"qmmesp.scf_spin must equal multiplicity - 1; RESP may not fit a different "
|
|
304
|
+
"electronic spin state"
|
|
305
|
+
)
|
|
306
|
+
return self
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
class MetalIonConfig(StrictModel):
|
|
310
|
+
"""Identity and formal charge of one metal atom.
|
|
311
|
+
|
|
312
|
+
The charge is deliberately mandatory: neither a PDB residue name nor an
|
|
313
|
+
element identifies an oxidation state safely.
|
|
314
|
+
"""
|
|
315
|
+
|
|
316
|
+
selector: AtomSelector
|
|
317
|
+
element: str = Field(pattern=r"^[A-Z][a-z]?$", min_length=1, max_length=2)
|
|
318
|
+
charge: int = Field(ge=1, le=4)
|
|
319
|
+
multiplicity: int | None = Field(default=None, ge=1)
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
class NonbondedMetalConfig(StrictModel):
|
|
323
|
+
"""Li/Merz Amber ion model selection."""
|
|
324
|
+
|
|
325
|
+
parameter_set: Literal["12_6", "12_6_4", "hfe", "iod", "cm"]
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
class McpbQmArtifactsConfig(StrictModel):
|
|
329
|
+
"""Completed external QM calculations consumed by MCPB.py."""
|
|
330
|
+
|
|
331
|
+
small_opt_fchk: str | None = None
|
|
332
|
+
small_fc_log: str | None = None
|
|
333
|
+
large_mk_log: str
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
class McpbBondConfig(StrictModel):
|
|
337
|
+
"""One explicitly approved metal--coordinator bond."""
|
|
338
|
+
|
|
339
|
+
ion: AtomSelector
|
|
340
|
+
coordinator: AtomSelector
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
class McpbXtbHessianConfig(StrictModel):
|
|
344
|
+
"""Pinned xTB settings for a fixed-geometry MCPB small-model Hessian."""
|
|
345
|
+
|
|
346
|
+
model: Literal["gfn2", "gxtb"] = "gfn2"
|
|
347
|
+
executable: str = Field(default="xtb", min_length=1)
|
|
348
|
+
release_tag: str | None = Field(default=None, min_length=1)
|
|
349
|
+
expected_executable_sha256: str | None = Field(
|
|
350
|
+
default=None,
|
|
351
|
+
pattern=r"^[0-9a-fA-F]{64}$",
|
|
352
|
+
)
|
|
353
|
+
accuracy: float = Field(default=0.001, gt=0)
|
|
354
|
+
num_threads: int = Field(default=1, ge=1)
|
|
355
|
+
max_scf_iterations: int | None = Field(default=None, ge=1)
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
class McpbGxtbHessianConfig(McpbXtbHessianConfig):
|
|
359
|
+
"""Backward-compatible g-xTB-only form used by older manifests."""
|
|
360
|
+
|
|
361
|
+
model: Literal["gxtb"] = "gxtb"
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
class McpbMacePolar1HessianConfig(StrictModel):
|
|
365
|
+
"""MACE-POLAR-1 settings for a fixed-geometry MCPB Hessian."""
|
|
366
|
+
|
|
367
|
+
python_executable: str = Field(default="python", min_length=1)
|
|
368
|
+
model: Literal["polar-1-s", "polar-1-m", "polar-1-l"] = "polar-1-m"
|
|
369
|
+
device: Literal["cpu", "cuda", "mps"] = "cpu"
|
|
370
|
+
num_threads: int = Field(default=1, ge=1)
|
|
371
|
+
expected_model_sha256: str | None = Field(
|
|
372
|
+
default=None,
|
|
373
|
+
pattern=r"^[0-9a-fA-F]{64}$",
|
|
374
|
+
)
|
|
375
|
+
finite_difference_step_angstrom: float = Field(default=0.001, gt=0)
|
|
376
|
+
max_hessian_vector_relative_error: float = Field(default=0.01, gt=0)
|
|
377
|
+
accept_model_license: bool = False
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
class McpbParameterComparisonConfig(StrictModel):
|
|
381
|
+
"""Optional comparison of generated MCPB terms with a reviewed reference."""
|
|
382
|
+
|
|
383
|
+
reference_frcmod: str = Field(min_length=1)
|
|
384
|
+
candidate_label: str = Field(default="xTB MCPB", min_length=1)
|
|
385
|
+
reference_label: str = Field(default="MCPB reference", min_length=1)
|
|
386
|
+
require_exact_term_set: bool = True
|
|
387
|
+
max_bond_force_constant_relative_rmse: float | None = Field(
|
|
388
|
+
default=None, ge=0
|
|
389
|
+
)
|
|
390
|
+
max_angle_force_constant_relative_rmse: float | None = Field(
|
|
391
|
+
default=None, ge=0
|
|
392
|
+
)
|
|
393
|
+
max_bond_equilibrium_distance_rmse_angstrom: float | None = Field(
|
|
394
|
+
default=None, ge=0
|
|
395
|
+
)
|
|
396
|
+
max_angle_equilibrium_value_rmse_degrees: float | None = Field(
|
|
397
|
+
default=None, ge=0
|
|
398
|
+
)
|
|
399
|
+
fail_on_thresholds: bool = True
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
class McpbPySCFConfig(StrictModel):
|
|
403
|
+
"""Fixed-geometry calculations that complete the MCPB.py QM stages.
|
|
404
|
+
|
|
405
|
+
The geometry can be supplied as an external, identity-preserving optimized
|
|
406
|
+
MCPB small-model PDB, or inherited from mdprep's completed ASH QM/MM
|
|
407
|
+
refinement. An explicit ``test_unoptimized`` status exists only for
|
|
408
|
+
end-to-end workflow tests and is carried into reports as a scientific
|
|
409
|
+
warning. PySCF remains the backend for the embedded large-model ESP. The
|
|
410
|
+
small-model Cartesian Hessian may come from PySCF, GFN2-xTB, g-xTB, or
|
|
411
|
+
MACE-POLAR-1.
|
|
412
|
+
"""
|
|
413
|
+
|
|
414
|
+
geometry_source: Literal["external_pdb", "qmmm_refinement"] = "external_pdb"
|
|
415
|
+
optimized_small_model_pdb: str | None = Field(default=None, min_length=1)
|
|
416
|
+
small_model_geometry_status: Literal[
|
|
417
|
+
"user_optimized",
|
|
418
|
+
"test_unoptimized",
|
|
419
|
+
] | None = None
|
|
420
|
+
hessian_backend: Literal["pyscf", "xtb", "gxtb", "mace_polar1"] = "pyscf"
|
|
421
|
+
xtb: McpbXtbHessianConfig | None = None
|
|
422
|
+
gxtb: McpbGxtbHessianConfig | None = None
|
|
423
|
+
mace_polar1: McpbMacePolar1HessianConfig | None = None
|
|
424
|
+
method: str = Field(min_length=1)
|
|
425
|
+
basis: str = Field(min_length=1)
|
|
426
|
+
max_cycle: int = Field(default=150, ge=1)
|
|
427
|
+
conv_tol: float = Field(default=1.0e-9, gt=0)
|
|
428
|
+
scf_algorithm: Literal[
|
|
429
|
+
"diis",
|
|
430
|
+
"newton",
|
|
431
|
+
"adiis_then_diis",
|
|
432
|
+
"adiis_then_newton",
|
|
433
|
+
] = "diis"
|
|
434
|
+
initial_guess: Literal["minao", "atom", "huckel"] = "minao"
|
|
435
|
+
large_model_restart_checkpoint: str | None = Field(default=None, min_length=1)
|
|
436
|
+
large_model_restart_checkpoint_sha256: str | None = Field(
|
|
437
|
+
default=None,
|
|
438
|
+
pattern=r"^[0-9a-fA-F]{64}$",
|
|
439
|
+
)
|
|
440
|
+
large_model_restart_max_displacement_angstrom: float = Field(
|
|
441
|
+
default=0.0,
|
|
442
|
+
ge=0,
|
|
443
|
+
)
|
|
444
|
+
adiis_precondition_cycles: int = Field(default=15, ge=1)
|
|
445
|
+
adiis_precondition_dft_grid_level: int | None = Field(
|
|
446
|
+
default=None, ge=0, le=9
|
|
447
|
+
)
|
|
448
|
+
level_shift_mode: Literal["static", "dynamic"] = "static"
|
|
449
|
+
level_shift_hartree: float = Field(default=0.0, ge=0)
|
|
450
|
+
damping_factor: float = Field(default=0.0, ge=0, lt=1)
|
|
451
|
+
diis_space: int = Field(default=8, ge=2)
|
|
452
|
+
large_model_density_fitting: bool = False
|
|
453
|
+
large_model_auxbasis: str | None = Field(default=None, min_length=1)
|
|
454
|
+
dft_grid_level: int = Field(default=3, ge=0, le=9)
|
|
455
|
+
num_threads: int = Field(default=1, ge=1)
|
|
456
|
+
max_memory_mb: int = Field(default=8000, ge=256)
|
|
457
|
+
embedding_cutoff_angstrom: float | None = Field(default=None, gt=0)
|
|
458
|
+
embedding_min_distance_angstrom: float = Field(default=1.2, gt=0)
|
|
459
|
+
esp_batch_size: int = Field(default=256, ge=1)
|
|
460
|
+
grid: QmEspGridConfig = Field(default_factory=QmEspGridConfig)
|
|
461
|
+
|
|
462
|
+
@model_validator(mode="after")
|
|
463
|
+
def validate_hessian_backend(self) -> "McpbPySCFConfig":
|
|
464
|
+
restart_values = (
|
|
465
|
+
self.large_model_restart_checkpoint,
|
|
466
|
+
self.large_model_restart_checkpoint_sha256,
|
|
467
|
+
)
|
|
468
|
+
if any(value is not None for value in restart_values) and not all(
|
|
469
|
+
value is not None for value in restart_values
|
|
470
|
+
):
|
|
471
|
+
raise ValueError(
|
|
472
|
+
"mcpb.pyscf large_model_restart_checkpoint and "
|
|
473
|
+
"large_model_restart_checkpoint_sha256 must be provided together"
|
|
474
|
+
)
|
|
475
|
+
if (
|
|
476
|
+
self.large_model_restart_checkpoint is not None
|
|
477
|
+
and self.scf_algorithm != "newton"
|
|
478
|
+
):
|
|
479
|
+
raise ValueError(
|
|
480
|
+
"mcpb.pyscf large_model_restart_checkpoint requires "
|
|
481
|
+
"scf_algorithm: newton"
|
|
482
|
+
)
|
|
483
|
+
if (
|
|
484
|
+
self.large_model_restart_checkpoint is None
|
|
485
|
+
and self.large_model_restart_max_displacement_angstrom != 0.0
|
|
486
|
+
):
|
|
487
|
+
raise ValueError(
|
|
488
|
+
"mcpb.pyscf large_model_restart_max_displacement_angstrom "
|
|
489
|
+
"requires large_model_restart_checkpoint"
|
|
490
|
+
)
|
|
491
|
+
if self.scf_algorithm != "diis" and self.level_shift_mode == "dynamic":
|
|
492
|
+
raise ValueError(
|
|
493
|
+
"mcpb.pyscf.level_shift_mode: dynamic is available only with "
|
|
494
|
+
"scf_algorithm: diis"
|
|
495
|
+
)
|
|
496
|
+
if (
|
|
497
|
+
self.adiis_precondition_dft_grid_level is not None
|
|
498
|
+
and self.scf_algorithm
|
|
499
|
+
not in {"adiis_then_diis", "adiis_then_newton"}
|
|
500
|
+
):
|
|
501
|
+
raise ValueError(
|
|
502
|
+
"mcpb.pyscf.adiis_precondition_dft_grid_level requires "
|
|
503
|
+
"scf_algorithm: adiis_then_diis or adiis_then_newton"
|
|
504
|
+
)
|
|
505
|
+
if self.large_model_auxbasis is not None and not self.large_model_density_fitting:
|
|
506
|
+
raise ValueError(
|
|
507
|
+
"mcpb.pyscf.large_model_auxbasis requires "
|
|
508
|
+
"large_model_density_fitting: true"
|
|
509
|
+
)
|
|
510
|
+
if self.geometry_source == "external_pdb":
|
|
511
|
+
if self.optimized_small_model_pdb is None:
|
|
512
|
+
raise ValueError(
|
|
513
|
+
"mcpb.pyscf geometry_source: external_pdb requires "
|
|
514
|
+
"optimized_small_model_pdb"
|
|
515
|
+
)
|
|
516
|
+
if self.small_model_geometry_status is None:
|
|
517
|
+
raise ValueError(
|
|
518
|
+
"mcpb.pyscf geometry_source: external_pdb requires "
|
|
519
|
+
"small_model_geometry_status"
|
|
520
|
+
)
|
|
521
|
+
else:
|
|
522
|
+
if self.optimized_small_model_pdb is not None:
|
|
523
|
+
raise ValueError(
|
|
524
|
+
"mcpb.pyscf.optimized_small_model_pdb may not be supplied when "
|
|
525
|
+
"geometry_source: qmmm_refinement"
|
|
526
|
+
)
|
|
527
|
+
if self.small_model_geometry_status is not None:
|
|
528
|
+
raise ValueError(
|
|
529
|
+
"mcpb.pyscf.small_model_geometry_status may not be supplied when "
|
|
530
|
+
"geometry_source: qmmm_refinement; completion is verified from the "
|
|
531
|
+
"refinement stage"
|
|
532
|
+
)
|
|
533
|
+
if self.hessian_backend == "xtb" and self.xtb is None:
|
|
534
|
+
raise ValueError(
|
|
535
|
+
"mcpb.pyscf hessian_backend: xtb requires explicit xtb settings"
|
|
536
|
+
)
|
|
537
|
+
if self.hessian_backend != "xtb" and self.xtb is not None:
|
|
538
|
+
raise ValueError(
|
|
539
|
+
"mcpb.pyscf.xtb settings may only be supplied when hessian_backend: xtb"
|
|
540
|
+
)
|
|
541
|
+
if self.hessian_backend == "gxtb" and self.gxtb is None:
|
|
542
|
+
raise ValueError(
|
|
543
|
+
"mcpb.pyscf hessian_backend: gxtb requires explicit gxtb settings"
|
|
544
|
+
)
|
|
545
|
+
if self.hessian_backend != "gxtb" and self.gxtb is not None:
|
|
546
|
+
raise ValueError(
|
|
547
|
+
"mcpb.pyscf.gxtb settings may only be supplied when "
|
|
548
|
+
"hessian_backend: gxtb"
|
|
549
|
+
)
|
|
550
|
+
if self.hessian_backend == "mace_polar1" and self.mace_polar1 is None:
|
|
551
|
+
raise ValueError(
|
|
552
|
+
"mcpb.pyscf hessian_backend: mace_polar1 requires explicit "
|
|
553
|
+
"mace_polar1 settings"
|
|
554
|
+
)
|
|
555
|
+
if self.hessian_backend != "mace_polar1" and self.mace_polar1 is not None:
|
|
556
|
+
raise ValueError(
|
|
557
|
+
"mcpb.pyscf.mace_polar1 settings may only be supplied when "
|
|
558
|
+
"hessian_backend: mace_polar1"
|
|
559
|
+
)
|
|
560
|
+
if (
|
|
561
|
+
self.mace_polar1 is not None
|
|
562
|
+
and not self.mace_polar1.accept_model_license
|
|
563
|
+
):
|
|
564
|
+
raise ValueError(
|
|
565
|
+
"MACE-POLAR-1 checkpoints use the Academic Software License; set "
|
|
566
|
+
"mcpb.pyscf.mace_polar1.accept_model_license: true only after "
|
|
567
|
+
"reviewing and accepting its terms"
|
|
568
|
+
)
|
|
569
|
+
return self
|
|
570
|
+
|
|
571
|
+
|
|
572
|
+
class McpbConfig(StrictModel):
|
|
573
|
+
"""Explicit MCPB.py bonded-model workflow configuration."""
|
|
574
|
+
|
|
575
|
+
executable: str = "MCPB.py"
|
|
576
|
+
workflow: Literal["prepare_inputs", "complete", "pyscf"] = "prepare_inputs"
|
|
577
|
+
provisional_nonbonded_parameter_set: Literal[
|
|
578
|
+
"12_6", "12_6_4", "hfe", "iod", "cm"
|
|
579
|
+
] | None = None
|
|
580
|
+
bonds: list[McpbBondConfig] = Field(min_length=1)
|
|
581
|
+
additional_residues: list[ResidueSelector] = Field(default_factory=list)
|
|
582
|
+
cutoff_angstrom: float = Field(default=2.8, gt=0)
|
|
583
|
+
force_constant_method: Literal[
|
|
584
|
+
"seminario",
|
|
585
|
+
"modified_seminario",
|
|
586
|
+
"empirical",
|
|
587
|
+
"z_matrix",
|
|
588
|
+
] = "seminario"
|
|
589
|
+
charge_restraint: Literal[
|
|
590
|
+
"all_ligating",
|
|
591
|
+
"backbone_heavy",
|
|
592
|
+
"backbone_all",
|
|
593
|
+
"backbone_and_cb",
|
|
594
|
+
] = "backbone_heavy"
|
|
595
|
+
software_version: Literal["g03", "g09", "g16", "gau", "gms"] = "g16"
|
|
596
|
+
small_model_charge: int
|
|
597
|
+
small_model_spin: int = Field(ge=1)
|
|
598
|
+
large_model_charge: int
|
|
599
|
+
large_model_spin: int = Field(ge=1)
|
|
600
|
+
scale_factor: float = Field(default=1.0, gt=0)
|
|
601
|
+
large_opt: Literal[0, 1, 2] = 0
|
|
602
|
+
artifacts: McpbQmArtifactsConfig | None = None
|
|
603
|
+
pyscf: McpbPySCFConfig | None = None
|
|
604
|
+
parameter_comparison: McpbParameterComparisonConfig | None = None
|
|
605
|
+
|
|
606
|
+
@model_validator(mode="after")
|
|
607
|
+
def validate_complete_artifacts(self) -> "McpbConfig":
|
|
608
|
+
if self.force_constant_method == "z_matrix" and self.software_version == "gms":
|
|
609
|
+
raise ValueError(
|
|
610
|
+
"mcpb force_constant_method: z_matrix is only supported with Gaussian output; "
|
|
611
|
+
"MCPB.py's Z-matrix parser does not read GAMESS output"
|
|
612
|
+
)
|
|
613
|
+
if self.workflow == "pyscf":
|
|
614
|
+
if self.artifacts is not None:
|
|
615
|
+
raise ValueError("mcpb.artifacts may not be supplied with workflow: pyscf")
|
|
616
|
+
if self.pyscf is None:
|
|
617
|
+
raise ValueError("mcpb workflow: pyscf requires mcpb.pyscf settings")
|
|
618
|
+
if self.software_version != "gau":
|
|
619
|
+
raise ValueError(
|
|
620
|
+
"mcpb workflow: pyscf requires software_version: gau because mdprep "
|
|
621
|
+
"writes MCPB.py-compatible formatted-checkpoint and MK-ESP adapters"
|
|
622
|
+
)
|
|
623
|
+
if self.force_constant_method not in {"seminario", "modified_seminario"}:
|
|
624
|
+
raise ValueError(
|
|
625
|
+
"mcpb workflow: pyscf supports the Hessian-based seminario and "
|
|
626
|
+
"modified_seminario force-constant methods"
|
|
627
|
+
)
|
|
628
|
+
if self.large_opt != 0:
|
|
629
|
+
raise ValueError(
|
|
630
|
+
"mcpb workflow: pyscf currently performs the MCPB large-model embedded "
|
|
631
|
+
"single point and therefore requires large_opt: 0"
|
|
632
|
+
)
|
|
633
|
+
return self
|
|
634
|
+
if self.pyscf is not None:
|
|
635
|
+
raise ValueError("mcpb.pyscf settings may only be supplied with workflow: pyscf")
|
|
636
|
+
if self.workflow != "complete":
|
|
637
|
+
if self.artifacts is not None:
|
|
638
|
+
raise ValueError(
|
|
639
|
+
"mcpb.artifacts may only be supplied with workflow: complete"
|
|
640
|
+
)
|
|
641
|
+
return self
|
|
642
|
+
if self.artifacts is None:
|
|
643
|
+
raise ValueError("mcpb workflow: complete requires artifacts")
|
|
644
|
+
needs_small_log = self.force_constant_method == "z_matrix" or (
|
|
645
|
+
self.force_constant_method in {"seminario", "modified_seminario"}
|
|
646
|
+
and self.software_version == "gms"
|
|
647
|
+
)
|
|
648
|
+
if needs_small_log:
|
|
649
|
+
if not self.artifacts.small_fc_log:
|
|
650
|
+
raise ValueError(
|
|
651
|
+
f"mcpb force_constant_method: {self.force_constant_method} requires "
|
|
652
|
+
"artifacts.small_fc_log"
|
|
653
|
+
)
|
|
654
|
+
needs_fchk = (
|
|
655
|
+
self.force_constant_method in {"seminario", "modified_seminario"}
|
|
656
|
+
and self.software_version != "gms"
|
|
657
|
+
)
|
|
658
|
+
if needs_fchk:
|
|
659
|
+
if not self.artifacts.small_opt_fchk:
|
|
660
|
+
raise ValueError(
|
|
661
|
+
f"mcpb force_constant_method: {self.force_constant_method} requires "
|
|
662
|
+
"artifacts.small_opt_fchk"
|
|
663
|
+
)
|
|
664
|
+
return self
|
|
665
|
+
|
|
666
|
+
|
|
667
|
+
class MetalSiteConfig(StrictModel):
|
|
668
|
+
"""One independent mono- or multi-metal parameterization site."""
|
|
669
|
+
|
|
670
|
+
id: str = Field(min_length=1)
|
|
671
|
+
model: Literal["nonbonded", "bonded_mcpb"]
|
|
672
|
+
ions: list[MetalIonConfig] = Field(min_length=1)
|
|
673
|
+
nonbonded: NonbondedMetalConfig | None = None
|
|
674
|
+
mcpb: McpbConfig | None = None
|
|
675
|
+
|
|
676
|
+
@model_validator(mode="after")
|
|
677
|
+
def validate_model_settings(self) -> "MetalSiteConfig":
|
|
678
|
+
if self.model == "nonbonded":
|
|
679
|
+
if self.nonbonded is None:
|
|
680
|
+
raise ValueError("metal model: nonbonded requires nonbonded settings")
|
|
681
|
+
if self.mcpb is not None:
|
|
682
|
+
raise ValueError("metal model: nonbonded may not include mcpb settings")
|
|
683
|
+
else:
|
|
684
|
+
if self.mcpb is None:
|
|
685
|
+
raise ValueError("metal model: bonded_mcpb requires mcpb settings")
|
|
686
|
+
if self.nonbonded is not None:
|
|
687
|
+
raise ValueError("metal model: bonded_mcpb may not include nonbonded settings")
|
|
688
|
+
if self.mcpb.provisional_nonbonded_parameter_set is None:
|
|
689
|
+
raise ValueError(
|
|
690
|
+
"metal model: bonded_mcpb requires "
|
|
691
|
+
"mcpb.provisional_nonbonded_parameter_set for the pre-MCPB tleap "
|
|
692
|
+
"hydrogenation build"
|
|
693
|
+
)
|
|
694
|
+
selectors = [ion.selector.model_dump_json() for ion in self.ions]
|
|
695
|
+
if len(selectors) != len(set(selectors)):
|
|
696
|
+
raise ValueError("metal site ion selectors must be unique")
|
|
697
|
+
return self
|
|
698
|
+
|
|
699
|
+
|
|
700
|
+
class RefinementQmComponentsConfig(StrictModel):
|
|
701
|
+
"""Explicit non-MM components of the QM region.
|
|
702
|
+
|
|
703
|
+
Protein coordinating residues are named per selected metal site. Bonded
|
|
704
|
+
MCPB sites may instead obtain them from their already explicit bond list.
|
|
705
|
+
"""
|
|
706
|
+
|
|
707
|
+
ligands: list[str] = Field(default_factory=list)
|
|
708
|
+
metal_sites: list[str] = Field(default_factory=list)
|
|
709
|
+
metal_coordinating_residues: dict[str, list[ResidueSelector]] = Field(
|
|
710
|
+
default_factory=dict
|
|
711
|
+
)
|
|
712
|
+
|
|
713
|
+
|
|
714
|
+
class AshRefinementConfig(StrictModel):
|
|
715
|
+
"""Executable and optimizer settings for the isolated ASH environment."""
|
|
716
|
+
|
|
717
|
+
python_executable: str = Field(min_length=1)
|
|
718
|
+
xtb_executable: str = Field(default="xtb", min_length=1)
|
|
719
|
+
allow_unusual_link_boundaries: bool = False
|
|
720
|
+
max_iterations: int = Field(default=250, ge=1)
|
|
721
|
+
num_cores: int = Field(default=1, ge=1)
|
|
722
|
+
platform: Literal["CPU", "Reference"] = "CPU"
|
|
723
|
+
xtb_max_iterations: int = Field(default=500, ge=1)
|
|
724
|
+
electronic_temperature_kelvin: float = Field(default=300.0, gt=0)
|
|
725
|
+
accuracy: float = Field(default=0.1, gt=0)
|
|
726
|
+
|
|
727
|
+
|
|
728
|
+
class MacePolar1RefinementConfig(StrictModel):
|
|
729
|
+
"""Pinned MACE-POLAR-1 settings for mechanical-embedding refinement."""
|
|
730
|
+
|
|
731
|
+
model: Literal["polar-1-s", "polar-1-m", "polar-1-l"] = "polar-1-m"
|
|
732
|
+
model_path: str = Field(min_length=1)
|
|
733
|
+
expected_model_sha256: str = Field(pattern=r"^[0-9a-fA-F]{64}$")
|
|
734
|
+
device: Literal["cpu", "cuda", "mps"] = "cpu"
|
|
735
|
+
python_search_paths: list[str] = Field(default_factory=list)
|
|
736
|
+
accept_model_license: bool = False
|
|
737
|
+
|
|
738
|
+
|
|
739
|
+
class RefinementConfig(StrictModel):
|
|
740
|
+
"""Optional pre-parameterization active-site QM/MM refinement."""
|
|
741
|
+
|
|
742
|
+
enabled: bool = False
|
|
743
|
+
backend: Literal["ash"] = "ash"
|
|
744
|
+
qm_method: Literal["gfn2_xtb", "gxtb", "mace_polar1"] = "gfn2_xtb"
|
|
745
|
+
embedding: Literal["electrostatic", "mechanical"] = "electrostatic"
|
|
746
|
+
qm_components: RefinementQmComponentsConfig = Field(
|
|
747
|
+
default_factory=RefinementQmComponentsConfig
|
|
748
|
+
)
|
|
749
|
+
active_region_cutoff_angstrom: Literal[4.0] = 4.0
|
|
750
|
+
active_water_cutoff_angstrom: Literal[8.0] = 8.0
|
|
751
|
+
include_contact_waters: Literal[True] = True
|
|
752
|
+
movable_atoms: Literal[
|
|
753
|
+
"all_active_region", "active_region_hydrogens"
|
|
754
|
+
] = "all_active_region"
|
|
755
|
+
post_refinement_protonation: Literal["rerun", "reuse_initial"] = "rerun"
|
|
756
|
+
total_qm_multiplicity: int | None = Field(default=None, ge=1)
|
|
757
|
+
ash: AshRefinementConfig | None = None
|
|
758
|
+
mace_polar1: MacePolar1RefinementConfig | None = None
|
|
759
|
+
|
|
760
|
+
@model_validator(mode="after")
|
|
761
|
+
def validate_method_embedding(self) -> "RefinementConfig":
|
|
762
|
+
if (
|
|
763
|
+
self.movable_atoms == "active_region_hydrogens"
|
|
764
|
+
and self.post_refinement_protonation != "reuse_initial"
|
|
765
|
+
):
|
|
766
|
+
raise ValueError(
|
|
767
|
+
"refinement.movable_atoms: active_region_hydrogens requires "
|
|
768
|
+
"post_refinement_protonation: reuse_initial so the optimized "
|
|
769
|
+
"hydrogen coordinates are not discarded"
|
|
770
|
+
)
|
|
771
|
+
if self.qm_method == "gfn2_xtb":
|
|
772
|
+
if self.embedding != "electrostatic":
|
|
773
|
+
raise ValueError(
|
|
774
|
+
"refinement qm_method: gfn2_xtb requires embedding: electrostatic"
|
|
775
|
+
)
|
|
776
|
+
if self.mace_polar1 is not None:
|
|
777
|
+
raise ValueError(
|
|
778
|
+
"refinement.mace_polar1 settings may only be supplied when "
|
|
779
|
+
"qm_method: mace_polar1"
|
|
780
|
+
)
|
|
781
|
+
return self
|
|
782
|
+
if self.embedding != "mechanical":
|
|
783
|
+
raise ValueError(
|
|
784
|
+
f"refinement qm_method: {self.qm_method} requires explicit "
|
|
785
|
+
"embedding: mechanical; electrostatic embedding is not supported "
|
|
786
|
+
"by this backend"
|
|
787
|
+
)
|
|
788
|
+
if self.qm_method == "mace_polar1":
|
|
789
|
+
if self.mace_polar1 is None:
|
|
790
|
+
raise ValueError(
|
|
791
|
+
"refinement qm_method: mace_polar1 requires explicit "
|
|
792
|
+
"refinement.mace_polar1 settings"
|
|
793
|
+
)
|
|
794
|
+
if not self.mace_polar1.accept_model_license:
|
|
795
|
+
raise ValueError(
|
|
796
|
+
"MACE-POLAR-1 checkpoints use the Academic Software License; set "
|
|
797
|
+
"refinement.mace_polar1.accept_model_license: true only after "
|
|
798
|
+
"reviewing and accepting its terms"
|
|
799
|
+
)
|
|
800
|
+
elif self.mace_polar1 is not None:
|
|
801
|
+
raise ValueError(
|
|
802
|
+
"refinement.mace_polar1 settings may only be supplied when "
|
|
803
|
+
"qm_method: mace_polar1"
|
|
804
|
+
)
|
|
805
|
+
return self
|
|
806
|
+
|
|
807
|
+
|
|
808
|
+
class SolvationConfig(StrictModel):
|
|
809
|
+
enabled: bool = True
|
|
810
|
+
box: Literal["truncated_octahedron", "rectangular"] = "truncated_octahedron"
|
|
811
|
+
buffer_angstrom: float = Field(default=10.0, gt=0)
|
|
812
|
+
neutralize: bool = True
|
|
813
|
+
salt_concentration_molar: float = Field(default=0.15, ge=0)
|
|
814
|
+
positive_ion: str = "Na+"
|
|
815
|
+
negative_ion: str = "Cl-"
|
|
816
|
+
|
|
817
|
+
|
|
818
|
+
class ValidationConfig(StrictModel):
|
|
819
|
+
run_openmm_energy_check: bool = True
|
|
820
|
+
fail_on_warnings: bool = False
|
|
821
|
+
fail_on_missing_parameters: bool = True
|
|
822
|
+
fail_on_noninteger_ligand_charge: bool = True
|
|
823
|
+
|
|
824
|
+
|
|
825
|
+
class RoeBrooksDensityConfig(StrictModel):
|
|
826
|
+
"""Density-plateau settings for Roe--Brooks step 10."""
|
|
827
|
+
|
|
828
|
+
increment_ns: float = Field(default=1.0, gt=0)
|
|
829
|
+
report_interval_ps: float = Field(default=1.0, gt=0)
|
|
830
|
+
window_ps: float = Field(default=300.0, gt=0)
|
|
831
|
+
slope_threshold_g_ml_ps: float = Field(default=1.0e-6, gt=0)
|
|
832
|
+
mean_difference_threshold_g_ml: float = Field(default=0.02, gt=0)
|
|
833
|
+
minimum_points: int = Field(default=50, ge=3)
|
|
834
|
+
maximum_duration_ns: float = Field(default=10.0, gt=0)
|
|
835
|
+
|
|
836
|
+
@model_validator(mode="after")
|
|
837
|
+
def validate_sampling_window(self) -> "RoeBrooksDensityConfig":
|
|
838
|
+
if self.window_ps > self.maximum_duration_ns * 1000.0:
|
|
839
|
+
raise ValueError(
|
|
840
|
+
"molecular_dynamics.density.window_ps may not exceed "
|
|
841
|
+
"maximum_duration_ns"
|
|
842
|
+
)
|
|
843
|
+
return self
|
|
844
|
+
|
|
845
|
+
|
|
846
|
+
class ProductionMdConfig(StrictModel):
|
|
847
|
+
"""User-selected production length and reporting cadence in MD steps."""
|
|
848
|
+
|
|
849
|
+
steps: int = Field(ge=1)
|
|
850
|
+
timestep_fs: Literal[2.0] = 2.0
|
|
851
|
+
trajectory_interval_steps: int = Field(default=10_000, ge=1)
|
|
852
|
+
state_interval_steps: int = Field(default=5_000, ge=1)
|
|
853
|
+
checkpoint_interval_steps: int = Field(default=50_000, ge=1)
|
|
854
|
+
|
|
855
|
+
@model_validator(mode="after")
|
|
856
|
+
def validate_intervals(self) -> "ProductionMdConfig":
|
|
857
|
+
for name in (
|
|
858
|
+
"trajectory_interval_steps",
|
|
859
|
+
"state_interval_steps",
|
|
860
|
+
"checkpoint_interval_steps",
|
|
861
|
+
):
|
|
862
|
+
if getattr(self, name) > self.steps:
|
|
863
|
+
raise ValueError(
|
|
864
|
+
f"molecular_dynamics.production.{name} may not exceed production.steps"
|
|
865
|
+
)
|
|
866
|
+
return self
|
|
867
|
+
|
|
868
|
+
|
|
869
|
+
class MolecularDynamicsConfig(StrictModel):
|
|
870
|
+
"""Optional OpenMM Roe--Brooks equilibration and production stage."""
|
|
871
|
+
|
|
872
|
+
enabled: bool = False
|
|
873
|
+
protocol: Literal["roe_brooks_2020"] = "roe_brooks_2020"
|
|
874
|
+
temperature_kelvin: float = Field(default=300.0, gt=0)
|
|
875
|
+
pressure_atmosphere: float = Field(default=1.0, gt=0)
|
|
876
|
+
platform: Literal[
|
|
877
|
+
"auto", "CUDA", "HIP", "OpenCL", "Metal", "CPU", "Reference"
|
|
878
|
+
] = "auto"
|
|
879
|
+
device_index: int | None = Field(default=None, ge=0)
|
|
880
|
+
precision: Literal["single", "mixed", "double"] = "mixed"
|
|
881
|
+
cpu_threads: int | None = Field(default=None, ge=1)
|
|
882
|
+
random_seed: int = Field(default=20260817, ge=0)
|
|
883
|
+
step08_ensemble: Literal["NPT", "NVT"] = "NPT"
|
|
884
|
+
density: RoeBrooksDensityConfig = Field(default_factory=RoeBrooksDensityConfig)
|
|
885
|
+
production: ProductionMdConfig | None = None
|
|
886
|
+
|
|
887
|
+
@model_validator(mode="after")
|
|
888
|
+
def validate_enabled_stage(self) -> "MolecularDynamicsConfig":
|
|
889
|
+
if self.enabled and self.production is None:
|
|
890
|
+
raise ValueError(
|
|
891
|
+
"molecular_dynamics.enabled: true requires an explicit production block "
|
|
892
|
+
"with the requested number of MD steps"
|
|
893
|
+
)
|
|
894
|
+
if not self.enabled and self.production is not None:
|
|
895
|
+
raise ValueError(
|
|
896
|
+
"molecular_dynamics.production may only be supplied when enabled: true"
|
|
897
|
+
)
|
|
898
|
+
if self.device_index is not None and self.platform in {"CPU", "Reference"}:
|
|
899
|
+
raise ValueError(
|
|
900
|
+
"molecular_dynamics.device_index is only valid for auto or accelerator platforms"
|
|
901
|
+
)
|
|
902
|
+
return self
|
|
903
|
+
|
|
904
|
+
|
|
905
|
+
class ManifestConfig(StrictModel):
|
|
906
|
+
project: ProjectConfig
|
|
907
|
+
structure: StructureConfig
|
|
908
|
+
protein: ProteinConfig
|
|
909
|
+
protonation: ProtonationConfig
|
|
910
|
+
disulfides: DisulfideConfig
|
|
911
|
+
ligands: list[LigandConfig] = Field(default_factory=list)
|
|
912
|
+
metals: list[MetalSiteConfig] = Field(default_factory=list)
|
|
913
|
+
refinement: RefinementConfig = Field(default_factory=RefinementConfig)
|
|
914
|
+
solvation: SolvationConfig
|
|
915
|
+
validation: ValidationConfig
|
|
916
|
+
molecular_dynamics: MolecularDynamicsConfig = Field(
|
|
917
|
+
default_factory=MolecularDynamicsConfig
|
|
918
|
+
)
|
|
919
|
+
|
|
920
|
+
@model_validator(mode="after")
|
|
921
|
+
def validate_unique_workflow_ids(self) -> "ManifestConfig":
|
|
922
|
+
ligand_ids = [ligand.id for ligand in self.ligands]
|
|
923
|
+
if len(ligand_ids) != len(set(ligand_ids)):
|
|
924
|
+
raise ValueError("ligand ids must be unique")
|
|
925
|
+
metal_ids = [site.id for site in self.metals]
|
|
926
|
+
if len(metal_ids) != len(set(metal_ids)):
|
|
927
|
+
raise ValueError("metal site ids must be unique")
|
|
928
|
+
ion_selectors = [
|
|
929
|
+
ion.selector.model_dump_json()
|
|
930
|
+
for site in self.metals
|
|
931
|
+
for ion in site.ions
|
|
932
|
+
]
|
|
933
|
+
if len(ion_selectors) != len(set(ion_selectors)):
|
|
934
|
+
raise ValueError("a metal ion atom may belong to only one metal site")
|
|
935
|
+
self._validate_refinement(ligand_ids=ligand_ids, metal_ids=metal_ids)
|
|
936
|
+
self._validate_mcpb_refinement_geometry()
|
|
937
|
+
if any(ligand.charge_method == "qmmesp_pyscf" for ligand in self.ligands):
|
|
938
|
+
missing = [
|
|
939
|
+
site.id
|
|
940
|
+
for site in self.metals
|
|
941
|
+
if site.model == "bonded_mcpb"
|
|
942
|
+
and site.mcpb is not None
|
|
943
|
+
and site.mcpb.provisional_nonbonded_parameter_set is None
|
|
944
|
+
]
|
|
945
|
+
if missing:
|
|
946
|
+
raise ValueError(
|
|
947
|
+
"QMMESP with bonded_mcpb metal sites requires an explicit "
|
|
948
|
+
"mcpb.provisional_nonbonded_parameter_set for provisional electrostatic "
|
|
949
|
+
f"embedding; missing for {missing}"
|
|
950
|
+
)
|
|
951
|
+
for ligand in self.ligands:
|
|
952
|
+
if ligand.charge_method != "mcpb_resp_pyscf":
|
|
953
|
+
continue
|
|
954
|
+
key = (
|
|
955
|
+
ligand.selector.chain,
|
|
956
|
+
ligand.selector.resid,
|
|
957
|
+
ligand.selector.icode,
|
|
958
|
+
)
|
|
959
|
+
matching_sites: list[MetalSiteConfig] = []
|
|
960
|
+
for site in self.metals:
|
|
961
|
+
if site.model != "bonded_mcpb" or site.mcpb is None:
|
|
962
|
+
continue
|
|
963
|
+
coordinator_keys = {
|
|
964
|
+
(
|
|
965
|
+
bond.coordinator.chain,
|
|
966
|
+
bond.coordinator.resid,
|
|
967
|
+
bond.coordinator.icode,
|
|
968
|
+
)
|
|
969
|
+
for bond in site.mcpb.bonds
|
|
970
|
+
}
|
|
971
|
+
additional_keys = {
|
|
972
|
+
(item.chain, item.resid, item.icode)
|
|
973
|
+
for item in site.mcpb.additional_residues
|
|
974
|
+
}
|
|
975
|
+
if key in coordinator_keys or key in additional_keys:
|
|
976
|
+
matching_sites.append(site)
|
|
977
|
+
if len(matching_sites) != 1:
|
|
978
|
+
raise ValueError(
|
|
979
|
+
f"Ligand {ligand.id!r} uses mcpb_resp_pyscf but belongs to "
|
|
980
|
+
f"{len(matching_sites)} bonded MCPB sites; list it as a coordinator or "
|
|
981
|
+
"additional_residue in exactly one bonded site"
|
|
982
|
+
)
|
|
983
|
+
site = matching_sites[0]
|
|
984
|
+
assert site.mcpb is not None
|
|
985
|
+
if site.mcpb.workflow not in {"pyscf", "complete"}:
|
|
986
|
+
raise ValueError(
|
|
987
|
+
f"Ligand {ligand.id!r} uses mcpb_resp_pyscf, so metal site "
|
|
988
|
+
f"{site.id!r} must use mcpb.workflow: pyscf or resume from "
|
|
989
|
+
"explicit PySCF-generated artifacts with mcpb.workflow: complete"
|
|
990
|
+
)
|
|
991
|
+
if (
|
|
992
|
+
site.mcpb.workflow == "complete"
|
|
993
|
+
and site.mcpb.software_version != "gau"
|
|
994
|
+
):
|
|
995
|
+
raise ValueError(
|
|
996
|
+
f"Ligand {ligand.id!r} uses mcpb_resp_pyscf with "
|
|
997
|
+
"mcpb.workflow: complete, so software_version must be gau for "
|
|
998
|
+
"the PySCF-generated Gaussian-compatible artifacts"
|
|
999
|
+
)
|
|
1000
|
+
return self
|
|
1001
|
+
|
|
1002
|
+
def _validate_mcpb_refinement_geometry(self) -> None:
|
|
1003
|
+
for site in self.metals:
|
|
1004
|
+
if site.model != "bonded_mcpb" or site.mcpb is None:
|
|
1005
|
+
continue
|
|
1006
|
+
pyscf = site.mcpb.pyscf
|
|
1007
|
+
if pyscf is None or pyscf.geometry_source != "qmmm_refinement":
|
|
1008
|
+
continue
|
|
1009
|
+
if not self.refinement.enabled:
|
|
1010
|
+
raise ValueError(
|
|
1011
|
+
f"Metal site {site.id!r} uses geometry_source: qmmm_refinement, "
|
|
1012
|
+
"which requires refinement.enabled: true"
|
|
1013
|
+
)
|
|
1014
|
+
if site.id not in self.refinement.qm_components.metal_sites:
|
|
1015
|
+
raise ValueError(
|
|
1016
|
+
f"Metal site {site.id!r} uses geometry_source: qmmm_refinement but "
|
|
1017
|
+
"is not listed in refinement.qm_components.metal_sites"
|
|
1018
|
+
)
|
|
1019
|
+
|
|
1020
|
+
def _validate_refinement(
|
|
1021
|
+
self,
|
|
1022
|
+
*,
|
|
1023
|
+
ligand_ids: list[str],
|
|
1024
|
+
metal_ids: list[str],
|
|
1025
|
+
) -> None:
|
|
1026
|
+
refinement = self.refinement
|
|
1027
|
+
if not refinement.enabled:
|
|
1028
|
+
return
|
|
1029
|
+
if not self.structure.keep_crystal_waters:
|
|
1030
|
+
raise ValueError(
|
|
1031
|
+
"refinement requires structure.keep_crystal_waters: true so active-site "
|
|
1032
|
+
"waters are protonated and included"
|
|
1033
|
+
)
|
|
1034
|
+
if refinement.ash is None:
|
|
1035
|
+
raise ValueError("refinement.enabled: true requires refinement.ash settings")
|
|
1036
|
+
selected_ligands = refinement.qm_components.ligands
|
|
1037
|
+
selected_metals = refinement.qm_components.metal_sites
|
|
1038
|
+
if not selected_ligands and not selected_metals:
|
|
1039
|
+
raise ValueError(
|
|
1040
|
+
"refinement requires at least one explicit ligand or metal site in "
|
|
1041
|
+
"refinement.qm_components"
|
|
1042
|
+
)
|
|
1043
|
+
if len(selected_ligands) != len(set(selected_ligands)):
|
|
1044
|
+
raise ValueError("refinement.qm_components.ligands must be unique")
|
|
1045
|
+
if len(selected_metals) != len(set(selected_metals)):
|
|
1046
|
+
raise ValueError("refinement.qm_components.metal_sites must be unique")
|
|
1047
|
+
unknown_ligands = sorted(set(selected_ligands) - set(ligand_ids))
|
|
1048
|
+
if unknown_ligands:
|
|
1049
|
+
raise ValueError(
|
|
1050
|
+
f"refinement references unknown ligand ids: {unknown_ligands}"
|
|
1051
|
+
)
|
|
1052
|
+
unknown_metals = sorted(set(selected_metals) - set(metal_ids))
|
|
1053
|
+
if unknown_metals:
|
|
1054
|
+
raise ValueError(
|
|
1055
|
+
f"refinement references unknown metal site ids: {unknown_metals}"
|
|
1056
|
+
)
|
|
1057
|
+
coordinator_sites = refinement.qm_components.metal_coordinating_residues
|
|
1058
|
+
unselected_coordinators = sorted(set(coordinator_sites) - set(selected_metals))
|
|
1059
|
+
if unselected_coordinators:
|
|
1060
|
+
raise ValueError(
|
|
1061
|
+
"refinement metal_coordinating_residues contains unselected metal sites: "
|
|
1062
|
+
f"{unselected_coordinators}"
|
|
1063
|
+
)
|
|
1064
|
+
|
|
1065
|
+
ligand_by_id = {ligand.id: ligand for ligand in self.ligands}
|
|
1066
|
+
site_by_id = {site.id: site for site in self.metals}
|
|
1067
|
+
component_multiplicities: list[tuple[str, int]] = []
|
|
1068
|
+
for ligand_id in selected_ligands:
|
|
1069
|
+
ligand = ligand_by_id[ligand_id]
|
|
1070
|
+
if "multiplicity" not in ligand.model_fields_set:
|
|
1071
|
+
raise ValueError(
|
|
1072
|
+
f"Refinement ligand {ligand_id!r} must explicitly set multiplicity; "
|
|
1073
|
+
"the normal ligand default is not accepted for a QM region"
|
|
1074
|
+
)
|
|
1075
|
+
component_multiplicities.append((f"ligand:{ligand_id}", ligand.multiplicity))
|
|
1076
|
+
for site_id in selected_metals:
|
|
1077
|
+
site = site_by_id[site_id]
|
|
1078
|
+
explicit_coordinators = coordinator_sites.get(site_id, [])
|
|
1079
|
+
if site.model == "nonbonded" and not explicit_coordinators:
|
|
1080
|
+
raise ValueError(
|
|
1081
|
+
f"Refinement metal site {site_id!r} is nonbonded and must explicitly "
|
|
1082
|
+
"list its complete coordinating residues in "
|
|
1083
|
+
"refinement.qm_components.metal_coordinating_residues"
|
|
1084
|
+
)
|
|
1085
|
+
for index, ion in enumerate(site.ions, start=1):
|
|
1086
|
+
if ion.multiplicity is None:
|
|
1087
|
+
raise ValueError(
|
|
1088
|
+
f"Refinement metal ion {index} in site {site_id!r} must explicitly "
|
|
1089
|
+
"set multiplicity"
|
|
1090
|
+
)
|
|
1091
|
+
component_multiplicities.append(
|
|
1092
|
+
(f"metal:{site_id}:{index}", ion.multiplicity)
|
|
1093
|
+
)
|
|
1094
|
+
open_shell = [item for item in component_multiplicities if item[1] > 1]
|
|
1095
|
+
if len(open_shell) > 1 and refinement.total_qm_multiplicity is None:
|
|
1096
|
+
labels = ", ".join(f"{name}={mult}" for name, mult in open_shell)
|
|
1097
|
+
raise ValueError(
|
|
1098
|
+
"Multiple open-shell QM components require an explicit "
|
|
1099
|
+
f"refinement.total_qm_multiplicity; found {labels}"
|
|
1100
|
+
)
|