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,242 @@
1
+ """PySCF-based ligand RESP/QMMESP charge derivation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import csv
6
+ import json
7
+ from dataclasses import dataclass
8
+ from pathlib import Path
9
+
10
+ import numpy as np
11
+
12
+ from mdprep.ambertools.mol2 import write_mol2_with_charges
13
+ from mdprep.ambertools.resp import AmberRespError, AmberRespFitResult, run_amber_resp_fit
14
+ from mdprep.charges.esp_grid import EspGridError, generate_merz_kollman_grid, write_grid_xyz
15
+ from mdprep.config.models import LigandConfig
16
+ from mdprep.ligands.extract import ExtractedLigand
17
+ from mdprep.qm.point_charges import PointChargeSelection, write_point_charge_files
18
+ from mdprep.qm.pyscf_esp import BOHR_PER_ANGSTROM, PySCFEspError, evaluate_ligand_esp, write_esp_values
19
+ from mdprep.qm.pyscf_runner import PySCFResult, PySCFRunnerError, run_pyscf_scf
20
+ from mdprep.structure.models import AtomRecord
21
+
22
+
23
+ class LigandPySCFChargeError(ValueError):
24
+ """Raised when PySCF ligand charge derivation fails."""
25
+
26
+
27
+ QMMESP_CONFIRMATION = (
28
+ "The target-ligand PySCF single point was run as electrostatically embedded QM/MM "
29
+ "using pyscf.qmmm.mm_charge. Fixed MM point charges from the provisional Amber "
30
+ "system polarized the target-ligand QM density. The RESP fitting target contains "
31
+ "only the polarized target-ligand QM electrostatic potential; environment point "
32
+ "charges were not fitted and were not written to the ligand mol2."
33
+ )
34
+
35
+
36
+ GAS_RESP_CONFIRMATION = "Gas-phase ligand ESP fit; no MM point charges were used."
37
+
38
+
39
+ @dataclass(frozen=True)
40
+ class LigandPySCFChargeResult:
41
+ method: str
42
+ qm_dir: Path
43
+ charged_mol2_path: Path
44
+ fitted_charges_csv_path: Path
45
+ fit_report_path: Path
46
+ pyscf_result: dict[str, object]
47
+ fit_result: dict[str, object]
48
+ grid_point_count: int
49
+ embedding_summary: dict[str, object] | None
50
+ warnings: list[str]
51
+
52
+ def to_dict(self) -> dict[str, object]:
53
+ return {
54
+ "method": self.method,
55
+ "qm_dir": str(self.qm_dir),
56
+ "charged_mol2_path": str(self.charged_mol2_path),
57
+ "fitted_charges_csv_path": str(self.fitted_charges_csv_path),
58
+ "fit_report_path": str(self.fit_report_path),
59
+ "pyscf_result": self.pyscf_result,
60
+ "fit_result": self.fit_result,
61
+ "grid_point_count": self.grid_point_count,
62
+ "embedding_summary": self.embedding_summary,
63
+ "warnings": self.warnings,
64
+ }
65
+
66
+
67
+ def derive_pyscf_charges(
68
+ *,
69
+ extracted: ExtractedLigand,
70
+ provisional_mol2_path: str | Path,
71
+ output_mol2_path: str | Path,
72
+ output_dir: str | Path,
73
+ method_name: str,
74
+ point_charges: PointChargeSelection | None = None,
75
+ ) -> LigandPySCFChargeResult:
76
+ ligand = extracted.config
77
+ config = ligand.qmmesp
78
+ if config is None:
79
+ raise LigandPySCFChargeError(f"Ligand {ligand.id} requires a qmmesp block for {method_name}.")
80
+ if method_name == "qmmesp_pyscf" and point_charges is None:
81
+ raise LigandPySCFChargeError("qmmesp_pyscf requires an explicit provisional-system MM environment selection.")
82
+ if method_name == "qmmesp_pyscf" and point_charges is not None and point_charges.total_after_cutoff == 0:
83
+ raise LigandPySCFChargeError(
84
+ "qmmesp_pyscf selected zero MM point charges; refusing to run a gas-phase SCF "
85
+ "under a QMMESP label. Expand the environment selection or embedding cutoff."
86
+ )
87
+ if method_name == "gas_resp_pyscf" and point_charges is not None:
88
+ raise LigandPySCFChargeError("gas_resp_pyscf cannot accept MM embedding point charges.")
89
+ qm_dir = Path(output_dir) / "ligands" / ligand.id / "qm" / method_name
90
+ qm_dir.mkdir(parents=True, exist_ok=True)
91
+ atoms = extracted.atoms
92
+ elements = [_atom_element(atom) for atom in atoms]
93
+ coords = np.asarray([[atom.x, atom.y, atom.z] for atom in atoms], dtype=float)
94
+ scf_charge = ligand.net_charge if config.scf_charge is None else config.scf_charge
95
+ scf_spin = ligand.multiplicity - 1 if config.scf_spin is None else config.scf_spin
96
+ embedded_qmmm = point_charges is not None
97
+ point_charge_count = 0 if point_charges is None else point_charges.total_after_cutoff
98
+ pyscf_input = {
99
+ "ligand_id": ligand.id,
100
+ "elements": elements,
101
+ "coordinates_angstrom": coords.tolist(),
102
+ "charge": scf_charge,
103
+ "spin": scf_spin,
104
+ "multiplicity": scf_spin + 1,
105
+ "method": config.method,
106
+ "basis": config.basis,
107
+ "max_cycle": config.max_cycle,
108
+ "conv_tol": config.conv_tol,
109
+ "num_threads": config.num_threads,
110
+ "max_memory_mb": config.max_memory_mb,
111
+ "checkpoint_path": str(qm_dir / "pyscf.chk"),
112
+ "calculation_type": (
113
+ "electrostatically_embedded_qm_mm_single_point"
114
+ if embedded_qmmm
115
+ else "gas_phase_qm_single_point"
116
+ ),
117
+ "qm_region": "target_ligand_only",
118
+ "qm_atom_count": len(atoms),
119
+ "mm_region": "selected_non_target_atoms_from_provisional_amber_system" if embedded_qmmm else None,
120
+ "mm_point_charge_count": point_charge_count,
121
+ "electrostatic_embedding": embedded_qmmm,
122
+ "embedding_operator": "pyscf.qmmm.mm_charge" if embedded_qmmm else None,
123
+ "mm_point_charge_potential_in_scf_hamiltonian": embedded_qmmm,
124
+ "mm_bonded_and_lennard_jones_terms_in_scf": False,
125
+ "direct_mm_potential_in_resp_target": False,
126
+ "esp_grid": config.grid.model_dump(mode="json"),
127
+ "resp_fitting": config.resp_fitting.model_dump(mode="json"),
128
+ }
129
+ (qm_dir / "pyscf_input.json").write_text(json.dumps(pyscf_input, indent=2, sort_keys=True) + "\n", encoding="utf-8")
130
+ if point_charges is not None:
131
+ write_point_charge_files(
132
+ point_charges,
133
+ csv_path=qm_dir / "mm_point_charges.csv",
134
+ xyz_path=qm_dir / "mm_point_charges.xyz",
135
+ summary_path=qm_dir / "embedding_summary.json",
136
+ )
137
+ try:
138
+ grid = generate_merz_kollman_grid(
139
+ elements=elements,
140
+ coordinates=coords,
141
+ vdw_scale_factors=config.grid.vdw_scale_factors,
142
+ point_density_per_square_angstrom=config.grid.point_density_per_square_angstrom,
143
+ exclude_inside_vdw_scale=config.grid.exclude_inside_vdw_scale,
144
+ max_points=config.grid.max_points,
145
+ )
146
+ mm_charges = None if point_charges is None else point_charges.charge_array
147
+ mm_coords = None if point_charges is None else point_charges.coordinate_array
148
+ pyscf_result = run_pyscf_scf(
149
+ elements=elements,
150
+ coordinates=coords,
151
+ charge=scf_charge,
152
+ spin=scf_spin,
153
+ method=config.method,
154
+ basis=config.basis,
155
+ max_cycle=config.max_cycle,
156
+ conv_tol=config.conv_tol,
157
+ mm_charges=mm_charges,
158
+ mm_coordinates=mm_coords,
159
+ work_dir=qm_dir,
160
+ checkpoint_path=qm_dir / "pyscf.chk",
161
+ num_threads=config.num_threads,
162
+ max_memory_mb=config.max_memory_mb,
163
+ )
164
+ (qm_dir / "pyscf_stdout.txt").write_text(pyscf_result.stdout, encoding="utf-8")
165
+ (qm_dir / "pyscf_stderr.txt").write_text(pyscf_result.stderr, encoding="utf-8")
166
+ (qm_dir / "pyscf_result.json").write_text(
167
+ json.dumps(pyscf_result.to_dict(), indent=2, sort_keys=True) + "\n",
168
+ encoding="utf-8",
169
+ )
170
+ esp = evaluate_ligand_esp(
171
+ mol=pyscf_result.mol,
172
+ mf=pyscf_result.mf,
173
+ grid_coordinates_angstrom=grid.points,
174
+ )
175
+ write_grid_xyz(grid, qm_dir / "esp_grid.xyz")
176
+ write_esp_values(esp, str(qm_dir / "esp_values.dat"))
177
+ fit = run_amber_resp_fit(
178
+ provisional_mol2_path=provisional_mol2_path,
179
+ atom_coordinates_bohr=coords * BOHR_PER_ANGSTROM,
180
+ grid_coordinates_bohr=grid.points * BOHR_PER_ANGSTROM,
181
+ esp_values_au=esp,
182
+ total_charge=ligand.net_charge,
183
+ multiplicity=ligand.multiplicity,
184
+ atom_types=ligand.atom_types,
185
+ work_dir=qm_dir / "resp",
186
+ )
187
+ except (EspGridError, PySCFRunnerError, PySCFEspError, AmberRespError) as exc:
188
+ raise LigandPySCFChargeError(str(exc)) from exc
189
+
190
+ charged_mol2 = Path(output_mol2_path)
191
+ write_mol2_with_charges(provisional_mol2_path, [float(charge) for charge in fit.charges], charged_mol2)
192
+ charges_csv = qm_dir / "fitted_charges.csv"
193
+ _write_fitted_charges(charges_csv, atoms, fit)
194
+ fit_report = {
195
+ **fit.to_dict(),
196
+ "grid_point_count": int(len(grid.points)),
197
+ "fitted_charge_center_count": len(atoms),
198
+ "fitted_charge_centers": [atom.name for atom in atoms],
199
+ "calculation_type": pyscf_input["calculation_type"],
200
+ "electrostatic_embedding_applied": embedded_qmmm,
201
+ "embedding_operator": pyscf_input["embedding_operator"],
202
+ "mm_point_charge_count": point_charge_count,
203
+ "mm_point_charge_potential_in_scf_hamiltonian": embedded_qmmm,
204
+ "mm_bonded_and_lennard_jones_terms_in_scf": False,
205
+ "external_mm_potential_included_in_fit": False,
206
+ "esp_grid": config.grid.model_dump(mode="json"),
207
+ "confirmation": QMMESP_CONFIRMATION if point_charges is not None else GAS_RESP_CONFIRMATION,
208
+ }
209
+ fit_report_path = qm_dir / "fit_report.json"
210
+ fit_report_path.write_text(json.dumps(fit_report, indent=2, sort_keys=True) + "\n", encoding="utf-8")
211
+ embedding_summary = None
212
+ if point_charges is not None:
213
+ embedding_summary = point_charges.to_dict()
214
+ return LigandPySCFChargeResult(
215
+ method=method_name,
216
+ qm_dir=qm_dir,
217
+ charged_mol2_path=charged_mol2,
218
+ fitted_charges_csv_path=charges_csv,
219
+ fit_report_path=fit_report_path,
220
+ pyscf_result=pyscf_result.to_dict(),
221
+ fit_result=fit_report,
222
+ grid_point_count=int(len(grid.points)),
223
+ embedding_summary=embedding_summary,
224
+ warnings=list(fit.warnings) + list(pyscf_result.warnings),
225
+ )
226
+
227
+
228
+ def _atom_element(atom: AtomRecord) -> str:
229
+ if atom.element:
230
+ return atom.element
231
+ stripped = atom.name.strip()
232
+ while stripped and stripped[0].isdigit():
233
+ stripped = stripped[1:]
234
+ return stripped[:1].upper()
235
+
236
+
237
+ def _write_fitted_charges(path: Path, atoms: list[AtomRecord], fit: AmberRespFitResult) -> None:
238
+ with path.open("w", encoding="utf-8", newline="") as handle:
239
+ writer = csv.DictWriter(handle, fieldnames=["atom_index", "atom_name", "charge"])
240
+ writer.writeheader()
241
+ for index, (atom, charge) in enumerate(zip(atoms, fit.charges, strict=True), start=1):
242
+ writer.writerow({"atom_index": index, "atom_name": atom.name, "charge": float(charge)})
@@ -0,0 +1,146 @@
1
+ """Ligand-stage report writers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import csv
6
+ import json
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ from mdprep.ligands.workflow import LigandStageResult, LigandWorkflowItem
11
+
12
+
13
+ CSV_COLUMNS = [
14
+ "ligand_id",
15
+ "chain",
16
+ "resname",
17
+ "resid",
18
+ "icode",
19
+ "atom_count",
20
+ "charge_method",
21
+ "atom_types",
22
+ "net_charge",
23
+ "multiplicity",
24
+ "final_mol2",
25
+ "final_frcmod",
26
+ "charge_sum_final",
27
+ "coordinate_max_deviation",
28
+ "status",
29
+ ]
30
+
31
+
32
+ def write_ligand_reports(
33
+ result: LigandStageResult,
34
+ *,
35
+ json_path: str | Path,
36
+ csv_path: str | Path,
37
+ markdown_path: str | Path,
38
+ ) -> dict[str, Any]:
39
+ report = result.to_report_dict()
40
+ json_output = Path(json_path)
41
+ csv_output = Path(csv_path)
42
+ markdown_output = Path(markdown_path)
43
+ json_output.parent.mkdir(parents=True, exist_ok=True)
44
+ json_output.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n", encoding="utf-8")
45
+ _write_csv(result.ligands, csv_output)
46
+ markdown_output.write_text(_render_markdown(report), encoding="utf-8")
47
+ return report
48
+
49
+
50
+ def _write_csv(items: list[LigandWorkflowItem], path: Path) -> None:
51
+ path.parent.mkdir(parents=True, exist_ok=True)
52
+ with path.open("w", encoding="utf-8", newline="") as handle:
53
+ writer = csv.DictWriter(handle, fieldnames=CSV_COLUMNS)
54
+ writer.writeheader()
55
+ for item in items:
56
+ data = item.to_dict()
57
+ residue = data["residue_identity"]
58
+ validation = data.get("validation") or {}
59
+ writer.writerow(
60
+ {
61
+ "ligand_id": data["ligand_id"],
62
+ "chain": residue["chain_id"],
63
+ "resname": residue["resname"],
64
+ "resid": residue["resid"],
65
+ "icode": residue["icode"],
66
+ "atom_count": data["atom_count"],
67
+ "charge_method": data["charge_method"],
68
+ "atom_types": data["atom_types"],
69
+ "net_charge": data["net_charge"],
70
+ "multiplicity": data["multiplicity"],
71
+ "final_mol2": data["final_mol2_path"],
72
+ "final_frcmod": data["final_frcmod_path"],
73
+ "charge_sum_final": validation.get("charge_sum_final"),
74
+ "coordinate_max_deviation": validation.get("coordinate_max_deviation"),
75
+ "status": data["status"],
76
+ }
77
+ )
78
+
79
+
80
+ def _render_markdown(report: dict[str, Any]) -> str:
81
+ lines = ["# Ligand Report", ""]
82
+ provisional_system = report.get("qmmesp_provisional_system")
83
+ if provisional_system:
84
+ lines.extend(
85
+ [
86
+ "## QMMESP provisional system",
87
+ "",
88
+ "- Provisional charge model: AM1-BCC for every configured ligand/cofactor",
89
+ f"- prmtop: `{provisional_system['prmtop_path']}`",
90
+ f"- inpcrd: `{provisional_system['inpcrd_path']}`",
91
+ f"- tleap input: `{provisional_system['script_path']}`",
92
+ f"- tleap log: `{provisional_system['tleap']['log_path']}`",
93
+ "",
94
+ "### Provisional ligands",
95
+ "",
96
+ ]
97
+ )
98
+ for provisional in report.get("qmmesp_provisional_ligands", []):
99
+ lines.append(
100
+ f"- `{provisional['ligand_id']}`: `{provisional['final_mol2_path']}` "
101
+ f"(charge method `{provisional['charge_method']}`)"
102
+ )
103
+ lines.append("")
104
+ for item in report["ligands"]:
105
+ lines.extend(
106
+ [
107
+ f"## {item['ligand_id']}",
108
+ "",
109
+ f"- Status: `{item['status']}`",
110
+ f"- Charge method: `{item['charge_method']}`",
111
+ f"- Atom types: `{item['atom_types']}`",
112
+ f"- Net charge: {item['net_charge']}",
113
+ f"- Extracted PDB: `{item['extracted_pdb_path']}`",
114
+ f"- Final mol2: `{item['final_mol2_path']}`",
115
+ f"- Final frcmod: `{item['final_frcmod_path']}`",
116
+ ]
117
+ )
118
+ if item.get("antechamber"):
119
+ lines.append(f"- antechamber command: `{' '.join(item['antechamber']['command'])}`")
120
+ if item.get("parmchk2"):
121
+ lines.append(f"- parmchk2 command: `{' '.join(item['parmchk2']['command'])}`")
122
+ if item.get("qm"):
123
+ qm = item["qm"]
124
+ lines.extend(
125
+ [
126
+ "- PySCF charge derivation:",
127
+ f" - Method: `{qm['method']}`",
128
+ f" - Output directory: `{qm['qm_dir']}`",
129
+ f" - Grid points: {qm['grid_point_count']}",
130
+ f" - Fitted charge sum: {qm['fit_result']['charge_sum_final']}",
131
+ ]
132
+ )
133
+ if qm["fit_result"].get("calculation_type"):
134
+ lines.append(f" - Calculation type: `{qm['fit_result']['calculation_type']}`")
135
+ if qm["fit_result"].get("embedding_operator"):
136
+ lines.append(f" - Electrostatic embedding: `{qm['fit_result']['embedding_operator']}`")
137
+ if qm["fit_result"].get("confirmation"):
138
+ lines.append(f" - Interpretation: {qm['fit_result']['confirmation']}")
139
+ if qm.get("embedding_summary"):
140
+ embedding = qm["embedding_summary"]
141
+ lines.append(f" - MM point charges: {embedding['point_charge_count_after_cutoff']}")
142
+ lines.append(f" - Target atom count: {embedding['target_atom_count']}")
143
+ warnings = item.get("warnings") or []
144
+ lines.append("- Warnings: " + ("; ".join(warnings) if warnings else "None"))
145
+ lines.append("")
146
+ return "\n".join(lines)