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,763 @@
|
|
|
1
|
+
"""Preparation workflow."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import platform
|
|
7
|
+
import shutil
|
|
8
|
+
import subprocess
|
|
9
|
+
import sys
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
import yaml
|
|
14
|
+
|
|
15
|
+
from mdprep import __version__
|
|
16
|
+
from mdprep.config.loader import load_manifest
|
|
17
|
+
from mdprep.external.runner import run_command
|
|
18
|
+
from mdprep.leap.builder import TLeapBuildError, TLeapStageResult, run_tleap_stage
|
|
19
|
+
from mdprep.leap.report import write_tleap_reports
|
|
20
|
+
from mdprep.ligands.report import write_ligand_reports
|
|
21
|
+
from mdprep.ligands.workflow import LigandStageResult, LigandWorkflowError, run_ligand_stage
|
|
22
|
+
from mdprep.metals.report import write_metal_reports
|
|
23
|
+
from mdprep.metals.workflow import (
|
|
24
|
+
MetalStageResult,
|
|
25
|
+
MetalWorkflowError,
|
|
26
|
+
promote_mcpb_resp_ligands,
|
|
27
|
+
run_metal_stage,
|
|
28
|
+
)
|
|
29
|
+
from mdprep.md.roe_brooks import RoeBrooksError, RoeBrooksResult, run_roe_brooks
|
|
30
|
+
from mdprep.protonation.apply import (
|
|
31
|
+
ProtonationApplicationError,
|
|
32
|
+
ProtonationResult,
|
|
33
|
+
apply_protonation_stage,
|
|
34
|
+
reuse_protonation_assignments_after_refinement,
|
|
35
|
+
)
|
|
36
|
+
from mdprep.protonation.report import write_protonation_reports
|
|
37
|
+
from mdprep.refinement.ash import AshRefinementError, capture_ash_refinement_cache
|
|
38
|
+
from mdprep.refinement.report import write_refinement_reports
|
|
39
|
+
from mdprep.refinement.workflow import (
|
|
40
|
+
RefinementResult,
|
|
41
|
+
RefinementWorkflowError,
|
|
42
|
+
run_refinement_stage,
|
|
43
|
+
)
|
|
44
|
+
from mdprep.reports.structure_report import write_structure_reports
|
|
45
|
+
from mdprep.structure.normalize import StructureNormalizationResult, normalize_structure_stage
|
|
46
|
+
from mdprep.structure.writer import write_pdb
|
|
47
|
+
from mdprep.validation.openmm_check import openmm_version
|
|
48
|
+
from mdprep.validation.parmed_check import parmed_version
|
|
49
|
+
from mdprep.qm.pyscf_runner import pyscf_version
|
|
50
|
+
from mdprep.validation.topology import FinalValidationError, validate_final_outputs, write_validation_reports
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class PrepareWorkflowError(ValueError):
|
|
54
|
+
"""Raised when the requested preparation workflow is not available."""
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass
|
|
58
|
+
class PrepareResult:
|
|
59
|
+
stage: str
|
|
60
|
+
structure_result: StructureNormalizationResult
|
|
61
|
+
protonation_result: ProtonationResult | None = None
|
|
62
|
+
refinement_result: RefinementResult | None = None
|
|
63
|
+
ligand_result: LigandStageResult | None = None
|
|
64
|
+
metal_result: MetalStageResult | None = None
|
|
65
|
+
tleap_result: TLeapStageResult | None = None
|
|
66
|
+
md_result: RoeBrooksResult | None = None
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def output_path(self) -> Path | None:
|
|
70
|
+
if self.md_result is not None:
|
|
71
|
+
return self.md_result.final_pdb
|
|
72
|
+
if self.tleap_result is not None:
|
|
73
|
+
return self.tleap_result.final_outputs.pdb
|
|
74
|
+
if self.metal_result is not None:
|
|
75
|
+
if self.metal_result.mcpb_site is not None:
|
|
76
|
+
site = self.metal_result.mcpb_site
|
|
77
|
+
return site.restored_final_pdb_path or site.mcpb_input_pdb_path
|
|
78
|
+
if self.metal_result.structure is not None:
|
|
79
|
+
return self.metal_result.structure.path
|
|
80
|
+
if self.protonation_result is not None:
|
|
81
|
+
return self.protonation_result.output_protonation_pdb_path
|
|
82
|
+
return self.structure_result.output_path
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def prepare_system(
|
|
86
|
+
manifest_path: str | Path,
|
|
87
|
+
*,
|
|
88
|
+
stop_after: str | None = None,
|
|
89
|
+
overwrite: bool = False,
|
|
90
|
+
resume: bool = False,
|
|
91
|
+
) -> PrepareResult:
|
|
92
|
+
manifest_file = Path(manifest_path)
|
|
93
|
+
manifest = load_manifest(manifest_file, resolve_paths=True)
|
|
94
|
+
if stop_after is None:
|
|
95
|
+
stop_after = "md" if manifest.molecular_dynamics.enabled else "tleap"
|
|
96
|
+
if stop_after not in {
|
|
97
|
+
"structure",
|
|
98
|
+
"protonation",
|
|
99
|
+
"refinement",
|
|
100
|
+
"ligands",
|
|
101
|
+
"metals",
|
|
102
|
+
"tleap",
|
|
103
|
+
"md",
|
|
104
|
+
}:
|
|
105
|
+
raise PrepareWorkflowError(
|
|
106
|
+
"Unsupported stop stage; use --stop-after structure, --stop-after protonation, "
|
|
107
|
+
"--stop-after refinement, --stop-after ligands, --stop-after metals, or "
|
|
108
|
+
"--stop-after tleap, or --stop-after md."
|
|
109
|
+
)
|
|
110
|
+
if stop_after == "md" and not manifest.molecular_dynamics.enabled:
|
|
111
|
+
raise PrepareWorkflowError(
|
|
112
|
+
"--stop-after md requires molecular_dynamics.enabled: true in the manifest"
|
|
113
|
+
)
|
|
114
|
+
if stop_after == "refinement" and not manifest.refinement.enabled:
|
|
115
|
+
raise PrepareWorkflowError(
|
|
116
|
+
"--stop-after refinement requires refinement.enabled: true in the manifest"
|
|
117
|
+
)
|
|
118
|
+
output_dir = Path(manifest.project.output_dir)
|
|
119
|
+
if overwrite and resume:
|
|
120
|
+
raise PrepareWorkflowError("--overwrite and --resume are mutually exclusive")
|
|
121
|
+
if resume and not output_dir.exists():
|
|
122
|
+
raise FileNotFoundError(
|
|
123
|
+
f"Cannot resume because the output directory does not exist: {output_dir}"
|
|
124
|
+
)
|
|
125
|
+
if output_dir.exists() and not overwrite and not resume:
|
|
126
|
+
raise FileExistsError(f"Output directory already exists: {output_dir}. Use --overwrite to replace mdprep outputs.")
|
|
127
|
+
cached_ash_run = None
|
|
128
|
+
if resume and manifest.refinement.enabled and stop_after in {
|
|
129
|
+
"refinement",
|
|
130
|
+
"ligands",
|
|
131
|
+
"metals",
|
|
132
|
+
"tleap",
|
|
133
|
+
"md",
|
|
134
|
+
}:
|
|
135
|
+
try:
|
|
136
|
+
cached_ash_run = capture_ash_refinement_cache(
|
|
137
|
+
output_dir / "refinement" / "ash"
|
|
138
|
+
)
|
|
139
|
+
except AshRefinementError as exc:
|
|
140
|
+
raise PrepareWorkflowError(str(exc)) from exc
|
|
141
|
+
|
|
142
|
+
intermediate_dir = output_dir / "intermediate"
|
|
143
|
+
reports_dir = output_dir / "reports"
|
|
144
|
+
intermediate_dir.mkdir(parents=True, exist_ok=True)
|
|
145
|
+
reports_dir.mkdir(parents=True, exist_ok=True)
|
|
146
|
+
|
|
147
|
+
manifest_input_path = output_dir / "manifest.input.yaml"
|
|
148
|
+
shutil.copyfile(manifest_file, manifest_input_path)
|
|
149
|
+
|
|
150
|
+
normalized_pdb = intermediate_dir / "00_input_normalized.pdb"
|
|
151
|
+
result = normalize_structure_stage(manifest, output_path=normalized_pdb)
|
|
152
|
+
write_pdb(result.normalized_structure, normalized_pdb)
|
|
153
|
+
result.output_path = normalized_pdb
|
|
154
|
+
|
|
155
|
+
report = write_structure_reports(
|
|
156
|
+
result,
|
|
157
|
+
json_path=reports_dir / "structure_report.json",
|
|
158
|
+
markdown_path=reports_dir / "structure_report.md",
|
|
159
|
+
)
|
|
160
|
+
_write_manifest_lock(
|
|
161
|
+
manifest=manifest,
|
|
162
|
+
structure_report=report,
|
|
163
|
+
path=output_dir / "manifest.lock.yaml",
|
|
164
|
+
input_manifest_path=manifest_file,
|
|
165
|
+
)
|
|
166
|
+
_write_versions(output_dir / "versions.json")
|
|
167
|
+
|
|
168
|
+
protonation_report = None
|
|
169
|
+
protonation_result = None
|
|
170
|
+
refinement_report = None
|
|
171
|
+
refinement_result = None
|
|
172
|
+
ligand_report = None
|
|
173
|
+
ligand_result = None
|
|
174
|
+
metal_report = None
|
|
175
|
+
metal_result = None
|
|
176
|
+
tleap_report = None
|
|
177
|
+
validation_report = None
|
|
178
|
+
tleap_result = None
|
|
179
|
+
if stop_after in {"protonation", "refinement", "ligands", "metals", "tleap", "md"}:
|
|
180
|
+
protonation_pdb = intermediate_dir / "01_protonation_assigned.pdb"
|
|
181
|
+
protonation_result = apply_protonation_stage(
|
|
182
|
+
result.normalized_structure,
|
|
183
|
+
manifest,
|
|
184
|
+
input_normalized_pdb_path=normalized_pdb,
|
|
185
|
+
output_protonation_pdb_path=protonation_pdb,
|
|
186
|
+
)
|
|
187
|
+
write_pdb(protonation_result.structure, protonation_pdb)
|
|
188
|
+
preliminary_report_name = (
|
|
189
|
+
"protonation_pre_refinement_report"
|
|
190
|
+
if manifest.refinement.enabled
|
|
191
|
+
else "protonation_report"
|
|
192
|
+
)
|
|
193
|
+
protonation_report = write_protonation_reports(
|
|
194
|
+
protonation_result,
|
|
195
|
+
json_path=reports_dir / f"{preliminary_report_name}.json",
|
|
196
|
+
csv_path=reports_dir / f"{preliminary_report_name}.csv",
|
|
197
|
+
markdown_path=reports_dir / f"{preliminary_report_name}.md",
|
|
198
|
+
)
|
|
199
|
+
_write_manifest_lock(
|
|
200
|
+
manifest=manifest,
|
|
201
|
+
structure_report=report,
|
|
202
|
+
path=output_dir / "manifest.lock.yaml",
|
|
203
|
+
input_manifest_path=manifest_file,
|
|
204
|
+
protonation_report=protonation_report,
|
|
205
|
+
)
|
|
206
|
+
_write_versions(
|
|
207
|
+
output_dir / "versions.json",
|
|
208
|
+
external_executables=_external_executables_from_protonation_report(protonation_report),
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
if manifest.refinement.enabled and stop_after in {
|
|
212
|
+
"refinement",
|
|
213
|
+
"ligands",
|
|
214
|
+
"metals",
|
|
215
|
+
"tleap",
|
|
216
|
+
"md",
|
|
217
|
+
}:
|
|
218
|
+
assert protonation_result is not None
|
|
219
|
+
refinement_kwargs = {}
|
|
220
|
+
if cached_ash_run is not None:
|
|
221
|
+
refinement_kwargs["cached_ash_run"] = cached_ash_run
|
|
222
|
+
refinement_result = run_refinement_stage(
|
|
223
|
+
protonated_structure=protonation_result.structure,
|
|
224
|
+
reference_structure=result.normalized_structure,
|
|
225
|
+
manifest=manifest,
|
|
226
|
+
output_dir=output_dir,
|
|
227
|
+
protonation_result=protonation_result,
|
|
228
|
+
**refinement_kwargs,
|
|
229
|
+
)
|
|
230
|
+
refinement_report = write_refinement_reports(
|
|
231
|
+
refinement_result,
|
|
232
|
+
json_path=reports_dir / "refinement_report.json",
|
|
233
|
+
markdown_path=reports_dir / "refinement_report.md",
|
|
234
|
+
)
|
|
235
|
+
final_protonation_pdb = intermediate_dir / "03_final_protonation_assigned.pdb"
|
|
236
|
+
if manifest.refinement.post_refinement_protonation == "reuse_initial":
|
|
237
|
+
protonation_result = reuse_protonation_assignments_after_refinement(
|
|
238
|
+
refinement_result.structure,
|
|
239
|
+
protonation_result,
|
|
240
|
+
input_refined_pdb_path=(
|
|
241
|
+
refinement_result.refined_hydrogenated_pdb_path
|
|
242
|
+
),
|
|
243
|
+
output_protonation_pdb_path=final_protonation_pdb,
|
|
244
|
+
)
|
|
245
|
+
else:
|
|
246
|
+
protonation_result = apply_protonation_stage(
|
|
247
|
+
refinement_result.structure,
|
|
248
|
+
manifest,
|
|
249
|
+
input_normalized_pdb_path=(
|
|
250
|
+
refinement_result.refined_hydrogenated_pdb_path
|
|
251
|
+
),
|
|
252
|
+
output_protonation_pdb_path=final_protonation_pdb,
|
|
253
|
+
)
|
|
254
|
+
write_pdb(protonation_result.structure, final_protonation_pdb)
|
|
255
|
+
protonation_report = write_protonation_reports(
|
|
256
|
+
protonation_result,
|
|
257
|
+
json_path=reports_dir / "protonation_report.json",
|
|
258
|
+
csv_path=reports_dir / "protonation_report.csv",
|
|
259
|
+
markdown_path=reports_dir / "protonation_report.md",
|
|
260
|
+
)
|
|
261
|
+
_write_manifest_lock(
|
|
262
|
+
manifest=manifest,
|
|
263
|
+
structure_report=report,
|
|
264
|
+
path=output_dir / "manifest.lock.yaml",
|
|
265
|
+
input_manifest_path=manifest_file,
|
|
266
|
+
protonation_report=protonation_report,
|
|
267
|
+
refinement_report=refinement_report,
|
|
268
|
+
)
|
|
269
|
+
external_versions = _external_executables_from_protonation_report(
|
|
270
|
+
protonation_report
|
|
271
|
+
)
|
|
272
|
+
external_versions.update(
|
|
273
|
+
_external_executables_from_refinement_report(refinement_report)
|
|
274
|
+
)
|
|
275
|
+
_write_versions(
|
|
276
|
+
output_dir / "versions.json",
|
|
277
|
+
external_executables=external_versions,
|
|
278
|
+
include_optional_python_packages=True,
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
if stop_after in {"ligands", "metals", "tleap", "md"}:
|
|
282
|
+
assert protonation_result is not None
|
|
283
|
+
ligand_result = run_ligand_stage(
|
|
284
|
+
protonation_result.structure,
|
|
285
|
+
manifest,
|
|
286
|
+
output_dir=output_dir,
|
|
287
|
+
protonation_result=protonation_result,
|
|
288
|
+
)
|
|
289
|
+
ligand_report = write_ligand_reports(
|
|
290
|
+
ligand_result,
|
|
291
|
+
json_path=reports_dir / "ligand_report.json",
|
|
292
|
+
csv_path=reports_dir / "ligand_report.csv",
|
|
293
|
+
markdown_path=reports_dir / "ligand_report.md",
|
|
294
|
+
)
|
|
295
|
+
_write_manifest_lock(
|
|
296
|
+
manifest=manifest,
|
|
297
|
+
structure_report=report,
|
|
298
|
+
path=output_dir / "manifest.lock.yaml",
|
|
299
|
+
input_manifest_path=manifest_file,
|
|
300
|
+
protonation_report=protonation_report,
|
|
301
|
+
refinement_report=refinement_report,
|
|
302
|
+
ligand_report=ligand_report,
|
|
303
|
+
)
|
|
304
|
+
external_versions = {}
|
|
305
|
+
if protonation_report is not None:
|
|
306
|
+
external_versions.update(_external_executables_from_protonation_report(protonation_report))
|
|
307
|
+
if refinement_report is not None:
|
|
308
|
+
external_versions.update(
|
|
309
|
+
_external_executables_from_refinement_report(refinement_report)
|
|
310
|
+
)
|
|
311
|
+
external_versions.update(_external_executables_from_ligand_report(ligand_report))
|
|
312
|
+
_write_versions(
|
|
313
|
+
output_dir / "versions.json",
|
|
314
|
+
external_executables=external_versions,
|
|
315
|
+
include_optional_python_packages=True,
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
if stop_after in {"metals", "tleap", "md"}:
|
|
319
|
+
assert protonation_result is not None
|
|
320
|
+
assert ligand_result is not None
|
|
321
|
+
metal_result = run_metal_stage(
|
|
322
|
+
protonation_result.structure,
|
|
323
|
+
manifest,
|
|
324
|
+
output_dir=output_dir,
|
|
325
|
+
ligand_result=ligand_result,
|
|
326
|
+
protonation_result=protonation_result,
|
|
327
|
+
refinement_result=refinement_result,
|
|
328
|
+
)
|
|
329
|
+
ligand_result = promote_mcpb_resp_ligands(ligand_result, metal_result)
|
|
330
|
+
ligand_report = write_ligand_reports(
|
|
331
|
+
ligand_result,
|
|
332
|
+
json_path=reports_dir / "ligand_report.json",
|
|
333
|
+
csv_path=reports_dir / "ligand_report.csv",
|
|
334
|
+
markdown_path=reports_dir / "ligand_report.md",
|
|
335
|
+
)
|
|
336
|
+
metal_report = write_metal_reports(
|
|
337
|
+
metal_result,
|
|
338
|
+
json_path=reports_dir / "metal_report.json",
|
|
339
|
+
markdown_path=reports_dir / "metal_report.md",
|
|
340
|
+
)
|
|
341
|
+
_write_manifest_lock(
|
|
342
|
+
manifest=manifest,
|
|
343
|
+
structure_report=report,
|
|
344
|
+
path=output_dir / "manifest.lock.yaml",
|
|
345
|
+
input_manifest_path=manifest_file,
|
|
346
|
+
protonation_report=protonation_report,
|
|
347
|
+
refinement_report=refinement_report,
|
|
348
|
+
ligand_report=ligand_report,
|
|
349
|
+
metal_report=metal_report,
|
|
350
|
+
)
|
|
351
|
+
external_versions = {}
|
|
352
|
+
if protonation_report is not None:
|
|
353
|
+
external_versions.update(_external_executables_from_protonation_report(protonation_report))
|
|
354
|
+
if refinement_report is not None:
|
|
355
|
+
external_versions.update(
|
|
356
|
+
_external_executables_from_refinement_report(refinement_report)
|
|
357
|
+
)
|
|
358
|
+
if ligand_report is not None:
|
|
359
|
+
external_versions.update(_external_executables_from_ligand_report(ligand_report))
|
|
360
|
+
external_versions.update(_external_executables_from_metal_report(metal_report))
|
|
361
|
+
_write_versions(
|
|
362
|
+
output_dir / "versions.json",
|
|
363
|
+
external_executables=external_versions,
|
|
364
|
+
include_optional_python_packages=True,
|
|
365
|
+
)
|
|
366
|
+
if stop_after in {"tleap", "md"} and not metal_result.complete:
|
|
367
|
+
assert metal_result.mcpb_site is not None
|
|
368
|
+
required = ", ".join(metal_result.mcpb_site.expected_qm_artifacts)
|
|
369
|
+
raise PrepareWorkflowError(
|
|
370
|
+
"Bonded MCPB.py workflow prepared QM inputs but is not complete. Run the generated "
|
|
371
|
+
f"QM jobs, configure metals[].mcpb.workflow: complete and artifacts ({required}), "
|
|
372
|
+
"then rerun before tleap."
|
|
373
|
+
)
|
|
374
|
+
|
|
375
|
+
if stop_after in {"tleap", "md"}:
|
|
376
|
+
assert protonation_result is not None
|
|
377
|
+
assert ligand_result is not None
|
|
378
|
+
assert metal_result is not None
|
|
379
|
+
tleap_result = run_tleap_stage(
|
|
380
|
+
structure=protonation_result.structure,
|
|
381
|
+
manifest=manifest,
|
|
382
|
+
output_dir=output_dir,
|
|
383
|
+
protonation_result=protonation_result,
|
|
384
|
+
ligand_result=ligand_result,
|
|
385
|
+
metal_result=metal_result,
|
|
386
|
+
)
|
|
387
|
+
tleap_report = write_tleap_reports(
|
|
388
|
+
tleap_result,
|
|
389
|
+
json_path=reports_dir / "tleap_report.json",
|
|
390
|
+
markdown_path=reports_dir / "tleap_report.md",
|
|
391
|
+
)
|
|
392
|
+
# Keep the legacy no-metal call shape so downstream integrations that
|
|
393
|
+
# wrap the validation hook do not need to understand metal metadata.
|
|
394
|
+
if manifest.metals:
|
|
395
|
+
validation_report = validate_final_outputs(
|
|
396
|
+
manifest=manifest,
|
|
397
|
+
prmtop=tleap_result.final_outputs.prmtop,
|
|
398
|
+
inpcrd=tleap_result.final_outputs.inpcrd,
|
|
399
|
+
pdb=tleap_result.final_outputs.pdb,
|
|
400
|
+
metal_result=metal_result,
|
|
401
|
+
)
|
|
402
|
+
else:
|
|
403
|
+
validation_report = validate_final_outputs(
|
|
404
|
+
manifest=manifest,
|
|
405
|
+
prmtop=tleap_result.final_outputs.prmtop,
|
|
406
|
+
inpcrd=tleap_result.final_outputs.inpcrd,
|
|
407
|
+
pdb=tleap_result.final_outputs.pdb,
|
|
408
|
+
)
|
|
409
|
+
write_validation_reports(
|
|
410
|
+
validation_report,
|
|
411
|
+
json_path=reports_dir / "validation_report.json",
|
|
412
|
+
markdown_path=reports_dir / "validation_report.md",
|
|
413
|
+
)
|
|
414
|
+
_write_manifest_lock(
|
|
415
|
+
manifest=manifest,
|
|
416
|
+
structure_report=report,
|
|
417
|
+
path=output_dir / "manifest.lock.yaml",
|
|
418
|
+
input_manifest_path=manifest_file,
|
|
419
|
+
protonation_report=protonation_report,
|
|
420
|
+
refinement_report=refinement_report,
|
|
421
|
+
ligand_report=ligand_report,
|
|
422
|
+
metal_report=metal_report,
|
|
423
|
+
tleap_report=tleap_report,
|
|
424
|
+
validation_report=validation_report,
|
|
425
|
+
)
|
|
426
|
+
external_versions = {}
|
|
427
|
+
if protonation_report is not None:
|
|
428
|
+
external_versions.update(_external_executables_from_protonation_report(protonation_report))
|
|
429
|
+
if refinement_report is not None:
|
|
430
|
+
external_versions.update(
|
|
431
|
+
_external_executables_from_refinement_report(refinement_report)
|
|
432
|
+
)
|
|
433
|
+
if ligand_report is not None:
|
|
434
|
+
external_versions.update(_external_executables_from_ligand_report(ligand_report))
|
|
435
|
+
if metal_report is not None:
|
|
436
|
+
external_versions.update(_external_executables_from_metal_report(metal_report))
|
|
437
|
+
external_versions.update(_external_executables_from_tleap_report(tleap_report))
|
|
438
|
+
_write_versions(
|
|
439
|
+
output_dir / "versions.json",
|
|
440
|
+
external_executables=external_versions,
|
|
441
|
+
include_optional_python_packages=True,
|
|
442
|
+
)
|
|
443
|
+
|
|
444
|
+
md_result = None
|
|
445
|
+
if stop_after == "md":
|
|
446
|
+
assert tleap_result is not None
|
|
447
|
+
try:
|
|
448
|
+
md_result = run_roe_brooks(
|
|
449
|
+
prmtop_path=tleap_result.final_outputs.prmtop,
|
|
450
|
+
inpcrd_path=tleap_result.final_outputs.inpcrd,
|
|
451
|
+
output_dir=output_dir / "md" / "roe_brooks_2020",
|
|
452
|
+
config=manifest.molecular_dynamics,
|
|
453
|
+
)
|
|
454
|
+
except RoeBrooksError as exc:
|
|
455
|
+
raise PrepareWorkflowError(str(exc)) from exc
|
|
456
|
+
|
|
457
|
+
return PrepareResult(
|
|
458
|
+
stage=stop_after,
|
|
459
|
+
structure_result=result,
|
|
460
|
+
protonation_result=protonation_result,
|
|
461
|
+
refinement_result=refinement_result,
|
|
462
|
+
ligand_result=ligand_result,
|
|
463
|
+
metal_result=metal_result,
|
|
464
|
+
tleap_result=tleap_result,
|
|
465
|
+
md_result=md_result,
|
|
466
|
+
)
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
def _write_manifest_lock(
|
|
470
|
+
*,
|
|
471
|
+
manifest: object,
|
|
472
|
+
structure_report: dict[str, object],
|
|
473
|
+
path: Path,
|
|
474
|
+
input_manifest_path: Path,
|
|
475
|
+
protonation_report: dict[str, object] | None = None,
|
|
476
|
+
refinement_report: dict[str, object] | None = None,
|
|
477
|
+
ligand_report: dict[str, object] | None = None,
|
|
478
|
+
metal_report: dict[str, object] | None = None,
|
|
479
|
+
tleap_report: dict[str, object] | None = None,
|
|
480
|
+
validation_report: dict[str, object] | None = None,
|
|
481
|
+
) -> None:
|
|
482
|
+
manifest_data = manifest.model_dump(mode="json") # type: ignore[attr-defined]
|
|
483
|
+
lock_data = {
|
|
484
|
+
"mdprep_version": __version__,
|
|
485
|
+
"manifest": manifest_data,
|
|
486
|
+
"resolved": {
|
|
487
|
+
"input_manifest_path": str(input_manifest_path),
|
|
488
|
+
"input_structure": manifest_data["project"]["input_structure"],
|
|
489
|
+
"selected_altloc_policy": manifest_data["structure"]["altloc_policy"],
|
|
490
|
+
"keep_crystal_waters": manifest_data["structure"]["keep_crystal_waters"],
|
|
491
|
+
"remove_unknown_heterogens": manifest_data["structure"]["remove_unknown_heterogens"],
|
|
492
|
+
"configured_ligand_selectors": [
|
|
493
|
+
{"id": ligand["id"], "selector": ligand["selector"]}
|
|
494
|
+
for ligand in manifest_data.get("ligands", [])
|
|
495
|
+
],
|
|
496
|
+
"unknown_heterogens_removed": structure_report["unknown_heterogens_removed"],
|
|
497
|
+
"unknown_heterogens_causing_failure": structure_report["unknown_heterogens_causing_failure"],
|
|
498
|
+
"possible_disulfides": structure_report["possible_disulfides"],
|
|
499
|
+
"structure_repair": structure_report.get("structure_repair"),
|
|
500
|
+
},
|
|
501
|
+
}
|
|
502
|
+
if protonation_report is not None:
|
|
503
|
+
lock_data["resolved"]["protonation"] = {
|
|
504
|
+
"method": protonation_report["method"],
|
|
505
|
+
"ph": protonation_report["ph"],
|
|
506
|
+
"applied_manual_overrides": protonation_report["manual_overrides_applied"],
|
|
507
|
+
"metal_coordination_assignments": protonation_report.get(
|
|
508
|
+
"metal_coordination_assignments_applied", []
|
|
509
|
+
),
|
|
510
|
+
"applied_disulfide_assignments": protonation_report["disulfide_assignments_applied"],
|
|
511
|
+
"input_state_assignments": protonation_report["input_state_assignments_applied"],
|
|
512
|
+
"propka_assignments": protonation_report["propka_assignments_applied"],
|
|
513
|
+
"xtb_assignments": protonation_report["xtb_assignments_applied"],
|
|
514
|
+
"hydrogens_removed": protonation_report["hydrogen_atoms_removed"],
|
|
515
|
+
"final_protonation_pdb_path": protonation_report["output_protonation_pdb_path"],
|
|
516
|
+
}
|
|
517
|
+
if protonation_report.get("propka") is not None:
|
|
518
|
+
propka = protonation_report["propka"]
|
|
519
|
+
lock_data["resolved"]["protonation"]["propka"] = {
|
|
520
|
+
"executable": propka["executable"],
|
|
521
|
+
"command": propka["command"],
|
|
522
|
+
"output_pka_path": propka["output_pka_path"],
|
|
523
|
+
"parsed_pkas_path": propka["parsed_pkas_path"],
|
|
524
|
+
}
|
|
525
|
+
if protonation_report.get("xtb_histidines"):
|
|
526
|
+
manifest_data = manifest.model_dump(mode="json") # type: ignore[attr-defined]
|
|
527
|
+
lock_data["resolved"]["protonation"]["xtb"] = {
|
|
528
|
+
"executable": protonation_report["xtb_histidines"][0]["executable"],
|
|
529
|
+
"model": manifest_data["protonation"]["histidine"]["xtb"]["model"],
|
|
530
|
+
"mode": manifest_data["protonation"]["histidine"]["xtb"]["mode"],
|
|
531
|
+
"solvent": manifest_data["protonation"]["histidine"]["xtb"]["solvent"],
|
|
532
|
+
"histidine_selections": protonation_report["xtb_histidines"],
|
|
533
|
+
}
|
|
534
|
+
if refinement_report is not None:
|
|
535
|
+
lock_data["resolved"]["refinement"] = refinement_report
|
|
536
|
+
if ligand_report is not None:
|
|
537
|
+
lock_data["resolved"]["ligands"] = [
|
|
538
|
+
{
|
|
539
|
+
"id": ligand["ligand_id"],
|
|
540
|
+
"selector": ligand["selector"],
|
|
541
|
+
"charge_method": ligand["charge_method"],
|
|
542
|
+
"atom_types": ligand["atom_types"],
|
|
543
|
+
"final_mol2_path": ligand["final_mol2_path"],
|
|
544
|
+
"final_frcmod_path": ligand["final_frcmod_path"],
|
|
545
|
+
"antechamber": ligand["antechamber"],
|
|
546
|
+
"parmchk2": ligand["parmchk2"],
|
|
547
|
+
"provisional_mol2_path": ligand.get("provisional_mol2_path"),
|
|
548
|
+
"qm": ligand.get("qm"),
|
|
549
|
+
}
|
|
550
|
+
for ligand in ligand_report["ligands"]
|
|
551
|
+
]
|
|
552
|
+
lock_data["resolved"]["qmmesp_provisional_ligands"] = ligand_report.get(
|
|
553
|
+
"qmmesp_provisional_ligands", []
|
|
554
|
+
)
|
|
555
|
+
lock_data["resolved"]["qmmesp_provisional_system"] = ligand_report.get(
|
|
556
|
+
"qmmesp_provisional_system"
|
|
557
|
+
)
|
|
558
|
+
if metal_report is not None:
|
|
559
|
+
lock_data["resolved"]["metals"] = metal_report
|
|
560
|
+
if tleap_report is not None:
|
|
561
|
+
lock_data["resolved"]["tleap"] = {
|
|
562
|
+
"force_fields_sourced": tleap_report["force_fields_sourced"],
|
|
563
|
+
"water_box": tleap_report["water_box"],
|
|
564
|
+
"ligands": tleap_report["ligands"],
|
|
565
|
+
"mcpb_resp_ligands": tleap_report.get("mcpb_resp_ligands", []),
|
|
566
|
+
"disulfide_bond_commands": tleap_report["disulfide_bond_commands"],
|
|
567
|
+
"solvation_enabled": tleap_report["solvation_enabled"],
|
|
568
|
+
"solvation_box_type": tleap_report["solvation_box_type"],
|
|
569
|
+
"buffer_angstrom": tleap_report["buffer_angstrom"],
|
|
570
|
+
"neutralization_requested": tleap_report["neutralization_requested"],
|
|
571
|
+
"neutralizing_ions_added": tleap_report["neutralizing_ions_added"],
|
|
572
|
+
"salt_concentration_molar": tleap_report["salt_concentration_molar"],
|
|
573
|
+
"salt_ion_pairs_requested": tleap_report["salt_ion_pairs_requested"],
|
|
574
|
+
"c4_postprocessing": tleap_report.get("c4_postprocessing", []),
|
|
575
|
+
"final_outputs": tleap_report["final_outputs"],
|
|
576
|
+
}
|
|
577
|
+
if validation_report is not None:
|
|
578
|
+
lock_data["resolved"]["validation"] = {
|
|
579
|
+
"final_prmtop_path": validation_report["final_prmtop_path"],
|
|
580
|
+
"final_inpcrd_path": validation_report["final_inpcrd_path"],
|
|
581
|
+
"final_pdb_path": validation_report["final_pdb_path"],
|
|
582
|
+
"warnings": validation_report["warnings"],
|
|
583
|
+
"errors": validation_report["errors"],
|
|
584
|
+
}
|
|
585
|
+
path.write_text(yaml.safe_dump(lock_data, sort_keys=False), encoding="utf-8")
|
|
586
|
+
|
|
587
|
+
|
|
588
|
+
def _write_versions(
|
|
589
|
+
path: Path,
|
|
590
|
+
*,
|
|
591
|
+
external_executables: dict[str, str] | None = None,
|
|
592
|
+
include_optional_python_packages: bool = False,
|
|
593
|
+
) -> None:
|
|
594
|
+
versions = {
|
|
595
|
+
"mdprep": __version__,
|
|
596
|
+
"python": sys.version,
|
|
597
|
+
"platform": platform.platform(),
|
|
598
|
+
"external_tools_required": [],
|
|
599
|
+
"external_versions": {
|
|
600
|
+
name: _external_version(executable)
|
|
601
|
+
for name, executable in (external_executables or {}).items()
|
|
602
|
+
},
|
|
603
|
+
}
|
|
604
|
+
if include_optional_python_packages:
|
|
605
|
+
versions["python_packages"] = {
|
|
606
|
+
"parmed": parmed_version(),
|
|
607
|
+
"openmm": openmm_version(),
|
|
608
|
+
"pyscf": pyscf_version(),
|
|
609
|
+
}
|
|
610
|
+
path.write_text(json.dumps(versions, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
|
611
|
+
|
|
612
|
+
|
|
613
|
+
def _external_executables_from_protonation_report(
|
|
614
|
+
protonation_report: dict[str, object],
|
|
615
|
+
) -> dict[str, str]:
|
|
616
|
+
executables: dict[str, str] = {}
|
|
617
|
+
propka = protonation_report.get("propka")
|
|
618
|
+
if isinstance(propka, dict) and isinstance(propka.get("executable"), str):
|
|
619
|
+
executables["propka"] = propka["executable"]
|
|
620
|
+
xtb_histidines = protonation_report.get("xtb_histidines")
|
|
621
|
+
if isinstance(xtb_histidines, list) and xtb_histidines:
|
|
622
|
+
first = xtb_histidines[0]
|
|
623
|
+
if isinstance(first, dict) and isinstance(first.get("executable"), str):
|
|
624
|
+
executables["xtb"] = first["executable"]
|
|
625
|
+
return executables
|
|
626
|
+
|
|
627
|
+
|
|
628
|
+
def _external_executables_from_refinement_report(
|
|
629
|
+
refinement_report: dict[str, object],
|
|
630
|
+
) -> dict[str, str]:
|
|
631
|
+
executables: dict[str, str] = {}
|
|
632
|
+
ash = refinement_report.get("ash")
|
|
633
|
+
if isinstance(ash, dict):
|
|
634
|
+
python_executable = ash.get("python_executable")
|
|
635
|
+
xtb_executable = ash.get("xtb_executable")
|
|
636
|
+
if isinstance(python_executable, str):
|
|
637
|
+
executables["ash_python"] = python_executable
|
|
638
|
+
if isinstance(xtb_executable, str):
|
|
639
|
+
executables["xtb"] = xtb_executable
|
|
640
|
+
provisional_ligands = refinement_report.get("provisional_ligands")
|
|
641
|
+
if isinstance(provisional_ligands, list):
|
|
642
|
+
ligand_shape = {
|
|
643
|
+
"ligands": provisional_ligands,
|
|
644
|
+
"qmmesp_provisional_ligands": [],
|
|
645
|
+
"qmmesp_provisional_system": refinement_report.get("provisional_system"),
|
|
646
|
+
}
|
|
647
|
+
executables.update(_external_executables_from_ligand_report(ligand_shape))
|
|
648
|
+
return executables
|
|
649
|
+
|
|
650
|
+
|
|
651
|
+
def _external_executables_from_ligand_report(
|
|
652
|
+
ligand_report: dict[str, object],
|
|
653
|
+
) -> dict[str, str]:
|
|
654
|
+
executables: dict[str, str] = {}
|
|
655
|
+
ligand_groups = [ligand_report.get("ligands"), ligand_report.get("qmmesp_provisional_ligands")]
|
|
656
|
+
for ligands in ligand_groups:
|
|
657
|
+
if not isinstance(ligands, list):
|
|
658
|
+
continue
|
|
659
|
+
for ligand in ligands:
|
|
660
|
+
if not isinstance(ligand, dict):
|
|
661
|
+
continue
|
|
662
|
+
antechamber = ligand.get("antechamber")
|
|
663
|
+
if isinstance(antechamber, dict) and isinstance(antechamber.get("command"), list):
|
|
664
|
+
command = antechamber["command"]
|
|
665
|
+
if command:
|
|
666
|
+
executables["antechamber"] = str(command[0])
|
|
667
|
+
parmchk2 = ligand.get("parmchk2")
|
|
668
|
+
if isinstance(parmchk2, dict) and isinstance(parmchk2.get("command"), list):
|
|
669
|
+
command = parmchk2["command"]
|
|
670
|
+
if command:
|
|
671
|
+
executables["parmchk2"] = str(command[0])
|
|
672
|
+
qm = ligand.get("qm")
|
|
673
|
+
fit_result = qm.get("fit_result") if isinstance(qm, dict) else None
|
|
674
|
+
commands = fit_result.get("commands") if isinstance(fit_result, dict) else None
|
|
675
|
+
if isinstance(commands, list):
|
|
676
|
+
for record in commands:
|
|
677
|
+
if not isinstance(record, dict) or not isinstance(record.get("command"), list):
|
|
678
|
+
continue
|
|
679
|
+
command = record["command"]
|
|
680
|
+
if command:
|
|
681
|
+
executables[Path(str(command[0])).name] = str(command[0])
|
|
682
|
+
provisional_system = ligand_report.get("qmmesp_provisional_system")
|
|
683
|
+
if isinstance(provisional_system, dict):
|
|
684
|
+
tleap = provisional_system.get("tleap")
|
|
685
|
+
if isinstance(tleap, dict) and isinstance(tleap.get("command"), list):
|
|
686
|
+
command = tleap["command"]
|
|
687
|
+
if command:
|
|
688
|
+
executables["tleap"] = str(command[0])
|
|
689
|
+
return executables
|
|
690
|
+
|
|
691
|
+
|
|
692
|
+
def _external_executables_from_tleap_report(
|
|
693
|
+
tleap_report: dict[str, object],
|
|
694
|
+
) -> dict[str, str]:
|
|
695
|
+
executables: dict[str, str] = {}
|
|
696
|
+
dry = tleap_report.get("dry_tleap")
|
|
697
|
+
if isinstance(dry, dict) and isinstance(dry.get("command"), list):
|
|
698
|
+
command = dry["command"]
|
|
699
|
+
if command:
|
|
700
|
+
executables["tleap"] = str(command[0])
|
|
701
|
+
c4_runs = tleap_report.get("c4_postprocessing")
|
|
702
|
+
if isinstance(c4_runs, list):
|
|
703
|
+
for run in c4_runs:
|
|
704
|
+
if not isinstance(run, dict) or not isinstance(run.get("command"), list):
|
|
705
|
+
continue
|
|
706
|
+
command = run["command"]
|
|
707
|
+
if command:
|
|
708
|
+
executables["parmed"] = str(command[0])
|
|
709
|
+
return executables
|
|
710
|
+
|
|
711
|
+
|
|
712
|
+
def _external_executables_from_metal_report(
|
|
713
|
+
metal_report: dict[str, object],
|
|
714
|
+
) -> dict[str, str]:
|
|
715
|
+
executables: dict[str, str] = {}
|
|
716
|
+
bonded = metal_report.get("bonded_mcpb_site")
|
|
717
|
+
if isinstance(bonded, dict):
|
|
718
|
+
runs = bonded.get("runs")
|
|
719
|
+
if isinstance(runs, list):
|
|
720
|
+
for run in runs:
|
|
721
|
+
if not isinstance(run, dict) or not isinstance(run.get("command"), list):
|
|
722
|
+
continue
|
|
723
|
+
command = run["command"]
|
|
724
|
+
if command:
|
|
725
|
+
executables["MCPB.py"] = str(command[0])
|
|
726
|
+
qm_result = bonded.get("pyscf")
|
|
727
|
+
if isinstance(qm_result, dict):
|
|
728
|
+
backend = qm_result.get("small_model_hessian_backend")
|
|
729
|
+
small_model = qm_result.get("small_model")
|
|
730
|
+
if isinstance(small_model, dict):
|
|
731
|
+
external_commands = small_model.get("external_commands")
|
|
732
|
+
if isinstance(external_commands, list):
|
|
733
|
+
for record in external_commands:
|
|
734
|
+
if not isinstance(record, dict):
|
|
735
|
+
continue
|
|
736
|
+
command = record.get("command")
|
|
737
|
+
if not isinstance(command, list) or not command:
|
|
738
|
+
continue
|
|
739
|
+
if backend in {"gfn2_xtb", "gxtb"}:
|
|
740
|
+
executables[str(backend)] = str(command[0])
|
|
741
|
+
return executables
|
|
742
|
+
|
|
743
|
+
|
|
744
|
+
def _external_version(executable: str) -> dict[str, object]:
|
|
745
|
+
try:
|
|
746
|
+
result = run_command([executable, "--version"], timeout=5.0)
|
|
747
|
+
except (OSError, subprocess.TimeoutExpired, ValueError):
|
|
748
|
+
return {"executable": executable, "version": "unknown"}
|
|
749
|
+
text = (result.stdout or result.stderr).strip()
|
|
750
|
+
version = _version_line(text)
|
|
751
|
+
return {
|
|
752
|
+
"executable": executable,
|
|
753
|
+
"version": version,
|
|
754
|
+
"returncode": result.returncode,
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
|
|
758
|
+
def _version_line(text: str) -> str:
|
|
759
|
+
lines = [line.strip() for line in text.splitlines() if line.strip()]
|
|
760
|
+
for line in lines:
|
|
761
|
+
if "version" in line.lower():
|
|
762
|
+
return line
|
|
763
|
+
return lines[0] if lines else "unknown"
|