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,563 @@
|
|
|
1
|
+
"""Compare MCPB.py Seminario bond/angle terms with a reviewed reference."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import csv
|
|
6
|
+
import hashlib
|
|
7
|
+
import json
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from math import isfinite, sqrt
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Iterable
|
|
12
|
+
|
|
13
|
+
from mdprep.config.models import McpbParameterComparisonConfig
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class McpbParameterComparisonError(ValueError):
|
|
17
|
+
"""Raised when MCPB parameter files cannot be compared safely."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class McpbParameterTerm:
|
|
22
|
+
section: str
|
|
23
|
+
atom_types: tuple[str, ...]
|
|
24
|
+
force_constant: float
|
|
25
|
+
equilibrium_value: float
|
|
26
|
+
source_line: str
|
|
27
|
+
|
|
28
|
+
@property
|
|
29
|
+
def key(self) -> str:
|
|
30
|
+
return f"{self.section}:{'-'.join(self.atom_types)}"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True)
|
|
34
|
+
class McpbTermDifference:
|
|
35
|
+
section: str
|
|
36
|
+
atom_types: tuple[str, ...]
|
|
37
|
+
candidate_force_constant: float
|
|
38
|
+
reference_force_constant: float
|
|
39
|
+
force_constant_difference: float
|
|
40
|
+
force_constant_relative_difference: float | None
|
|
41
|
+
candidate_equilibrium_value: float
|
|
42
|
+
reference_equilibrium_value: float
|
|
43
|
+
equilibrium_value_difference: float
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def key(self) -> str:
|
|
47
|
+
return f"{self.section}:{'-'.join(self.atom_types)}"
|
|
48
|
+
|
|
49
|
+
def to_dict(self) -> dict[str, object]:
|
|
50
|
+
force_units = (
|
|
51
|
+
"kcal mol^-1 angstrom^-2"
|
|
52
|
+
if self.section == "BOND"
|
|
53
|
+
else "kcal mol^-1 radian^-2"
|
|
54
|
+
)
|
|
55
|
+
equilibrium_units = "angstrom" if self.section == "BOND" else "degrees"
|
|
56
|
+
return {
|
|
57
|
+
"key": self.key,
|
|
58
|
+
"section": self.section,
|
|
59
|
+
"atom_types": list(self.atom_types),
|
|
60
|
+
"candidate_force_constant": self.candidate_force_constant,
|
|
61
|
+
"reference_force_constant": self.reference_force_constant,
|
|
62
|
+
"force_constant_difference": self.force_constant_difference,
|
|
63
|
+
"force_constant_relative_difference": (
|
|
64
|
+
self.force_constant_relative_difference
|
|
65
|
+
),
|
|
66
|
+
"candidate_equilibrium_value": self.candidate_equilibrium_value,
|
|
67
|
+
"reference_equilibrium_value": self.reference_equilibrium_value,
|
|
68
|
+
"equilibrium_value_difference": self.equilibrium_value_difference,
|
|
69
|
+
"force_constant_units": force_units,
|
|
70
|
+
"equilibrium_value_units": equilibrium_units,
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass(frozen=True)
|
|
75
|
+
class McpbParameterComparisonResult:
|
|
76
|
+
candidate_frcmod: Path
|
|
77
|
+
reference_frcmod: Path
|
|
78
|
+
candidate_label: str
|
|
79
|
+
reference_label: str
|
|
80
|
+
differences: tuple[McpbTermDifference, ...]
|
|
81
|
+
missing_from_candidate: tuple[str, ...]
|
|
82
|
+
missing_from_reference: tuple[str, ...]
|
|
83
|
+
nonpositive_candidate_force_constants: tuple[str, ...]
|
|
84
|
+
nonpositive_reference_force_constants: tuple[str, ...]
|
|
85
|
+
metrics: dict[str, dict[str, float | int | None]]
|
|
86
|
+
thresholds: dict[str, float | None]
|
|
87
|
+
violations: tuple[str, ...]
|
|
88
|
+
json_path: Path
|
|
89
|
+
csv_path: Path
|
|
90
|
+
markdown_path: Path
|
|
91
|
+
|
|
92
|
+
@property
|
|
93
|
+
def passed(self) -> bool:
|
|
94
|
+
return not self.violations
|
|
95
|
+
|
|
96
|
+
@property
|
|
97
|
+
def has_numeric_thresholds(self) -> bool:
|
|
98
|
+
return any(value is not None for value in self.thresholds.values())
|
|
99
|
+
|
|
100
|
+
@property
|
|
101
|
+
def status(self) -> str:
|
|
102
|
+
if self.violations:
|
|
103
|
+
return "fail"
|
|
104
|
+
return "pass" if self.has_numeric_thresholds else "report_only"
|
|
105
|
+
|
|
106
|
+
def to_dict(self) -> dict[str, object]:
|
|
107
|
+
return {
|
|
108
|
+
"status": self.status,
|
|
109
|
+
"candidate": {
|
|
110
|
+
"label": self.candidate_label,
|
|
111
|
+
"frcmod": str(self.candidate_frcmod),
|
|
112
|
+
"sha256": _sha256(self.candidate_frcmod),
|
|
113
|
+
},
|
|
114
|
+
"reference": {
|
|
115
|
+
"label": self.reference_label,
|
|
116
|
+
"frcmod": str(self.reference_frcmod),
|
|
117
|
+
"sha256": _sha256(self.reference_frcmod),
|
|
118
|
+
},
|
|
119
|
+
"scope": (
|
|
120
|
+
"BOND and ANGL entries explicitly marked as created by the "
|
|
121
|
+
"Seminario method using MCPB.py"
|
|
122
|
+
),
|
|
123
|
+
"matched_term_count": len(self.differences),
|
|
124
|
+
"missing_from_candidate": list(self.missing_from_candidate),
|
|
125
|
+
"missing_from_reference": list(self.missing_from_reference),
|
|
126
|
+
"nonpositive_candidate_force_constants": list(
|
|
127
|
+
self.nonpositive_candidate_force_constants
|
|
128
|
+
),
|
|
129
|
+
"nonpositive_reference_force_constants": list(
|
|
130
|
+
self.nonpositive_reference_force_constants
|
|
131
|
+
),
|
|
132
|
+
"metrics": self.metrics,
|
|
133
|
+
"thresholds": self.thresholds,
|
|
134
|
+
"violations": list(self.violations),
|
|
135
|
+
"terms": [item.to_dict() for item in self.differences],
|
|
136
|
+
"json_path": str(self.json_path),
|
|
137
|
+
"csv_path": str(self.csv_path),
|
|
138
|
+
"markdown_path": str(self.markdown_path),
|
|
139
|
+
"interpretation": (
|
|
140
|
+
"Agreement with the selected reference measures method sensitivity for "
|
|
141
|
+
"this specific geometry and electronic state. It does not by itself "
|
|
142
|
+
"validate the force field for production MD."
|
|
143
|
+
),
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def parse_mcpb_seminario_terms(
|
|
148
|
+
path: str | Path,
|
|
149
|
+
*,
|
|
150
|
+
allow_nonpositive_force_constants: bool = False,
|
|
151
|
+
) -> dict[str, McpbParameterTerm]:
|
|
152
|
+
"""Parse the MCPB-created BOND/ANGL terms from an Amber frcmod file."""
|
|
153
|
+
|
|
154
|
+
frcmod = Path(path)
|
|
155
|
+
if not frcmod.is_file() or frcmod.stat().st_size == 0:
|
|
156
|
+
raise McpbParameterComparisonError(
|
|
157
|
+
f"MCPB parameter file is missing or empty: {frcmod}"
|
|
158
|
+
)
|
|
159
|
+
section: str | None = None
|
|
160
|
+
terms: dict[str, McpbParameterTerm] = {}
|
|
161
|
+
headings = {"MASS", "BOND", "ANGL", "DIHE", "IMPR", "HBON", "NONB"}
|
|
162
|
+
for line_number, raw in enumerate(
|
|
163
|
+
frcmod.read_text(encoding="utf-8").splitlines(), start=1
|
|
164
|
+
):
|
|
165
|
+
stripped = raw.strip()
|
|
166
|
+
if stripped in headings:
|
|
167
|
+
section = stripped
|
|
168
|
+
continue
|
|
169
|
+
if section not in {"BOND", "ANGL"}:
|
|
170
|
+
continue
|
|
171
|
+
if "seminario method using mcpb.py" not in raw.lower():
|
|
172
|
+
continue
|
|
173
|
+
term = _parse_term_line(
|
|
174
|
+
raw,
|
|
175
|
+
section=section,
|
|
176
|
+
path=frcmod,
|
|
177
|
+
line_number=line_number,
|
|
178
|
+
allow_nonpositive_force_constants=allow_nonpositive_force_constants,
|
|
179
|
+
)
|
|
180
|
+
if term.key in terms:
|
|
181
|
+
raise McpbParameterComparisonError(
|
|
182
|
+
f"Duplicate MCPB Seminario term {term.key} in {frcmod}"
|
|
183
|
+
)
|
|
184
|
+
terms[term.key] = term
|
|
185
|
+
if not terms:
|
|
186
|
+
raise McpbParameterComparisonError(
|
|
187
|
+
f"No MCPB.py Seminario-created BOND or ANGL terms were found in {frcmod}"
|
|
188
|
+
)
|
|
189
|
+
return terms
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def compare_mcpb_parameter_files(
|
|
193
|
+
candidate_frcmod: str | Path,
|
|
194
|
+
*,
|
|
195
|
+
config: McpbParameterComparisonConfig,
|
|
196
|
+
output_dir: str | Path,
|
|
197
|
+
) -> McpbParameterComparisonResult:
|
|
198
|
+
"""Compare, report, and optionally enforce user-supplied acceptance limits."""
|
|
199
|
+
|
|
200
|
+
candidate_path = Path(candidate_frcmod).resolve()
|
|
201
|
+
reference_path = Path(config.reference_frcmod).resolve()
|
|
202
|
+
if candidate_path == reference_path:
|
|
203
|
+
raise McpbParameterComparisonError(
|
|
204
|
+
"Candidate and reference frcmod paths resolve to the same file"
|
|
205
|
+
)
|
|
206
|
+
candidate = parse_mcpb_seminario_terms(
|
|
207
|
+
candidate_path,
|
|
208
|
+
allow_nonpositive_force_constants=True,
|
|
209
|
+
)
|
|
210
|
+
reference = parse_mcpb_seminario_terms(
|
|
211
|
+
reference_path,
|
|
212
|
+
allow_nonpositive_force_constants=True,
|
|
213
|
+
)
|
|
214
|
+
candidate_keys = set(candidate)
|
|
215
|
+
reference_keys = set(reference)
|
|
216
|
+
missing_candidate = tuple(sorted(reference_keys - candidate_keys))
|
|
217
|
+
missing_reference = tuple(sorted(candidate_keys - reference_keys))
|
|
218
|
+
nonpositive_candidate = tuple(
|
|
219
|
+
sorted(key for key, term in candidate.items() if term.force_constant <= 0.0)
|
|
220
|
+
)
|
|
221
|
+
nonpositive_reference = tuple(
|
|
222
|
+
sorted(key for key, term in reference.items() if term.force_constant <= 0.0)
|
|
223
|
+
)
|
|
224
|
+
differences = tuple(
|
|
225
|
+
_difference(candidate[key], reference[key])
|
|
226
|
+
for key in sorted(candidate_keys & reference_keys)
|
|
227
|
+
)
|
|
228
|
+
if not differences:
|
|
229
|
+
raise McpbParameterComparisonError(
|
|
230
|
+
"Candidate and reference contain no matching MCPB Seminario terms"
|
|
231
|
+
)
|
|
232
|
+
metrics = {
|
|
233
|
+
"bonds": _metrics(item for item in differences if item.section == "BOND"),
|
|
234
|
+
"angles": _metrics(item for item in differences if item.section == "ANGL"),
|
|
235
|
+
}
|
|
236
|
+
thresholds: dict[str, float | None] = {
|
|
237
|
+
"max_bond_force_constant_relative_rmse": (
|
|
238
|
+
config.max_bond_force_constant_relative_rmse
|
|
239
|
+
),
|
|
240
|
+
"max_angle_force_constant_relative_rmse": (
|
|
241
|
+
config.max_angle_force_constant_relative_rmse
|
|
242
|
+
),
|
|
243
|
+
"max_bond_equilibrium_distance_rmse_angstrom": (
|
|
244
|
+
config.max_bond_equilibrium_distance_rmse_angstrom
|
|
245
|
+
),
|
|
246
|
+
"max_angle_equilibrium_value_rmse_degrees": (
|
|
247
|
+
config.max_angle_equilibrium_value_rmse_degrees
|
|
248
|
+
),
|
|
249
|
+
}
|
|
250
|
+
violations: list[str] = []
|
|
251
|
+
if nonpositive_candidate:
|
|
252
|
+
violations.append(
|
|
253
|
+
"Candidate contains non-positive MCPB Seminario force constants: "
|
|
254
|
+
f"{list(nonpositive_candidate)}"
|
|
255
|
+
)
|
|
256
|
+
if nonpositive_reference:
|
|
257
|
+
violations.append(
|
|
258
|
+
"Reference contains non-positive MCPB Seminario force constants: "
|
|
259
|
+
f"{list(nonpositive_reference)}"
|
|
260
|
+
)
|
|
261
|
+
if config.require_exact_term_set and (missing_candidate or missing_reference):
|
|
262
|
+
violations.append(
|
|
263
|
+
"Candidate/reference MCPB Seminario term sets differ: "
|
|
264
|
+
f"missing from candidate={list(missing_candidate)}, "
|
|
265
|
+
f"missing from reference={list(missing_reference)}"
|
|
266
|
+
)
|
|
267
|
+
_check_threshold(
|
|
268
|
+
violations,
|
|
269
|
+
label="bond force-constant relative RMSE",
|
|
270
|
+
value=metrics["bonds"]["force_constant_relative_rmse"],
|
|
271
|
+
limit=config.max_bond_force_constant_relative_rmse,
|
|
272
|
+
)
|
|
273
|
+
_check_threshold(
|
|
274
|
+
violations,
|
|
275
|
+
label="angle force-constant relative RMSE",
|
|
276
|
+
value=metrics["angles"]["force_constant_relative_rmse"],
|
|
277
|
+
limit=config.max_angle_force_constant_relative_rmse,
|
|
278
|
+
)
|
|
279
|
+
_check_threshold(
|
|
280
|
+
violations,
|
|
281
|
+
label="bond equilibrium-distance RMSE (angstrom)",
|
|
282
|
+
value=metrics["bonds"]["equilibrium_value_rmse"],
|
|
283
|
+
limit=config.max_bond_equilibrium_distance_rmse_angstrom,
|
|
284
|
+
)
|
|
285
|
+
_check_threshold(
|
|
286
|
+
violations,
|
|
287
|
+
label="angle equilibrium-value RMSE (degrees)",
|
|
288
|
+
value=metrics["angles"]["equilibrium_value_rmse"],
|
|
289
|
+
limit=config.max_angle_equilibrium_value_rmse_degrees,
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
output = Path(output_dir)
|
|
293
|
+
output.mkdir(parents=True, exist_ok=True)
|
|
294
|
+
result = McpbParameterComparisonResult(
|
|
295
|
+
candidate_frcmod=candidate_path,
|
|
296
|
+
reference_frcmod=reference_path,
|
|
297
|
+
candidate_label=config.candidate_label,
|
|
298
|
+
reference_label=config.reference_label,
|
|
299
|
+
differences=differences,
|
|
300
|
+
missing_from_candidate=missing_candidate,
|
|
301
|
+
missing_from_reference=missing_reference,
|
|
302
|
+
nonpositive_candidate_force_constants=nonpositive_candidate,
|
|
303
|
+
nonpositive_reference_force_constants=nonpositive_reference,
|
|
304
|
+
metrics=metrics,
|
|
305
|
+
thresholds=thresholds,
|
|
306
|
+
violations=tuple(violations),
|
|
307
|
+
json_path=output / "mcpb_parameter_comparison.json",
|
|
308
|
+
csv_path=output / "mcpb_parameter_comparison.csv",
|
|
309
|
+
markdown_path=output / "mcpb_parameter_comparison.md",
|
|
310
|
+
)
|
|
311
|
+
_write_reports(result)
|
|
312
|
+
if violations and config.fail_on_thresholds:
|
|
313
|
+
raise McpbParameterComparisonError(
|
|
314
|
+
"MCPB parameter comparison failed configured acceptance criteria. "
|
|
315
|
+
f"See {result.json_path}. Violations: " + "; ".join(violations)
|
|
316
|
+
)
|
|
317
|
+
return result
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def _parse_term_line(
|
|
321
|
+
raw: str,
|
|
322
|
+
*,
|
|
323
|
+
section: str,
|
|
324
|
+
path: Path,
|
|
325
|
+
line_number: int,
|
|
326
|
+
allow_nonpositive_force_constants: bool,
|
|
327
|
+
) -> McpbParameterTerm:
|
|
328
|
+
atom_count = 2 if section == "BOND" else 3
|
|
329
|
+
minimum_width = 5 if section == "BOND" else 8
|
|
330
|
+
if len(raw) < minimum_width:
|
|
331
|
+
raise McpbParameterComparisonError(
|
|
332
|
+
f"Malformed {section} term at {path}:{line_number}: {raw!r}"
|
|
333
|
+
)
|
|
334
|
+
if section == "BOND":
|
|
335
|
+
raw_types = (raw[0:2].strip(), raw[3:5].strip())
|
|
336
|
+
values = raw[5:].split()
|
|
337
|
+
else:
|
|
338
|
+
raw_types = (raw[0:2].strip(), raw[3:5].strip(), raw[6:8].strip())
|
|
339
|
+
values = raw[8:].split()
|
|
340
|
+
if len(raw_types) != atom_count or any(not value for value in raw_types):
|
|
341
|
+
raise McpbParameterComparisonError(
|
|
342
|
+
f"Malformed atom types in {section} term at {path}:{line_number}: {raw!r}"
|
|
343
|
+
)
|
|
344
|
+
try:
|
|
345
|
+
force_constant = float(values[0])
|
|
346
|
+
equilibrium = float(values[1])
|
|
347
|
+
except (IndexError, ValueError) as exc:
|
|
348
|
+
raise McpbParameterComparisonError(
|
|
349
|
+
f"Malformed numeric values in {section} term at {path}:{line_number}: {raw!r}"
|
|
350
|
+
) from exc
|
|
351
|
+
if not isfinite(force_constant) or (
|
|
352
|
+
force_constant <= 0 and not allow_nonpositive_force_constants
|
|
353
|
+
):
|
|
354
|
+
raise McpbParameterComparisonError(
|
|
355
|
+
f"Non-positive or non-finite force constant in {section} term at "
|
|
356
|
+
f"{path}:{line_number}: {force_constant}"
|
|
357
|
+
)
|
|
358
|
+
equilibrium_valid = equilibrium > 0 and (
|
|
359
|
+
section == "BOND" or equilibrium <= 180.0
|
|
360
|
+
)
|
|
361
|
+
if not isfinite(equilibrium) or not equilibrium_valid:
|
|
362
|
+
raise McpbParameterComparisonError(
|
|
363
|
+
f"Invalid equilibrium value in {section} term at {path}:{line_number}: "
|
|
364
|
+
f"{equilibrium}"
|
|
365
|
+
)
|
|
366
|
+
atom_types = _canonical_atom_types(section, raw_types)
|
|
367
|
+
return McpbParameterTerm(
|
|
368
|
+
section=section,
|
|
369
|
+
atom_types=atom_types,
|
|
370
|
+
force_constant=force_constant,
|
|
371
|
+
equilibrium_value=equilibrium,
|
|
372
|
+
source_line=raw,
|
|
373
|
+
)
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def _canonical_atom_types(section: str, values: tuple[str, ...]) -> tuple[str, ...]:
|
|
377
|
+
if section == "BOND":
|
|
378
|
+
return tuple(sorted(values))
|
|
379
|
+
forward = values
|
|
380
|
+
reverse = tuple(reversed(values))
|
|
381
|
+
return min(forward, reverse)
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def _difference(
|
|
385
|
+
candidate: McpbParameterTerm,
|
|
386
|
+
reference: McpbParameterTerm,
|
|
387
|
+
) -> McpbTermDifference:
|
|
388
|
+
force_delta = candidate.force_constant - reference.force_constant
|
|
389
|
+
relative = (
|
|
390
|
+
force_delta / abs(reference.force_constant)
|
|
391
|
+
if reference.force_constant != 0.0
|
|
392
|
+
else None
|
|
393
|
+
)
|
|
394
|
+
return McpbTermDifference(
|
|
395
|
+
section=candidate.section,
|
|
396
|
+
atom_types=candidate.atom_types,
|
|
397
|
+
candidate_force_constant=candidate.force_constant,
|
|
398
|
+
reference_force_constant=reference.force_constant,
|
|
399
|
+
force_constant_difference=force_delta,
|
|
400
|
+
force_constant_relative_difference=relative,
|
|
401
|
+
candidate_equilibrium_value=candidate.equilibrium_value,
|
|
402
|
+
reference_equilibrium_value=reference.equilibrium_value,
|
|
403
|
+
equilibrium_value_difference=(
|
|
404
|
+
candidate.equilibrium_value - reference.equilibrium_value
|
|
405
|
+
),
|
|
406
|
+
)
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def _metrics(
|
|
410
|
+
items: Iterable[McpbTermDifference],
|
|
411
|
+
) -> dict[str, float | int | None]:
|
|
412
|
+
values = list(items)
|
|
413
|
+
if not values:
|
|
414
|
+
return {
|
|
415
|
+
"count": 0,
|
|
416
|
+
"force_constant_relative_count": 0,
|
|
417
|
+
"force_constant_mae": None,
|
|
418
|
+
"force_constant_rmse": None,
|
|
419
|
+
"force_constant_relative_mae": None,
|
|
420
|
+
"force_constant_relative_rmse": None,
|
|
421
|
+
"force_constant_max_absolute_relative_difference": None,
|
|
422
|
+
"equilibrium_value_mae": None,
|
|
423
|
+
"equilibrium_value_rmse": None,
|
|
424
|
+
"equilibrium_value_max_absolute_difference": None,
|
|
425
|
+
}
|
|
426
|
+
force_differences = [item.force_constant_difference for item in values]
|
|
427
|
+
relative_differences = [
|
|
428
|
+
item.force_constant_relative_difference
|
|
429
|
+
for item in values
|
|
430
|
+
if item.force_constant_relative_difference is not None
|
|
431
|
+
]
|
|
432
|
+
equilibrium_differences = [
|
|
433
|
+
item.equilibrium_value_difference for item in values
|
|
434
|
+
]
|
|
435
|
+
return {
|
|
436
|
+
"count": len(values),
|
|
437
|
+
"force_constant_relative_count": len(relative_differences),
|
|
438
|
+
"force_constant_mae": _mae(force_differences),
|
|
439
|
+
"force_constant_rmse": _rmse(force_differences),
|
|
440
|
+
"force_constant_relative_mae": _mae(relative_differences),
|
|
441
|
+
"force_constant_relative_rmse": _rmse(relative_differences),
|
|
442
|
+
"force_constant_max_absolute_relative_difference": max(
|
|
443
|
+
(abs(value) for value in relative_differences), default=None
|
|
444
|
+
),
|
|
445
|
+
"equilibrium_value_mae": _mae(equilibrium_differences),
|
|
446
|
+
"equilibrium_value_rmse": _rmse(equilibrium_differences),
|
|
447
|
+
"equilibrium_value_max_absolute_difference": max(
|
|
448
|
+
abs(value) for value in equilibrium_differences
|
|
449
|
+
),
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
def _mae(values: list[float]) -> float | None:
|
|
454
|
+
return sum(abs(value) for value in values) / len(values) if values else None
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
def _rmse(values: list[float]) -> float | None:
|
|
458
|
+
return sqrt(sum(value * value for value in values) / len(values)) if values else None
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
def _check_threshold(
|
|
462
|
+
violations: list[str],
|
|
463
|
+
*,
|
|
464
|
+
label: str,
|
|
465
|
+
value: float | int | None,
|
|
466
|
+
limit: float | None,
|
|
467
|
+
) -> None:
|
|
468
|
+
if limit is None:
|
|
469
|
+
return
|
|
470
|
+
if value is None:
|
|
471
|
+
violations.append(f"{label} could not be evaluated; configured limit is {limit}")
|
|
472
|
+
elif float(value) > limit:
|
|
473
|
+
violations.append(f"{label} {float(value):.8g} exceeds configured limit {limit:.8g}")
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
def _write_reports(result: McpbParameterComparisonResult) -> None:
|
|
477
|
+
data = result.to_dict()
|
|
478
|
+
result.json_path.write_text(
|
|
479
|
+
json.dumps(data, indent=2, sort_keys=True) + "\n", encoding="utf-8"
|
|
480
|
+
)
|
|
481
|
+
with result.csv_path.open("w", newline="", encoding="utf-8") as handle:
|
|
482
|
+
fieldnames = list(result.differences[0].to_dict())
|
|
483
|
+
writer = csv.DictWriter(handle, fieldnames=fieldnames)
|
|
484
|
+
writer.writeheader()
|
|
485
|
+
for item in result.differences:
|
|
486
|
+
row = item.to_dict()
|
|
487
|
+
row["atom_types"] = "-".join(item.atom_types)
|
|
488
|
+
writer.writerow(row)
|
|
489
|
+
lines = [
|
|
490
|
+
"# MCPB parameter comparison",
|
|
491
|
+
"",
|
|
492
|
+
f"- Status: {result.status.upper()}",
|
|
493
|
+
f"- Candidate: `{result.candidate_label}` (`{result.candidate_frcmod}`)",
|
|
494
|
+
f"- Reference: `{result.reference_label}` (`{result.reference_frcmod}`)",
|
|
495
|
+
f"- Matched terms: {len(result.differences)}",
|
|
496
|
+
"- Scope: MCPB.py Seminario-created BOND and ANGL terms only",
|
|
497
|
+
"",
|
|
498
|
+
"## Metrics",
|
|
499
|
+
"",
|
|
500
|
+
]
|
|
501
|
+
for section, metrics in result.metrics.items():
|
|
502
|
+
lines.extend(
|
|
503
|
+
[
|
|
504
|
+
f"### {section.title()}",
|
|
505
|
+
"",
|
|
506
|
+
f"- Count: {metrics['count']}",
|
|
507
|
+
f"- Relative force-constant count: "
|
|
508
|
+
f"{metrics['force_constant_relative_count']}",
|
|
509
|
+
f"- Force-constant relative RMSE: {metrics['force_constant_relative_rmse']}",
|
|
510
|
+
f"- Force-constant maximum absolute relative difference: "
|
|
511
|
+
f"{metrics['force_constant_max_absolute_relative_difference']}",
|
|
512
|
+
f"- Equilibrium-value RMSE: {metrics['equilibrium_value_rmse']}",
|
|
513
|
+
"",
|
|
514
|
+
]
|
|
515
|
+
)
|
|
516
|
+
lines.extend(["## Term-by-term differences", ""])
|
|
517
|
+
lines.append(
|
|
518
|
+
"| Term | Candidate K | Reference K | Relative K difference | "
|
|
519
|
+
"Candidate equilibrium | Reference equilibrium | Difference |"
|
|
520
|
+
)
|
|
521
|
+
lines.append("| --- | ---: | ---: | ---: | ---: | ---: | ---: |")
|
|
522
|
+
for item in result.differences:
|
|
523
|
+
lines.append(
|
|
524
|
+
f"| `{item.key}` | {item.candidate_force_constant:.8g} | "
|
|
525
|
+
f"{item.reference_force_constant:.8g} | "
|
|
526
|
+
f"{item.force_constant_relative_difference} | "
|
|
527
|
+
f"{item.candidate_equilibrium_value:.8g} | "
|
|
528
|
+
f"{item.reference_equilibrium_value:.8g} | "
|
|
529
|
+
f"{item.equilibrium_value_difference:.8g} |"
|
|
530
|
+
)
|
|
531
|
+
lines.extend(["", "## Interpretation", ""])
|
|
532
|
+
lines.append(
|
|
533
|
+
"Agreement with the selected reference measures method sensitivity for this "
|
|
534
|
+
"exact geometry and electronic state. It does not by itself validate "
|
|
535
|
+
"production MD."
|
|
536
|
+
)
|
|
537
|
+
if (
|
|
538
|
+
result.nonpositive_candidate_force_constants
|
|
539
|
+
or result.nonpositive_reference_force_constants
|
|
540
|
+
):
|
|
541
|
+
lines.extend(["", "## Invalid bonded terms", ""])
|
|
542
|
+
if result.nonpositive_candidate_force_constants:
|
|
543
|
+
lines.append(
|
|
544
|
+
"- Candidate non-positive force constants: "
|
|
545
|
+
+ ", ".join(result.nonpositive_candidate_force_constants)
|
|
546
|
+
)
|
|
547
|
+
if result.nonpositive_reference_force_constants:
|
|
548
|
+
lines.append(
|
|
549
|
+
"- Reference non-positive force constants: "
|
|
550
|
+
+ ", ".join(result.nonpositive_reference_force_constants)
|
|
551
|
+
)
|
|
552
|
+
if result.violations:
|
|
553
|
+
lines.extend(["", "## Violations", ""])
|
|
554
|
+
lines.extend(f"- {item}" for item in result.violations)
|
|
555
|
+
result.markdown_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
def _sha256(path: str | Path) -> str:
|
|
559
|
+
digest = hashlib.sha256()
|
|
560
|
+
with Path(path).open("rb") as handle:
|
|
561
|
+
for block in iter(lambda: handle.read(1024 * 1024), b""):
|
|
562
|
+
digest.update(block)
|
|
563
|
+
return digest.hexdigest()
|