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.
Files changed (62) hide show
  1. quantui/__init__.py +311 -0
  2. quantui/analytics.py +609 -0
  3. quantui/app.py +5650 -0
  4. quantui/app_analysis.py +662 -0
  5. quantui/app_builders.py +2465 -0
  6. quantui/app_exports.py +194 -0
  7. quantui/app_formatters.py +493 -0
  8. quantui/app_history.py +624 -0
  9. quantui/app_runflow.py +1544 -0
  10. quantui/app_visualization.py +2620 -0
  11. quantui/ase_bridge.py +236 -0
  12. quantui/benchmarks.py +1543 -0
  13. quantui/c_stderr.py +124 -0
  14. quantui/cactus.py +88 -0
  15. quantui/calc_log.py +1116 -0
  16. quantui/calculator.py +204 -0
  17. quantui/cancellation.py +88 -0
  18. quantui/cli.py +288 -0
  19. quantui/comparison.py +306 -0
  20. quantui/config.py +725 -0
  21. quantui/data/js/3Dmol-min.js +2 -0
  22. quantui/data/js/3Dmol-min.js.LICENSE.txt +5 -0
  23. quantui/data/library/library.sqlite +0 -0
  24. quantui/data/manifests/bulk_qm9.json +1 -0
  25. quantui/data/manifests/curated.json +15482 -0
  26. quantui/data/manifests/presets.json +816 -0
  27. quantui/descriptor_cards.py +186 -0
  28. quantui/freq_calc.py +712 -0
  29. quantui/freq_ir_workers.py +229 -0
  30. quantui/gpu_offload.py +278 -0
  31. quantui/help_content.py +474 -0
  32. quantui/ir_plot.py +130 -0
  33. quantui/issue_tracker.py +170 -0
  34. quantui/live_log.py +387 -0
  35. quantui/log_utils.py +492 -0
  36. quantui/molecule.py +577 -0
  37. quantui/molecule_library.py +433 -0
  38. quantui/nmr_calc.py +437 -0
  39. quantui/optimizer.py +670 -0
  40. quantui/orbital_visualization.py +1102 -0
  41. quantui/pes_scan.py +420 -0
  42. quantui/preopt.py +355 -0
  43. quantui/progress.py +111 -0
  44. quantui/pubchem.py +1157 -0
  45. quantui/reorganization_energy.py +435 -0
  46. quantui/results_storage.py +902 -0
  47. quantui/security.py +14 -0
  48. quantui/session_calc.py +622 -0
  49. quantui/structure_providers.py +277 -0
  50. quantui/tddft_calc.py +307 -0
  51. quantui/user_settings.py +238 -0
  52. quantui/utils.py +287 -0
  53. quantui/vib_cache.py +247 -0
  54. quantui/visualization_py3dmol.py +593 -0
  55. quantui/viz_assets.py +101 -0
  56. quantui/viz_backend_router.py +243 -0
  57. quantui-0.5.1.dist-info/METADATA +533 -0
  58. quantui-0.5.1.dist-info/RECORD +62 -0
  59. quantui-0.5.1.dist-info/WHEEL +5 -0
  60. quantui-0.5.1.dist-info/entry_points.txt +2 -0
  61. quantui-0.5.1.dist-info/licenses/LICENSE +21 -0
  62. 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)