quantui 0.5.1__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.
- quantui/__init__.py +311 -0
- quantui/analytics.py +609 -0
- quantui/app.py +5650 -0
- quantui/app_analysis.py +662 -0
- quantui/app_builders.py +2465 -0
- quantui/app_exports.py +194 -0
- quantui/app_formatters.py +493 -0
- quantui/app_history.py +624 -0
- quantui/app_runflow.py +1544 -0
- quantui/app_visualization.py +2620 -0
- quantui/ase_bridge.py +236 -0
- quantui/benchmarks.py +1543 -0
- quantui/c_stderr.py +124 -0
- quantui/cactus.py +88 -0
- quantui/calc_log.py +1116 -0
- quantui/calculator.py +204 -0
- quantui/cancellation.py +88 -0
- quantui/cli.py +288 -0
- quantui/comparison.py +306 -0
- quantui/config.py +725 -0
- quantui/data/js/3Dmol-min.js +2 -0
- quantui/data/js/3Dmol-min.js.LICENSE.txt +5 -0
- quantui/data/library/library.sqlite +0 -0
- quantui/data/manifests/bulk_qm9.json +1 -0
- quantui/data/manifests/curated.json +15482 -0
- quantui/data/manifests/presets.json +816 -0
- quantui/descriptor_cards.py +186 -0
- quantui/freq_calc.py +712 -0
- quantui/freq_ir_workers.py +229 -0
- quantui/gpu_offload.py +278 -0
- quantui/help_content.py +474 -0
- quantui/ir_plot.py +130 -0
- quantui/issue_tracker.py +170 -0
- quantui/live_log.py +387 -0
- quantui/log_utils.py +492 -0
- quantui/molecule.py +577 -0
- quantui/molecule_library.py +433 -0
- quantui/nmr_calc.py +437 -0
- quantui/optimizer.py +670 -0
- quantui/orbital_visualization.py +1102 -0
- quantui/pes_scan.py +420 -0
- quantui/preopt.py +355 -0
- quantui/progress.py +111 -0
- quantui/pubchem.py +1157 -0
- quantui/reorganization_energy.py +435 -0
- quantui/results_storage.py +902 -0
- quantui/security.py +14 -0
- quantui/session_calc.py +622 -0
- quantui/structure_providers.py +277 -0
- quantui/tddft_calc.py +307 -0
- quantui/user_settings.py +238 -0
- quantui/utils.py +287 -0
- quantui/vib_cache.py +247 -0
- quantui/visualization_py3dmol.py +593 -0
- quantui/viz_assets.py +101 -0
- quantui/viz_backend_router.py +243 -0
- quantui-0.5.1.dist-info/METADATA +533 -0
- quantui-0.5.1.dist-info/RECORD +62 -0
- quantui-0.5.1.dist-info/WHEEL +5 -0
- quantui-0.5.1.dist-info/entry_points.txt +2 -0
- quantui-0.5.1.dist-info/licenses/LICENSE +21 -0
- quantui-0.5.1.dist-info/top_level.txt +1 -0
quantui/log_utils.py
ADDED
|
@@ -0,0 +1,492 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Structured log header/footer for QuantUI calculation output.
|
|
3
|
+
|
|
4
|
+
Collects machine metadata (CPU, RAM, GPU, OMP threads), formats a banner
|
|
5
|
+
header written before each calculation, and a summary footer written after
|
|
6
|
+
with wall/CPU timing, convergence status, key energies, and a warnings digest.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
import platform
|
|
13
|
+
import socket
|
|
14
|
+
import subprocess
|
|
15
|
+
import uuid
|
|
16
|
+
from datetime import datetime, timezone
|
|
17
|
+
from functools import lru_cache
|
|
18
|
+
from typing import Any, Dict, Optional
|
|
19
|
+
|
|
20
|
+
_WIDTH = 80 # total width of === border lines
|
|
21
|
+
_SEP = "=" * _WIDTH
|
|
22
|
+
_SUB = "-" * _WIDTH # lighter divider between header sections
|
|
23
|
+
|
|
24
|
+
# Static list of formats the app can export a result to (EXPORT milestone).
|
|
25
|
+
_EXPORT_FORMATS = "XYZ, MOL/SDF, PDB, Molden, .traj, cube, HTML spectra, .zip"
|
|
26
|
+
|
|
27
|
+
# SCF/correlation methods that are NOT DFT functionals — shown verbatim in the
|
|
28
|
+
# "Method" field with no separate functional. Everything else is treated as a
|
|
29
|
+
# DFT functional and reported as RKS/UKS + the functional name.
|
|
30
|
+
_NON_DFT_METHODS = frozenset({"RHF", "UHF", "MP2", "CCSD", "CCSD(T)"})
|
|
31
|
+
|
|
32
|
+
# ASCII-art wordmark shown at the top of every run banner. Rendered in the
|
|
33
|
+
# monospace stdout stream of the live log, so the alignment holds. Raw string:
|
|
34
|
+
# the art contains backslashes. Trailing whitespace is stripped per line so it
|
|
35
|
+
# doesn't trip the linter (the art is the left edge; right padding is cosmetic).
|
|
36
|
+
_ASCII_LOGO_LINES = [
|
|
37
|
+
r" ___ _ _ _ ___",
|
|
38
|
+
r" / _ \ _ _ __ _ _ __ | |_| | | |_ _|",
|
|
39
|
+
r" | | | | | | |/ _` | '_ \| __| | | || |",
|
|
40
|
+
r" | |_| | |_| | (_| | | | | |_| |_| || |",
|
|
41
|
+
r" \__\_\\__,_|\__,_|_| |_|\__|\___/|___|",
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
# ============================================================================
|
|
46
|
+
# System-info helpers
|
|
47
|
+
# ============================================================================
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _read_proc_cpu() -> str:
|
|
51
|
+
"""Return CPU model name from /proc/cpuinfo (Linux/WSL)."""
|
|
52
|
+
try:
|
|
53
|
+
with open("/proc/cpuinfo") as f:
|
|
54
|
+
for line in f:
|
|
55
|
+
if line.lower().startswith("model name"):
|
|
56
|
+
return line.split(":", 1)[1].strip()
|
|
57
|
+
except OSError:
|
|
58
|
+
pass
|
|
59
|
+
return ""
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _read_proc_ram_gb() -> Optional[float]:
|
|
63
|
+
"""Return total RAM in GiB from /proc/meminfo (Linux/WSL)."""
|
|
64
|
+
try:
|
|
65
|
+
with open("/proc/meminfo") as f:
|
|
66
|
+
for line in f:
|
|
67
|
+
if line.startswith("MemTotal:"):
|
|
68
|
+
kb = int(line.split()[1])
|
|
69
|
+
return kb / (1024**2)
|
|
70
|
+
except (OSError, ValueError):
|
|
71
|
+
pass
|
|
72
|
+
return None
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _psutil_ram_gb() -> Optional[float]:
|
|
76
|
+
try:
|
|
77
|
+
import psutil
|
|
78
|
+
|
|
79
|
+
return float(psutil.virtual_memory().total) / (1024**3)
|
|
80
|
+
except Exception:
|
|
81
|
+
return None
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _detect_gpu() -> Optional[Dict[str, str]]:
|
|
85
|
+
"""Try nvidia-smi first, then cupy. Returns dict or None."""
|
|
86
|
+
try:
|
|
87
|
+
out = subprocess.run(
|
|
88
|
+
[
|
|
89
|
+
"nvidia-smi",
|
|
90
|
+
"--query-gpu=name,memory.total,driver_version",
|
|
91
|
+
"--format=csv,noheader,nounits",
|
|
92
|
+
],
|
|
93
|
+
capture_output=True,
|
|
94
|
+
text=True,
|
|
95
|
+
timeout=5,
|
|
96
|
+
)
|
|
97
|
+
if out.returncode == 0:
|
|
98
|
+
line = out.stdout.strip().splitlines()[0]
|
|
99
|
+
parts = [p.strip() for p in line.split(",")]
|
|
100
|
+
if len(parts) >= 3:
|
|
101
|
+
return {"name": parts[0], "mem_mb": parts[1], "driver": parts[2]}
|
|
102
|
+
elif len(parts) == 2:
|
|
103
|
+
return {"name": parts[0], "mem_mb": parts[1], "driver": ""}
|
|
104
|
+
elif len(parts) == 1:
|
|
105
|
+
return {"name": parts[0], "mem_mb": "", "driver": ""}
|
|
106
|
+
except Exception:
|
|
107
|
+
pass
|
|
108
|
+
|
|
109
|
+
# cupy fallback
|
|
110
|
+
try:
|
|
111
|
+
import cupy
|
|
112
|
+
|
|
113
|
+
n = cupy.cuda.runtime.getDeviceCount()
|
|
114
|
+
if n > 0:
|
|
115
|
+
props = cupy.cuda.runtime.getDeviceProperties(0)
|
|
116
|
+
name = props.get("name", b"Unknown GPU")
|
|
117
|
+
if isinstance(name, bytes):
|
|
118
|
+
name = name.decode()
|
|
119
|
+
total_mem_mb = props.get("totalGlobalMem", 0) // (1024 * 1024)
|
|
120
|
+
return {"name": name, "mem_mb": str(total_mem_mb), "driver": ""}
|
|
121
|
+
except Exception:
|
|
122
|
+
pass
|
|
123
|
+
|
|
124
|
+
return None
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _pyscf_version() -> str:
|
|
128
|
+
"""Return the installed PySCF version, or a clear 'not installed' marker.
|
|
129
|
+
|
|
130
|
+
PySCF is Linux/macOS/WSL only, so this is expected to be absent on the
|
|
131
|
+
Windows UI-dev path (see constraint #1).
|
|
132
|
+
"""
|
|
133
|
+
try:
|
|
134
|
+
import pyscf
|
|
135
|
+
|
|
136
|
+
return str(getattr(pyscf, "__version__", "unknown"))
|
|
137
|
+
except Exception:
|
|
138
|
+
return "not installed"
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def collect_system_info() -> Dict[str, Any]:
|
|
142
|
+
"""Gather CPU, RAM, GPU, thread count, host, and versions. Safe anywhere."""
|
|
143
|
+
cpu_model = (
|
|
144
|
+
_read_proc_cpu() or platform.processor() or platform.machine() or "Unknown CPU"
|
|
145
|
+
)
|
|
146
|
+
cpu_count = os.cpu_count() or 1
|
|
147
|
+
|
|
148
|
+
ram_gb = _read_proc_ram_gb() or _psutil_ram_gb()
|
|
149
|
+
ram_str = f"{ram_gb:.0f} GB" if ram_gb else "Unknown"
|
|
150
|
+
|
|
151
|
+
gpu = _detect_gpu()
|
|
152
|
+
|
|
153
|
+
omp = os.environ.get("OMP_NUM_THREADS", None)
|
|
154
|
+
|
|
155
|
+
try:
|
|
156
|
+
hostname = socket.gethostname() or "unknown"
|
|
157
|
+
except Exception:
|
|
158
|
+
hostname = "unknown"
|
|
159
|
+
|
|
160
|
+
return {
|
|
161
|
+
"cpu_model": cpu_model,
|
|
162
|
+
"cpu_count": cpu_count,
|
|
163
|
+
"ram_str": ram_str,
|
|
164
|
+
"gpu": gpu,
|
|
165
|
+
"omp_threads": omp,
|
|
166
|
+
"hostname": hostname,
|
|
167
|
+
"python": platform.python_version(),
|
|
168
|
+
"pyscf": _pyscf_version(),
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
@lru_cache(maxsize=1)
|
|
173
|
+
def get_system_info() -> Dict[str, Any]:
|
|
174
|
+
"""Lazy-cached version of collect_system_info(). Populated on first call."""
|
|
175
|
+
return collect_system_info()
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
# ============================================================================
|
|
179
|
+
# Live run-status helpers
|
|
180
|
+
# ============================================================================
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def emit_status(stream: Any, message: str) -> None:
|
|
184
|
+
"""Set the live run-status label via a progress stream, if it supports it.
|
|
185
|
+
|
|
186
|
+
The run's ``_LogCapture`` exposes a ``set_status`` method; calc modules
|
|
187
|
+
call this to update the status line during otherwise-silent phases (e.g. an
|
|
188
|
+
optimizer step's SCF running at ``verbose=0``) WITHOUT appending a line to
|
|
189
|
+
the log. No-op for plain streams (``sys.stdout``) — duck-typed, so calc
|
|
190
|
+
modules stay decoupled from the widget layer.
|
|
191
|
+
"""
|
|
192
|
+
setter = getattr(stream, "set_status", None)
|
|
193
|
+
if setter is None:
|
|
194
|
+
return
|
|
195
|
+
try:
|
|
196
|
+
setter(message)
|
|
197
|
+
except Exception: # noqa: BLE001 — status update is best-effort
|
|
198
|
+
pass
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def emit_progress(stream: Any, fraction: float) -> None:
|
|
202
|
+
"""Report a completion fraction (0..1) via a progress stream, if supported.
|
|
203
|
+
|
|
204
|
+
Calc modules that know a real completion fraction (PES scan
|
|
205
|
+
points, optimizer fmax-convergence trend) call this so the live ticker can
|
|
206
|
+
show a *self-correcting* ``elapsed·(1−f)/f`` remaining-time estimate instead
|
|
207
|
+
of the static total. Duck-typed on ``stream.set_progress_fraction`` — a
|
|
208
|
+
no-op for plain streams, keeping calc modules decoupled from the widget layer.
|
|
209
|
+
"""
|
|
210
|
+
setter = getattr(stream, "set_progress_fraction", None)
|
|
211
|
+
if setter is None:
|
|
212
|
+
return
|
|
213
|
+
try:
|
|
214
|
+
setter(fraction)
|
|
215
|
+
except Exception: # noqa: BLE001 — progress is best-effort
|
|
216
|
+
pass
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def format_elapsed(seconds: float) -> str:
|
|
220
|
+
"""Compact elapsed-time label for the live ticker: ``0:42`` / ``1:03:22``."""
|
|
221
|
+
if seconds < 0:
|
|
222
|
+
seconds = 0.0
|
|
223
|
+
s = int(seconds)
|
|
224
|
+
h, rem = divmod(s, 3600)
|
|
225
|
+
m, sec = divmod(rem, 60)
|
|
226
|
+
if h:
|
|
227
|
+
return f"{h}:{m:02d}:{sec:02d}"
|
|
228
|
+
return f"{m}:{sec:02d}"
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
# ============================================================================
|
|
232
|
+
# Duration formatter
|
|
233
|
+
# ============================================================================
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _fmt_duration(seconds: float) -> str:
|
|
237
|
+
"""Format a duration in seconds as HH:MM:SS.t"""
|
|
238
|
+
if seconds < 0:
|
|
239
|
+
seconds = 0.0
|
|
240
|
+
h = int(seconds // 3600)
|
|
241
|
+
m = int((seconds % 3600) // 60)
|
|
242
|
+
s = seconds % 60
|
|
243
|
+
return f"{h:02d}:{m:02d}:{s:04.1f}"
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
# ============================================================================
|
|
247
|
+
# Header
|
|
248
|
+
# ============================================================================
|
|
249
|
+
|
|
250
|
+
_CALC_TYPE_LABELS: Dict[str, str] = {
|
|
251
|
+
"single_point": "Single Point Energy",
|
|
252
|
+
"geometry_opt": "Geometry Optimization",
|
|
253
|
+
"frequency": "Frequency Analysis",
|
|
254
|
+
"tddft": "TD-DFT (UV-Vis)",
|
|
255
|
+
"nmr": "NMR Shielding",
|
|
256
|
+
"pes_scan": "PES Scan",
|
|
257
|
+
"reorganization_energy": "Reorganization Energy (4-point)",
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _split_method_functional(
|
|
262
|
+
method: str, multiplicity: int
|
|
263
|
+
) -> tuple[str, Optional[str]]:
|
|
264
|
+
"""Split the user's method selection into (wf-method, functional).
|
|
265
|
+
|
|
266
|
+
HF / post-HF selections are shown verbatim with no functional. A DFT
|
|
267
|
+
functional is reported as its Kohn-Sham class (RKS closed-shell / UKS
|
|
268
|
+
open-shell) plus the functional name, matching PySCF's auto-dispatch.
|
|
269
|
+
"""
|
|
270
|
+
if method.upper() in _NON_DFT_METHODS:
|
|
271
|
+
return method, None
|
|
272
|
+
return ("RKS" if multiplicity <= 1 else "UKS"), method
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _row(label: str, value: Any) -> str:
|
|
276
|
+
"""One aligned `` Label : value`` header row."""
|
|
277
|
+
return f" {label:<16}: {value}"
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def format_log_header(
|
|
281
|
+
*,
|
|
282
|
+
formula: str,
|
|
283
|
+
method: str,
|
|
284
|
+
basis: str,
|
|
285
|
+
calc_type: str,
|
|
286
|
+
timestamp: Optional[str] = None,
|
|
287
|
+
n_atoms: Optional[int] = None,
|
|
288
|
+
multiplicity: int = 1,
|
|
289
|
+
solvent: Optional[str] = None,
|
|
290
|
+
output_dir: Optional[str] = None,
|
|
291
|
+
calc_id: Optional[str] = None,
|
|
292
|
+
starting_energy: Optional[float] = None,
|
|
293
|
+
) -> str:
|
|
294
|
+
"""Return a formatted header string to prepend to calculation log output.
|
|
295
|
+
|
|
296
|
+
Core args (formula/method/basis/calc_type) are required; the rest add
|
|
297
|
+
run-provenance rows (host, versions, device, solvation, output dir, …) and
|
|
298
|
+
default to sensible placeholders so callers can pass only what they know at
|
|
299
|
+
header-write time.
|
|
300
|
+
"""
|
|
301
|
+
sysinfo = get_system_info()
|
|
302
|
+
|
|
303
|
+
if timestamp is None:
|
|
304
|
+
timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
|
|
305
|
+
if calc_id is None:
|
|
306
|
+
calc_id = uuid.uuid4().hex[:8]
|
|
307
|
+
|
|
308
|
+
ct_label = _CALC_TYPE_LABELS.get(calc_type, calc_type.replace("_", " ").title())
|
|
309
|
+
wf_method, functional = _split_method_functional(method, multiplicity)
|
|
310
|
+
|
|
311
|
+
gpu = sysinfo["gpu"]
|
|
312
|
+
if gpu:
|
|
313
|
+
mem = f" | {gpu['mem_mb']} MB" if gpu.get("mem_mb") else ""
|
|
314
|
+
drv = f" | Driver {gpu['driver']}" if gpu.get("driver") else ""
|
|
315
|
+
device = f"GPU — {gpu['name']}{mem}{drv}"
|
|
316
|
+
else:
|
|
317
|
+
device = "CPU"
|
|
318
|
+
|
|
319
|
+
omp = sysinfo["omp_threads"]
|
|
320
|
+
omp_str = (
|
|
321
|
+
f"OMP_NUM_THREADS={omp}" if omp else f"not set (cores: {sysinfo['cpu_count']})"
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
structure = formula if n_atoms is None else f"{formula} ({n_atoms} atoms)"
|
|
325
|
+
start_e = (
|
|
326
|
+
f"{starting_energy:.8f} Ha"
|
|
327
|
+
if starting_energy is not None
|
|
328
|
+
else "— (available after the first SCF)"
|
|
329
|
+
)
|
|
330
|
+
|
|
331
|
+
lines = [
|
|
332
|
+
"",
|
|
333
|
+
_SEP,
|
|
334
|
+
"",
|
|
335
|
+
*_ASCII_LOGO_LINES,
|
|
336
|
+
"",
|
|
337
|
+
" Quantum Chemistry Interface",
|
|
338
|
+
_SEP,
|
|
339
|
+
_row("Calculation ID", calc_id),
|
|
340
|
+
_row("Timestamp", timestamp),
|
|
341
|
+
_row("Host", sysinfo.get("hostname", "unknown")),
|
|
342
|
+
_row(
|
|
343
|
+
"Python",
|
|
344
|
+
f"{sysinfo.get('python', '?')} | "
|
|
345
|
+
f"PySCF: {sysinfo.get('pyscf', '?')}",
|
|
346
|
+
),
|
|
347
|
+
_row("Device", device),
|
|
348
|
+
_row(
|
|
349
|
+
"Machine",
|
|
350
|
+
f"{sysinfo['cpu_model']} | {sysinfo['cpu_count']} cores "
|
|
351
|
+
f"| RAM: {sysinfo['ram_str']}",
|
|
352
|
+
),
|
|
353
|
+
_row("Threads", omp_str),
|
|
354
|
+
_SUB,
|
|
355
|
+
_row("Method", wf_method),
|
|
356
|
+
_row("Functional", functional or "—"),
|
|
357
|
+
_row("Basis Set", basis),
|
|
358
|
+
_row("Solvation", f"PCM ({solvent})" if solvent else "None"),
|
|
359
|
+
_row("Job Type", ct_label),
|
|
360
|
+
_SUB,
|
|
361
|
+
_row("Input Structure", structure),
|
|
362
|
+
_row("Starting Energy", start_e),
|
|
363
|
+
_SUB,
|
|
364
|
+
_row("Output directory", output_dir or "—"),
|
|
365
|
+
_row("Export formats", _EXPORT_FORMATS),
|
|
366
|
+
_SEP,
|
|
367
|
+
"",
|
|
368
|
+
]
|
|
369
|
+
return "\n".join(lines)
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
# ============================================================================
|
|
373
|
+
# Footer
|
|
374
|
+
# ============================================================================
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def _extract_warnings(log_text: str) -> list[str]:
|
|
378
|
+
"""Return list of unique warning/error lines found in log_text."""
|
|
379
|
+
seen: set[str] = set()
|
|
380
|
+
found = []
|
|
381
|
+
for line in log_text.splitlines():
|
|
382
|
+
stripped = line.strip()
|
|
383
|
+
if not stripped:
|
|
384
|
+
continue
|
|
385
|
+
lower = stripped.lower()
|
|
386
|
+
if any(
|
|
387
|
+
kw in lower
|
|
388
|
+
for kw in ("warn", "error", "failed", "not converge", "imaginary")
|
|
389
|
+
):
|
|
390
|
+
if stripped not in seen:
|
|
391
|
+
seen.add(stripped)
|
|
392
|
+
found.append(stripped)
|
|
393
|
+
return found
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def format_log_footer(
|
|
397
|
+
*,
|
|
398
|
+
result: Any,
|
|
399
|
+
wall_time: float,
|
|
400
|
+
cpu_time: float,
|
|
401
|
+
log_text: str = "",
|
|
402
|
+
success: bool = True,
|
|
403
|
+
) -> str:
|
|
404
|
+
"""Return a formatted footer string to append to calculation log output.
|
|
405
|
+
|
|
406
|
+
Args:
|
|
407
|
+
result: Any result dataclass (SessionResult, OptResult, FreqResult, etc.)
|
|
408
|
+
or None if the calculation failed.
|
|
409
|
+
wall_time: Elapsed wall-clock seconds.
|
|
410
|
+
cpu_time: Elapsed process CPU seconds.
|
|
411
|
+
log_text: The log body text to scan for warnings.
|
|
412
|
+
success: Whether the calculation completed without an exception.
|
|
413
|
+
"""
|
|
414
|
+
from .session_calc import HARTREE_TO_EV # local import — avoids circular deps
|
|
415
|
+
|
|
416
|
+
lines: list[str] = ["", _SEP, " ── Result " + "─" * (_WIDTH - 12)]
|
|
417
|
+
|
|
418
|
+
if result is not None:
|
|
419
|
+
converged = getattr(result, "converged", None)
|
|
420
|
+
n_iter = getattr(result, "n_iterations", None)
|
|
421
|
+
energy = getattr(result, "energy_hartree", None)
|
|
422
|
+
gap_ev = getattr(result, "homo_lumo_gap_ev", None)
|
|
423
|
+
zpve = getattr(result, "zpve_hartree", None)
|
|
424
|
+
n_steps = getattr(result, "n_steps", None) # OptResult
|
|
425
|
+
|
|
426
|
+
# Convergence line
|
|
427
|
+
if converged is not None:
|
|
428
|
+
tick = "✓" if converged else "✗"
|
|
429
|
+
conv_word = "converged" if converged else "did NOT converge"
|
|
430
|
+
iter_str = f" | Iterations: {n_iter}" if n_iter is not None else ""
|
|
431
|
+
lines.append(f" {tick} SCF {conv_word}{iter_str}")
|
|
432
|
+
if n_steps is not None:
|
|
433
|
+
lines.append(f" Geometry optimization: {n_steps} steps")
|
|
434
|
+
|
|
435
|
+
# Energy
|
|
436
|
+
if energy is not None:
|
|
437
|
+
ev = energy * HARTREE_TO_EV
|
|
438
|
+
lines.append(f" Energy: {energy:.8f} Ha ({ev:.4f} eV)")
|
|
439
|
+
|
|
440
|
+
# HOMO-LUMO gap
|
|
441
|
+
if gap_ev is not None:
|
|
442
|
+
lines.append(f" HOMO-LUMO gap: {gap_ev:.4f} eV")
|
|
443
|
+
|
|
444
|
+
# ZPVE (frequency only)
|
|
445
|
+
if zpve is not None and zpve != 0.0:
|
|
446
|
+
lines.append(
|
|
447
|
+
f" ZPVE: {zpve:.6f} Ha ({zpve * HARTREE_TO_EV:.4f} eV)"
|
|
448
|
+
)
|
|
449
|
+
|
|
450
|
+
# Imaginary frequencies (FreqResult)
|
|
451
|
+
n_imag = None
|
|
452
|
+
try:
|
|
453
|
+
n_imag = result.n_imaginary_modes() # type: ignore[attr-defined]
|
|
454
|
+
except AttributeError:
|
|
455
|
+
pass
|
|
456
|
+
if n_imag is not None and n_imag > 0:
|
|
457
|
+
lines.append(
|
|
458
|
+
f" ⚠ {n_imag} imaginary frequency mode(s) — geometry may not be a true minimum"
|
|
459
|
+
)
|
|
460
|
+
|
|
461
|
+
# Timing section
|
|
462
|
+
lines.append(" ── Timing " + "─" * (_WIDTH - 12))
|
|
463
|
+
wall_str = _fmt_duration(wall_time)
|
|
464
|
+
cpu_str = _fmt_duration(cpu_time)
|
|
465
|
+
ratio = cpu_time / wall_time if wall_time > 0 else 0.0
|
|
466
|
+
lines.append(
|
|
467
|
+
f" Wall time: {wall_str} CPU time: {cpu_str} CPU/Wall: {ratio:.1f}×"
|
|
468
|
+
)
|
|
469
|
+
|
|
470
|
+
# Warnings digest
|
|
471
|
+
lines.append(" ── Warnings Digest " + "─" * (_WIDTH - 22))
|
|
472
|
+
warnings = _extract_warnings(log_text)
|
|
473
|
+
if warnings:
|
|
474
|
+
for w in warnings[:10]: # cap at 10
|
|
475
|
+
# Truncate very long lines
|
|
476
|
+
w_disp = w if len(w) <= 74 else w[:71] + "..."
|
|
477
|
+
lines.append(f" ⚠ {w_disp}")
|
|
478
|
+
if len(warnings) > 10:
|
|
479
|
+
lines.append(f" ... and {len(warnings) - 10} more (see full log)")
|
|
480
|
+
else:
|
|
481
|
+
lines.append(" (none)")
|
|
482
|
+
|
|
483
|
+
# Final status line
|
|
484
|
+
lines.append(_SEP)
|
|
485
|
+
if success:
|
|
486
|
+
lines.append(" ✓ Calculation completed successfully.")
|
|
487
|
+
else:
|
|
488
|
+
lines.append(" ✗ Calculation ended with errors — see log above.")
|
|
489
|
+
lines.append(_SEP)
|
|
490
|
+
lines.append("")
|
|
491
|
+
|
|
492
|
+
return "\n".join(lines)
|