mdprep 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- mdprep/__init__.py +7 -0
- mdprep/_selftest_data/basic_protein.yaml +33 -0
- mdprep/_selftest_data/ligand_sub.mol2 +11 -0
- mdprep/ambertools/__init__.py +2 -0
- mdprep/ambertools/antechamber.py +110 -0
- mdprep/ambertools/commands.py +33 -0
- mdprep/ambertools/mol2.py +415 -0
- mdprep/ambertools/parmchk2.py +89 -0
- mdprep/ambertools/resp.py +570 -0
- mdprep/charges/__init__.py +1 -0
- mdprep/charges/esp_grid.py +190 -0
- mdprep/cli.py +451 -0
- mdprep/config/__init__.py +9 -0
- mdprep/config/loader.py +146 -0
- mdprep/config/models.py +1100 -0
- mdprep/external/__init__.py +14 -0
- mdprep/external/discovery.py +14 -0
- mdprep/external/runner.py +55 -0
- mdprep/leap/__init__.py +2 -0
- mdprep/leap/builder.py +476 -0
- mdprep/leap/forcefields.py +93 -0
- mdprep/leap/ions.py +112 -0
- mdprep/leap/log_parser.py +170 -0
- mdprep/leap/report.py +99 -0
- mdprep/leap/residues.py +653 -0
- mdprep/leap/runner.py +65 -0
- mdprep/ligands/__init__.py +2 -0
- mdprep/ligands/extract.py +355 -0
- mdprep/ligands/identity.py +39 -0
- mdprep/ligands/pyscf_charges.py +242 -0
- mdprep/ligands/report.py +146 -0
- mdprep/ligands/workflow.py +838 -0
- mdprep/md/__init__.py +5 -0
- mdprep/md/roe_brooks.py +650 -0
- mdprep/metals/__init__.py +2 -0
- mdprep/metals/c4.py +157 -0
- mdprep/metals/coordination.py +313 -0
- mdprep/metals/gxtb_hessian.py +467 -0
- mdprep/metals/hydrogenate.py +344 -0
- mdprep/metals/mace_polar1_hessian.py +357 -0
- mdprep/metals/mace_polar1_worker.py +295 -0
- mdprep/metals/mcpb.py +949 -0
- mdprep/metals/mcpb_compat.py +125 -0
- mdprep/metals/nonbonded.py +294 -0
- mdprep/metals/parameter_comparison.py +563 -0
- mdprep/metals/pyscf_mcpb.py +1388 -0
- mdprep/metals/report.py +119 -0
- mdprep/metals/workflow.py +320 -0
- mdprep/protonation/__init__.py +15 -0
- mdprep/protonation/apply.py +968 -0
- mdprep/protonation/disulfide_states.py +141 -0
- mdprep/protonation/histidine_geometry.py +950 -0
- mdprep/protonation/histidine_xtb.py +390 -0
- mdprep/protonation/overrides.py +64 -0
- mdprep/protonation/pka_rules.py +165 -0
- mdprep/protonation/propka.py +174 -0
- mdprep/protonation/propka_parser.py +140 -0
- mdprep/protonation/report.py +187 -0
- mdprep/protonation/states.py +52 -0
- mdprep/protonation/temporary_hydrogenation.py +238 -0
- mdprep/protonation/xtb_parser.py +79 -0
- mdprep/protonation/xtb_runner.py +108 -0
- mdprep/qm/__init__.py +2 -0
- mdprep/qm/point_charges.py +233 -0
- mdprep/qm/pyscf_esp.py +70 -0
- mdprep/qm/pyscf_runner.py +570 -0
- mdprep/refinement/__init__.py +5 -0
- mdprep/refinement/ash.py +541 -0
- mdprep/refinement/coordinates.py +210 -0
- mdprep/refinement/mapping.py +163 -0
- mdprep/refinement/report.py +66 -0
- mdprep/refinement/selection.py +436 -0
- mdprep/refinement/workflow.py +188 -0
- mdprep/reports/__init__.py +8 -0
- mdprep/reports/structure_report.py +92 -0
- mdprep/structure/__init__.py +14 -0
- mdprep/structure/classify.py +92 -0
- mdprep/structure/disulfides.py +51 -0
- mdprep/structure/inspect.py +105 -0
- mdprep/structure/models.py +86 -0
- mdprep/structure/normalize.py +363 -0
- mdprep/structure/pdb.py +511 -0
- mdprep/structure/repair.py +566 -0
- mdprep/structure/selectors.py +204 -0
- mdprep/structure/writer.py +84 -0
- mdprep/validation/__init__.py +2 -0
- mdprep/validation/openmm_check.py +86 -0
- mdprep/validation/parmed_check.py +43 -0
- mdprep/validation/topology.py +239 -0
- mdprep/workflows/__init__.py +6 -0
- mdprep/workflows/init.py +242 -0
- mdprep/workflows/inspect.py +21 -0
- mdprep/workflows/prepare.py +763 -0
- mdprep/workflows/selftest.py +220 -0
- mdprep/workflows/validate.py +30 -0
- mdprep-0.2.0.dist-info/METADATA +264 -0
- mdprep-0.2.0.dist-info/RECORD +100 -0
- mdprep-0.2.0.dist-info/WHEEL +4 -0
- mdprep-0.2.0.dist-info/entry_points.txt +2 -0
- mdprep-0.2.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
"""Transfer optimized Amber coordinates back to stable input residue identities."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections import OrderedDict
|
|
6
|
+
from dataclasses import dataclass, replace
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from mdprep.structure.classify import is_standard_protein_residue
|
|
10
|
+
from mdprep.refinement.mapping import (
|
|
11
|
+
RefinementMappingError,
|
|
12
|
+
compatible_resnames,
|
|
13
|
+
is_virtual_site,
|
|
14
|
+
map_residues_by_coordinates,
|
|
15
|
+
)
|
|
16
|
+
from mdprep.structure.models import AtomRecord, PdbStructure, ResidueId, ResidueRecord
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class CoordinateTransferError(ValueError):
|
|
20
|
+
"""Raised when optimized coordinates cannot be mapped without guessing."""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class CoordinateTransferResult:
|
|
25
|
+
structure: PdbStructure
|
|
26
|
+
restored_residue_names: tuple[dict[str, object], ...]
|
|
27
|
+
generated_hydrogen_count: int
|
|
28
|
+
excluded_virtual_site_count: int
|
|
29
|
+
|
|
30
|
+
def to_dict(self) -> dict[str, object]:
|
|
31
|
+
return {
|
|
32
|
+
"restored_residue_names": list(self.restored_residue_names),
|
|
33
|
+
"generated_hydrogen_count": self.generated_hydrogen_count,
|
|
34
|
+
"excluded_virtual_site_count": self.excluded_virtual_site_count,
|
|
35
|
+
"output_atom_count": len(self.structure.atoms),
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def transfer_optimized_coordinates(
|
|
40
|
+
*,
|
|
41
|
+
reference_structure: PdbStructure,
|
|
42
|
+
leap_input_structure: PdbStructure,
|
|
43
|
+
topology_structure: PdbStructure,
|
|
44
|
+
coordinates_angstrom: tuple[tuple[float, float, float], ...],
|
|
45
|
+
output_path: str | Path,
|
|
46
|
+
) -> CoordinateTransferResult:
|
|
47
|
+
"""Return a hydrogenated optimized structure with pre-protonation names.
|
|
48
|
+
|
|
49
|
+
tleap-generated hydrogens are retained here. With
|
|
50
|
+
``post_refinement_protonation: reuse_initial`` they remain the authoritative
|
|
51
|
+
optimized coordinates for subsequent MCPB/charge/final-tleap stages. With
|
|
52
|
+
the legacy ``rerun`` policy, the normal protonation stage may remove them
|
|
53
|
+
before final parameterization.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
if len(coordinates_angstrom) != len(topology_structure.atoms):
|
|
57
|
+
raise CoordinateTransferError(
|
|
58
|
+
"Optimized coordinate count does not match provisional tleap PDB atom count: "
|
|
59
|
+
f"{len(coordinates_angstrom)} != {len(topology_structure.atoms)}"
|
|
60
|
+
)
|
|
61
|
+
try:
|
|
62
|
+
reference_to_leap = map_residues_by_coordinates(
|
|
63
|
+
reference_structure,
|
|
64
|
+
leap_input_structure,
|
|
65
|
+
)
|
|
66
|
+
leap_to_topology = map_residues_by_coordinates(
|
|
67
|
+
leap_input_structure,
|
|
68
|
+
topology_structure,
|
|
69
|
+
)
|
|
70
|
+
except RefinementMappingError as exc:
|
|
71
|
+
raise CoordinateTransferError(str(exc)) from exc
|
|
72
|
+
leap_pair_by_input_index = {
|
|
73
|
+
pair.input_index: pair for pair in leap_to_topology
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
topology_atom_index = {
|
|
77
|
+
id(atom): index for index, atom in enumerate(topology_structure.atoms)
|
|
78
|
+
}
|
|
79
|
+
new_atoms: list[AtomRecord] = []
|
|
80
|
+
old_to_new_serial: dict[int, int] = {}
|
|
81
|
+
residue_last_serial: dict[int, int] = {}
|
|
82
|
+
restored_names: list[dict[str, object]] = []
|
|
83
|
+
generated_hydrogens = 0
|
|
84
|
+
excluded_virtual_sites = 0
|
|
85
|
+
|
|
86
|
+
for residue_index, reference_pair in enumerate(reference_to_leap):
|
|
87
|
+
reference = reference_pair.input_residue
|
|
88
|
+
leap_input = reference_pair.topology_residue
|
|
89
|
+
topology_pair = leap_pair_by_input_index[reference_pair.topology_index]
|
|
90
|
+
topology = topology_pair.topology_residue
|
|
91
|
+
if not compatible_resnames(reference.id.resname, leap_input.id.resname):
|
|
92
|
+
raise CoordinateTransferError(
|
|
93
|
+
f"Residue {residue_index + 1} changed identity before tleap: "
|
|
94
|
+
f"{reference.id.display()} -> {leap_input.id.display()}"
|
|
95
|
+
)
|
|
96
|
+
if not compatible_resnames(leap_input.id.resname, topology.id.resname):
|
|
97
|
+
raise CoordinateTransferError(
|
|
98
|
+
f"Residue {residue_index + 1} changed identity in tleap: "
|
|
99
|
+
f"{leap_input.id.display()} -> {topology.id.display()}"
|
|
100
|
+
)
|
|
101
|
+
if reference.id.resname != leap_input.id.resname:
|
|
102
|
+
restored_names.append(
|
|
103
|
+
{
|
|
104
|
+
"chain_id": reference.id.chain_id,
|
|
105
|
+
"resid": reference.id.resid,
|
|
106
|
+
"icode": reference.id.icode,
|
|
107
|
+
"optimized_provisional_resname": leap_input.id.resname,
|
|
108
|
+
"restored_pre_protonation_resname": reference.id.resname,
|
|
109
|
+
"reason": "rerun requested protonation protocol after QM/MM refinement",
|
|
110
|
+
}
|
|
111
|
+
)
|
|
112
|
+
reference_by_name = {atom.name: atom for atom in reference.atoms}
|
|
113
|
+
if len(reference_by_name) != len(reference.atoms):
|
|
114
|
+
raise CoordinateTransferError(
|
|
115
|
+
f"Reference residue {reference.id.display()} has duplicate atom names"
|
|
116
|
+
)
|
|
117
|
+
record_name = "ATOM" if is_standard_protein_residue(reference) else next(
|
|
118
|
+
iter(reference.record_names), "HETATM"
|
|
119
|
+
)
|
|
120
|
+
for topology_atom in topology.atoms:
|
|
121
|
+
atom_index = topology_atom_index[id(topology_atom)]
|
|
122
|
+
if is_virtual_site(topology_atom):
|
|
123
|
+
excluded_virtual_sites += 1
|
|
124
|
+
continue
|
|
125
|
+
x, y, z = coordinates_angstrom[atom_index]
|
|
126
|
+
reference_atom = reference_by_name.get(topology_atom.name)
|
|
127
|
+
serial = len(new_atoms) + 1
|
|
128
|
+
if reference_atom is not None:
|
|
129
|
+
atom_record_name = reference_atom.record_name
|
|
130
|
+
occupancy = reference_atom.occupancy
|
|
131
|
+
bfactor = reference_atom.bfactor
|
|
132
|
+
original_line = reference_atom.original_line
|
|
133
|
+
if reference_atom.serial is not None:
|
|
134
|
+
old_to_new_serial[reference_atom.serial] = serial
|
|
135
|
+
else:
|
|
136
|
+
atom_record_name = record_name
|
|
137
|
+
occupancy = 1.0
|
|
138
|
+
bfactor = 0.0
|
|
139
|
+
original_line = ""
|
|
140
|
+
if (topology_atom.element or "").strip().upper() == "H":
|
|
141
|
+
generated_hydrogens += 1
|
|
142
|
+
new_atoms.append(
|
|
143
|
+
replace(
|
|
144
|
+
topology_atom,
|
|
145
|
+
serial=serial,
|
|
146
|
+
resname=reference.id.resname,
|
|
147
|
+
chain_id=reference.id.chain_id,
|
|
148
|
+
resid=reference.id.resid,
|
|
149
|
+
icode=reference.id.icode,
|
|
150
|
+
x=x,
|
|
151
|
+
y=y,
|
|
152
|
+
z=z,
|
|
153
|
+
occupancy=occupancy,
|
|
154
|
+
bfactor=bfactor,
|
|
155
|
+
record_name=atom_record_name, # type: ignore[arg-type]
|
|
156
|
+
original_line=original_line,
|
|
157
|
+
)
|
|
158
|
+
)
|
|
159
|
+
residue_last_serial[residue_index] = serial
|
|
160
|
+
|
|
161
|
+
conect_bonds = {
|
|
162
|
+
(min(old_to_new_serial[left], old_to_new_serial[right]),
|
|
163
|
+
max(old_to_new_serial[left], old_to_new_serial[right]))
|
|
164
|
+
for left, right in reference_structure.conect_bonds
|
|
165
|
+
if left in old_to_new_serial and right in old_to_new_serial
|
|
166
|
+
}
|
|
167
|
+
reference_residue_index_by_serial = {
|
|
168
|
+
atom.serial: index
|
|
169
|
+
for index, residue in enumerate(reference_structure.residues)
|
|
170
|
+
for atom in residue.atoms
|
|
171
|
+
if atom.serial is not None
|
|
172
|
+
}
|
|
173
|
+
ter_after_serials = {
|
|
174
|
+
residue_last_serial[reference_residue_index_by_serial[serial]]
|
|
175
|
+
for serial in reference_structure.ter_after_serials
|
|
176
|
+
if serial in reference_residue_index_by_serial
|
|
177
|
+
and reference_residue_index_by_serial[serial] in residue_last_serial
|
|
178
|
+
}
|
|
179
|
+
output = Path(output_path)
|
|
180
|
+
structure = PdbStructure(
|
|
181
|
+
path=output,
|
|
182
|
+
atoms=new_atoms,
|
|
183
|
+
residues=_build_residues(new_atoms),
|
|
184
|
+
model_count=1,
|
|
185
|
+
used_model=1,
|
|
186
|
+
warnings=list(reference_structure.warnings),
|
|
187
|
+
conect_bonds=conect_bonds,
|
|
188
|
+
ter_after_serials=ter_after_serials,
|
|
189
|
+
)
|
|
190
|
+
return CoordinateTransferResult(
|
|
191
|
+
structure=structure,
|
|
192
|
+
restored_residue_names=tuple(restored_names),
|
|
193
|
+
generated_hydrogen_count=generated_hydrogens,
|
|
194
|
+
excluded_virtual_site_count=excluded_virtual_sites,
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _build_residues(atoms: list[AtomRecord]) -> list[ResidueRecord]:
|
|
199
|
+
grouped: "OrderedDict[tuple[str, str, int, str | None], list[AtomRecord]]" = OrderedDict()
|
|
200
|
+
for atom in atoms:
|
|
201
|
+
grouped.setdefault(atom.residue_key, []).append(atom)
|
|
202
|
+
return [
|
|
203
|
+
ResidueRecord(
|
|
204
|
+
id=ResidueId(chain_id=chain, resname=resname, resid=resid, icode=icode),
|
|
205
|
+
atoms=residue_atoms,
|
|
206
|
+
record_names={atom.record_name for atom in residue_atoms},
|
|
207
|
+
original_index=index,
|
|
208
|
+
)
|
|
209
|
+
for index, ((chain, resname, resid, icode), residue_atoms) in enumerate(grouped.items())
|
|
210
|
+
]
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""Coordinate-anchored residue mapping across tleap reordering."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from math import sqrt
|
|
7
|
+
|
|
8
|
+
from mdprep.structure.models import AtomRecord, PdbStructure, ResidueRecord
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class RefinementMappingError(ValueError):
|
|
12
|
+
"""Raised when two structure representations cannot be mapped uniquely."""
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class MappedResidue:
|
|
17
|
+
input_index: int
|
|
18
|
+
topology_index: int
|
|
19
|
+
input_residue: ResidueRecord
|
|
20
|
+
topology_residue: ResidueRecord
|
|
21
|
+
anchor_rmsd_angstrom: float
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
_PROTONATION_FAMILIES = (
|
|
25
|
+
frozenset({"HIS", "HID", "HIE", "HIP"}),
|
|
26
|
+
frozenset({"ASP", "ASH"}),
|
|
27
|
+
frozenset({"GLU", "GLH"}),
|
|
28
|
+
frozenset({"CYS", "CYM", "CYX"}),
|
|
29
|
+
frozenset({"LYS", "LYN"}),
|
|
30
|
+
frozenset({"HOH", "WAT", "H2O", "TIP3", "OPC"}),
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def map_residues_by_coordinates(
|
|
35
|
+
input_structure: PdbStructure,
|
|
36
|
+
topology_structure: PdbStructure,
|
|
37
|
+
*,
|
|
38
|
+
max_anchor_rmsd_angstrom: float = 0.25,
|
|
39
|
+
ambiguity_tolerance_angstrom: float = 1.0e-5,
|
|
40
|
+
) -> list[MappedResidue]:
|
|
41
|
+
"""Map residues after tleap without relying on output residue order.
|
|
42
|
+
|
|
43
|
+
tleap commonly moves loaded ligand templates ahead of waters. Heavy-atom
|
|
44
|
+
coordinates are preserved in the provisional build, so compatible residue
|
|
45
|
+
names plus same-named atom anchors provide a deterministic mapping. This
|
|
46
|
+
also distinguishes hundreds of otherwise identical water residues.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
if len(input_structure.residues) != len(topology_structure.residues):
|
|
50
|
+
raise RefinementMappingError(
|
|
51
|
+
"Provisional tleap changed the residue count, so its atom ordering cannot be "
|
|
52
|
+
f"mapped safely ({len(input_structure.residues)} input residues, "
|
|
53
|
+
f"{len(topology_structure.residues)} output residues)"
|
|
54
|
+
)
|
|
55
|
+
unused = set(range(len(topology_structure.residues)))
|
|
56
|
+
result: list[MappedResidue] = []
|
|
57
|
+
for input_index, input_residue in enumerate(input_structure.residues):
|
|
58
|
+
candidates: list[tuple[float, int, ResidueRecord]] = []
|
|
59
|
+
for topology_index in unused:
|
|
60
|
+
topology_residue = topology_structure.residues[topology_index]
|
|
61
|
+
if not compatible_resnames(
|
|
62
|
+
input_residue.id.resname,
|
|
63
|
+
topology_residue.id.resname,
|
|
64
|
+
):
|
|
65
|
+
continue
|
|
66
|
+
score = _anchor_rmsd(input_residue, topology_residue)
|
|
67
|
+
if score is not None and score <= max_anchor_rmsd_angstrom:
|
|
68
|
+
candidates.append((score, topology_index, topology_residue))
|
|
69
|
+
if not candidates:
|
|
70
|
+
raise RefinementMappingError(
|
|
71
|
+
"No coordinate-preserving provisional residue matched "
|
|
72
|
+
f"{input_residue.id.display()} within {max_anchor_rmsd_angstrom:.3f} A"
|
|
73
|
+
)
|
|
74
|
+
candidates.sort(key=lambda item: (item[0], item[1]))
|
|
75
|
+
best_score, topology_index, topology_residue = candidates[0]
|
|
76
|
+
if (
|
|
77
|
+
len(candidates) > 1
|
|
78
|
+
and abs(candidates[1][0] - best_score) <= ambiguity_tolerance_angstrom
|
|
79
|
+
):
|
|
80
|
+
raise RefinementMappingError(
|
|
81
|
+
"Coordinate mapping is ambiguous for "
|
|
82
|
+
f"{input_residue.id.display()}: provisional residues "
|
|
83
|
+
f"{topology_index + 1} and {candidates[1][1] + 1} have indistinguishable "
|
|
84
|
+
"anchor coordinates"
|
|
85
|
+
)
|
|
86
|
+
unused.remove(topology_index)
|
|
87
|
+
result.append(
|
|
88
|
+
MappedResidue(
|
|
89
|
+
input_index=input_index,
|
|
90
|
+
topology_index=topology_index,
|
|
91
|
+
input_residue=input_residue,
|
|
92
|
+
topology_residue=topology_residue,
|
|
93
|
+
anchor_rmsd_angstrom=best_score,
|
|
94
|
+
)
|
|
95
|
+
)
|
|
96
|
+
if unused:
|
|
97
|
+
raise RefinementMappingError(
|
|
98
|
+
f"{len(unused)} provisional residues remained unmapped after coordinate matching"
|
|
99
|
+
)
|
|
100
|
+
return result
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def compatible_resnames(left: str, right: str) -> bool:
|
|
104
|
+
if left == right:
|
|
105
|
+
return True
|
|
106
|
+
return any(left in family and right in family for family in _PROTONATION_FAMILIES)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def is_virtual_site(atom: AtomRecord) -> bool:
|
|
110
|
+
element = (atom.element or "").strip().upper()
|
|
111
|
+
name = atom.name.strip().upper()
|
|
112
|
+
return element in {"EP", "LP", "X"} or name.startswith("EP") or name.startswith("LP")
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _anchor_rmsd(
|
|
116
|
+
input_residue: ResidueRecord,
|
|
117
|
+
topology_residue: ResidueRecord,
|
|
118
|
+
) -> float | None:
|
|
119
|
+
input_by_name = _unique_physical_atoms(input_residue)
|
|
120
|
+
topology_by_name = _unique_physical_atoms(topology_residue)
|
|
121
|
+
common_heavy = sorted(
|
|
122
|
+
name
|
|
123
|
+
for name in input_by_name.keys() & topology_by_name.keys()
|
|
124
|
+
if not _is_hydrogen(input_by_name[name])
|
|
125
|
+
and not _is_hydrogen(topology_by_name[name])
|
|
126
|
+
)
|
|
127
|
+
names = common_heavy or sorted(input_by_name.keys() & topology_by_name.keys())
|
|
128
|
+
if not names:
|
|
129
|
+
return None
|
|
130
|
+
squared = 0.0
|
|
131
|
+
for name in names:
|
|
132
|
+
left = input_by_name[name]
|
|
133
|
+
right = topology_by_name[name]
|
|
134
|
+
if _element(left) != _element(right):
|
|
135
|
+
return None
|
|
136
|
+
squared += (
|
|
137
|
+
(left.x - right.x) ** 2
|
|
138
|
+
+ (left.y - right.y) ** 2
|
|
139
|
+
+ (left.z - right.z) ** 2
|
|
140
|
+
)
|
|
141
|
+
return sqrt(squared / len(names))
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _unique_physical_atoms(residue: ResidueRecord) -> dict[str, AtomRecord]:
|
|
145
|
+
result: dict[str, AtomRecord] = {}
|
|
146
|
+
duplicates: set[str] = set()
|
|
147
|
+
for atom in residue.atoms:
|
|
148
|
+
if is_virtual_site(atom):
|
|
149
|
+
continue
|
|
150
|
+
if atom.name in result:
|
|
151
|
+
duplicates.add(atom.name)
|
|
152
|
+
result[atom.name] = atom
|
|
153
|
+
for name in duplicates:
|
|
154
|
+
result.pop(name, None)
|
|
155
|
+
return result
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _element(atom: AtomRecord) -> str:
|
|
159
|
+
return (atom.element or "").strip().upper()
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _is_hydrogen(atom: AtomRecord) -> bool:
|
|
163
|
+
return _element(atom) == "H"
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""JSON and Markdown reporting for QM/MM refinement."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from mdprep.refinement.workflow import RefinementResult
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def write_refinement_reports(
|
|
12
|
+
result: RefinementResult,
|
|
13
|
+
*,
|
|
14
|
+
json_path: str | Path,
|
|
15
|
+
markdown_path: str | Path,
|
|
16
|
+
) -> dict[str, object]:
|
|
17
|
+
report = result.to_report_dict()
|
|
18
|
+
json_output = Path(json_path)
|
|
19
|
+
markdown_output = Path(markdown_path)
|
|
20
|
+
json_output.parent.mkdir(parents=True, exist_ok=True)
|
|
21
|
+
markdown_output.parent.mkdir(parents=True, exist_ok=True)
|
|
22
|
+
json_output.write_text(
|
|
23
|
+
json.dumps(report, indent=2, sort_keys=True) + "\n",
|
|
24
|
+
encoding="utf-8",
|
|
25
|
+
)
|
|
26
|
+
selection = report["selection"]
|
|
27
|
+
assert isinstance(selection, dict)
|
|
28
|
+
ash = report["ash"]
|
|
29
|
+
assert isinstance(ash, dict)
|
|
30
|
+
lines = [
|
|
31
|
+
"# QM/MM refinement report",
|
|
32
|
+
"",
|
|
33
|
+
"- Status: complete",
|
|
34
|
+
"- Backend: ASH",
|
|
35
|
+
f"- QM method: {report['qm_method']}",
|
|
36
|
+
"- MM theory: provisional Amber/OpenMM",
|
|
37
|
+
f"- Embedding: {report['embedding']}",
|
|
38
|
+
"- Provisional ligand/cofactor charges: AM1-BCC",
|
|
39
|
+
f"- QM atoms: {len(selection['qm_atom_indices_zero_based'])}",
|
|
40
|
+
f"- Active atoms: {len(selection['active_atom_indices_zero_based'])}",
|
|
41
|
+
f"- Movable-atom policy: {selection['movable_atom_policy']}",
|
|
42
|
+
f"- Active waters: {report['active_water_residue_count']}",
|
|
43
|
+
f"- Non-water active-region cutoff (Å): {selection['active_region_cutoff_angstrom']}",
|
|
44
|
+
f"- Active-water cutoff (Å): {selection['active_water_cutoff_angstrom']}",
|
|
45
|
+
f"- Total QM charge: {selection['total_qm_charge']}",
|
|
46
|
+
f"- Total QM multiplicity: {selection['total_qm_multiplicity']}",
|
|
47
|
+
f"- Final energy (Eh): {ash['final_energy_hartree']}",
|
|
48
|
+
f"- Refined hydrogenated PDB: `{report['refined_hydrogenated_pdb_path']}`",
|
|
49
|
+
"",
|
|
50
|
+
"## QM charge derivation",
|
|
51
|
+
"",
|
|
52
|
+
]
|
|
53
|
+
for component in selection["charge_components"]:
|
|
54
|
+
lines.append(
|
|
55
|
+
f"- {component['label']}: {component['charge']:+d} ({component['kind']})"
|
|
56
|
+
)
|
|
57
|
+
lines.extend(["", "## External command", ""])
|
|
58
|
+
lines.append(f"- Command: `{' '.join(ash['command'])}`")
|
|
59
|
+
lines.append(f"- Working directory: `{ash['cwd']}`")
|
|
60
|
+
lines.append(f"- Return code: {ash['returncode']}")
|
|
61
|
+
lines.append(f"- Runtime (s): {ash['runtime_seconds']}")
|
|
62
|
+
lines.extend(["", "## Notes", ""])
|
|
63
|
+
for warning in report["warnings"]:
|
|
64
|
+
lines.append(f"- {warning}")
|
|
65
|
+
markdown_output.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
|
66
|
+
return report
|