mdprep 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. mdprep/__init__.py +7 -0
  2. mdprep/_selftest_data/basic_protein.yaml +33 -0
  3. mdprep/_selftest_data/ligand_sub.mol2 +11 -0
  4. mdprep/ambertools/__init__.py +2 -0
  5. mdprep/ambertools/antechamber.py +110 -0
  6. mdprep/ambertools/commands.py +33 -0
  7. mdprep/ambertools/mol2.py +415 -0
  8. mdprep/ambertools/parmchk2.py +89 -0
  9. mdprep/ambertools/resp.py +570 -0
  10. mdprep/charges/__init__.py +1 -0
  11. mdprep/charges/esp_grid.py +190 -0
  12. mdprep/cli.py +451 -0
  13. mdprep/config/__init__.py +9 -0
  14. mdprep/config/loader.py +146 -0
  15. mdprep/config/models.py +1100 -0
  16. mdprep/external/__init__.py +14 -0
  17. mdprep/external/discovery.py +14 -0
  18. mdprep/external/runner.py +55 -0
  19. mdprep/leap/__init__.py +2 -0
  20. mdprep/leap/builder.py +476 -0
  21. mdprep/leap/forcefields.py +93 -0
  22. mdprep/leap/ions.py +112 -0
  23. mdprep/leap/log_parser.py +170 -0
  24. mdprep/leap/report.py +99 -0
  25. mdprep/leap/residues.py +653 -0
  26. mdprep/leap/runner.py +65 -0
  27. mdprep/ligands/__init__.py +2 -0
  28. mdprep/ligands/extract.py +355 -0
  29. mdprep/ligands/identity.py +39 -0
  30. mdprep/ligands/pyscf_charges.py +242 -0
  31. mdprep/ligands/report.py +146 -0
  32. mdprep/ligands/workflow.py +838 -0
  33. mdprep/md/__init__.py +5 -0
  34. mdprep/md/roe_brooks.py +650 -0
  35. mdprep/metals/__init__.py +2 -0
  36. mdprep/metals/c4.py +157 -0
  37. mdprep/metals/coordination.py +313 -0
  38. mdprep/metals/gxtb_hessian.py +467 -0
  39. mdprep/metals/hydrogenate.py +344 -0
  40. mdprep/metals/mace_polar1_hessian.py +357 -0
  41. mdprep/metals/mace_polar1_worker.py +295 -0
  42. mdprep/metals/mcpb.py +949 -0
  43. mdprep/metals/mcpb_compat.py +125 -0
  44. mdprep/metals/nonbonded.py +294 -0
  45. mdprep/metals/parameter_comparison.py +563 -0
  46. mdprep/metals/pyscf_mcpb.py +1388 -0
  47. mdprep/metals/report.py +119 -0
  48. mdprep/metals/workflow.py +320 -0
  49. mdprep/protonation/__init__.py +15 -0
  50. mdprep/protonation/apply.py +968 -0
  51. mdprep/protonation/disulfide_states.py +141 -0
  52. mdprep/protonation/histidine_geometry.py +950 -0
  53. mdprep/protonation/histidine_xtb.py +390 -0
  54. mdprep/protonation/overrides.py +64 -0
  55. mdprep/protonation/pka_rules.py +165 -0
  56. mdprep/protonation/propka.py +174 -0
  57. mdprep/protonation/propka_parser.py +140 -0
  58. mdprep/protonation/report.py +187 -0
  59. mdprep/protonation/states.py +52 -0
  60. mdprep/protonation/temporary_hydrogenation.py +238 -0
  61. mdprep/protonation/xtb_parser.py +79 -0
  62. mdprep/protonation/xtb_runner.py +108 -0
  63. mdprep/qm/__init__.py +2 -0
  64. mdprep/qm/point_charges.py +233 -0
  65. mdprep/qm/pyscf_esp.py +70 -0
  66. mdprep/qm/pyscf_runner.py +570 -0
  67. mdprep/refinement/__init__.py +5 -0
  68. mdprep/refinement/ash.py +541 -0
  69. mdprep/refinement/coordinates.py +210 -0
  70. mdprep/refinement/mapping.py +163 -0
  71. mdprep/refinement/report.py +66 -0
  72. mdprep/refinement/selection.py +436 -0
  73. mdprep/refinement/workflow.py +188 -0
  74. mdprep/reports/__init__.py +8 -0
  75. mdprep/reports/structure_report.py +92 -0
  76. mdprep/structure/__init__.py +14 -0
  77. mdprep/structure/classify.py +92 -0
  78. mdprep/structure/disulfides.py +51 -0
  79. mdprep/structure/inspect.py +105 -0
  80. mdprep/structure/models.py +86 -0
  81. mdprep/structure/normalize.py +363 -0
  82. mdprep/structure/pdb.py +511 -0
  83. mdprep/structure/repair.py +566 -0
  84. mdprep/structure/selectors.py +204 -0
  85. mdprep/structure/writer.py +84 -0
  86. mdprep/validation/__init__.py +2 -0
  87. mdprep/validation/openmm_check.py +86 -0
  88. mdprep/validation/parmed_check.py +43 -0
  89. mdprep/validation/topology.py +239 -0
  90. mdprep/workflows/__init__.py +6 -0
  91. mdprep/workflows/init.py +242 -0
  92. mdprep/workflows/inspect.py +21 -0
  93. mdprep/workflows/prepare.py +763 -0
  94. mdprep/workflows/selftest.py +220 -0
  95. mdprep/workflows/validate.py +30 -0
  96. mdprep-0.2.0.dist-info/METADATA +264 -0
  97. mdprep-0.2.0.dist-info/RECORD +100 -0
  98. mdprep-0.2.0.dist-info/WHEEL +4 -0
  99. mdprep-0.2.0.dist-info/entry_points.txt +2 -0
  100. mdprep-0.2.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,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
+ )