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/calc_log.py ADDED
@@ -0,0 +1,1116 @@
1
+ """
2
+ Performance and event logging for QuantUI.
3
+
4
+ Two separate log files, both stored in ``~/.quantui/logs/`` by default
5
+ (override with the ``QUANTUI_LOG_DIR`` environment variable):
6
+
7
+ ``perf_log.jsonl``
8
+ One record per completed calculation. Kept indefinitely — the full
9
+ history is needed to build reliable time-prediction models.
10
+
11
+ ``event_log.jsonl``
12
+ General app events (startup, calculation lifecycle, errors).
13
+ Auto-pruned: entries older than 7 days are removed on every write.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import json
19
+ import os
20
+ import statistics
21
+ import threading
22
+ from datetime import datetime, timedelta, timezone
23
+ from pathlib import Path
24
+ from typing import Optional
25
+
26
+ # ---------------------------------------------------------------------------
27
+ # Internal helpers
28
+ # ---------------------------------------------------------------------------
29
+
30
+ _LOCK = threading.Lock()
31
+
32
+ # Rough relative cost of each method per SCF iteration.
33
+ # Used when no exact method+basis match exists in the history.
34
+ _METHOD_COST: dict[str, float] = {
35
+ "RHF": 1.0,
36
+ "UHF": 1.0,
37
+ "B3LYP": 2.5,
38
+ "PBE": 2.0,
39
+ "PBE0": 2.5,
40
+ "M06-2X": 3.0,
41
+ "wB97X-D": 3.0,
42
+ "CAM-B3LYP": 2.5,
43
+ "M06-L": 2.0,
44
+ "HSE06": 2.5,
45
+ "PBE-D3": 2.1,
46
+ "MP2": 8.0,
47
+ # CCSD scales O(N⁶); CCSD(T) adds the perturbative-triples step that
48
+ # scales O(N⁷). Cost ratios here are illustrative — actual runtimes are
49
+ # extracted from the perf log when available.
50
+ "CCSD": 30.0,
51
+ "CCSD(T)": 100.0,
52
+ }
53
+
54
+ # Contracted basis function counts per element per basis set (spherical harmonics,
55
+ # PySCF default). Used by count_basis_functions() and estimate_time().
56
+ _BASIS_FUNCTIONS: dict[str, dict[str, int]] = {
57
+ "STO-3G": {
58
+ "H": 1,
59
+ "He": 1,
60
+ "Li": 5,
61
+ "Be": 5,
62
+ "B": 5,
63
+ "C": 5,
64
+ "N": 5,
65
+ "O": 5,
66
+ "F": 5,
67
+ "Ne": 5,
68
+ "Na": 9,
69
+ "Mg": 9,
70
+ "Al": 9,
71
+ "Si": 9,
72
+ "P": 9,
73
+ "S": 9,
74
+ "Cl": 9,
75
+ "Ar": 9,
76
+ },
77
+ "3-21G": {
78
+ "H": 2,
79
+ "He": 2,
80
+ "Li": 9,
81
+ "Be": 9,
82
+ "B": 9,
83
+ "C": 9,
84
+ "N": 9,
85
+ "O": 9,
86
+ "F": 9,
87
+ "Ne": 9,
88
+ "Na": 13,
89
+ "Mg": 13,
90
+ "Al": 15,
91
+ "Si": 15,
92
+ "P": 15,
93
+ "S": 15,
94
+ "Cl": 15,
95
+ "Ar": 15,
96
+ },
97
+ "6-31G": {
98
+ "H": 2,
99
+ "He": 2,
100
+ "Li": 9,
101
+ "Be": 9,
102
+ "B": 9,
103
+ "C": 9,
104
+ "N": 9,
105
+ "O": 9,
106
+ "F": 9,
107
+ "Ne": 9,
108
+ "Na": 13,
109
+ "Mg": 13,
110
+ "Al": 15,
111
+ "Si": 15,
112
+ "P": 15,
113
+ "S": 15,
114
+ "Cl": 15,
115
+ "Ar": 15,
116
+ },
117
+ "6-31G*": {
118
+ "H": 2,
119
+ "He": 2,
120
+ "Li": 9,
121
+ "Be": 9,
122
+ "B": 14,
123
+ "C": 14,
124
+ "N": 14,
125
+ "O": 14,
126
+ "F": 14,
127
+ "Ne": 14,
128
+ "Na": 13,
129
+ "Mg": 13,
130
+ "Al": 20,
131
+ "Si": 20,
132
+ "P": 20,
133
+ "S": 20,
134
+ "Cl": 20,
135
+ "Ar": 20,
136
+ },
137
+ "6-31G**": {
138
+ "H": 5,
139
+ "He": 5,
140
+ "Li": 9,
141
+ "Be": 9,
142
+ "B": 14,
143
+ "C": 14,
144
+ "N": 14,
145
+ "O": 14,
146
+ "F": 14,
147
+ "Ne": 14,
148
+ "Na": 13,
149
+ "Mg": 13,
150
+ "Al": 20,
151
+ "Si": 20,
152
+ "P": 20,
153
+ "S": 20,
154
+ "Cl": 20,
155
+ "Ar": 20,
156
+ },
157
+ "cc-pVDZ": {
158
+ "H": 5,
159
+ "He": 5,
160
+ "Li": 9,
161
+ "Be": 9,
162
+ "B": 14,
163
+ "C": 14,
164
+ "N": 14,
165
+ "O": 14,
166
+ "F": 14,
167
+ "Ne": 14,
168
+ "Na": 18,
169
+ "Mg": 18,
170
+ "Al": 23,
171
+ "Si": 23,
172
+ "P": 23,
173
+ "S": 23,
174
+ "Cl": 23,
175
+ "Ar": 23,
176
+ },
177
+ "cc-pVTZ": {
178
+ "H": 14,
179
+ "He": 14,
180
+ "Li": 20,
181
+ "Be": 20,
182
+ "B": 30,
183
+ "C": 30,
184
+ "N": 30,
185
+ "O": 30,
186
+ "F": 30,
187
+ "Ne": 30,
188
+ "Na": 35,
189
+ "Mg": 35,
190
+ "Al": 43,
191
+ "Si": 43,
192
+ "P": 43,
193
+ "S": 43,
194
+ "Cl": 43,
195
+ "Ar": 43,
196
+ },
197
+ "def2-SVP": {
198
+ "H": 5,
199
+ "He": 5,
200
+ "Li": 9,
201
+ "Be": 9,
202
+ "B": 14,
203
+ "C": 14,
204
+ "N": 14,
205
+ "O": 14,
206
+ "F": 14,
207
+ "Ne": 14,
208
+ "Na": 18,
209
+ "Mg": 18,
210
+ "Al": 23,
211
+ "Si": 23,
212
+ "P": 23,
213
+ "S": 23,
214
+ "Cl": 23,
215
+ "Ar": 23,
216
+ },
217
+ "def2-TZVP": {
218
+ "H": 14,
219
+ "He": 14,
220
+ "Li": 20,
221
+ "Be": 20,
222
+ "B": 30,
223
+ "C": 30,
224
+ "N": 30,
225
+ "O": 30,
226
+ "F": 30,
227
+ "Ne": 30,
228
+ "Na": 35,
229
+ "Mg": 35,
230
+ "Al": 43,
231
+ "Si": 43,
232
+ "P": 43,
233
+ "S": 43,
234
+ "Cl": 43,
235
+ "Ar": 43,
236
+ },
237
+ }
238
+
239
+ # Formal scaling exponents in N_basis. HF/DFT: formally O(N³–N⁴), empirically
240
+ # ~3.5 in the student size range. Correlated methods scale more steeply.
241
+ _METHOD_SCALE_EXP: dict[str, float] = {
242
+ "RHF": 3.5,
243
+ "UHF": 3.5,
244
+ "B3LYP": 3.5,
245
+ "PBE": 3.5,
246
+ "PBE0": 3.5,
247
+ "M06-2X": 3.5,
248
+ "wB97X-D": 3.5,
249
+ "CAM-B3LYP": 3.5,
250
+ "M06-L": 3.5,
251
+ "HSE06": 3.5,
252
+ "PBE-D3": 3.5,
253
+ "MP2": 5.0,
254
+ "CCSD": 6.0,
255
+ "CCSD(T)": 7.0,
256
+ }
257
+
258
+
259
+ def _log_dir() -> Path:
260
+ env = os.environ.get("QUANTUI_LOG_DIR")
261
+ return Path(env) if env else Path.home() / ".quantui" / "logs"
262
+
263
+
264
+ def _perf_path() -> Path:
265
+ return _log_dir() / "perf_log.jsonl"
266
+
267
+
268
+ def _event_path() -> Path:
269
+ return _log_dir() / "event_log.jsonl"
270
+
271
+
272
+ def _prediction_log_path() -> Path:
273
+ """Path to ``prediction_log.jsonl`` — the file capturing one record
274
+ per ``_do_run`` invocation with the estimator's pre-run prediction
275
+ and the actual wall-clock outcome.
276
+
277
+ Kept indefinitely (like ``perf_log.jsonl``) so the analytics
278
+ dashboard can plot prediction accuracy over time without manual
279
+ pruning. Lives in the same dir as the other logs; honours
280
+ ``QUANTUI_LOG_DIR`` for tests.
281
+ """
282
+ return _log_dir() / "prediction_log.jsonl"
283
+
284
+
285
+ def _append(path: Path, record: dict) -> None:
286
+ path.parent.mkdir(parents=True, exist_ok=True)
287
+ line = json.dumps(record, ensure_ascii=False) + "\n"
288
+ with _LOCK:
289
+ with open(path, "a", encoding="utf-8") as fh:
290
+ fh.write(line)
291
+
292
+
293
+ _READ_ALL_CACHE: dict[str, tuple[float, int, list[dict]]] = {}
294
+
295
+
296
+ def _read_all(path: Path) -> list[dict]:
297
+ """Parse every JSON line in *path*, caching on (mtime, size).
298
+
299
+ ``estimate_time()`` calls this on every UI refresh (widget-change
300
+ callback), re-parsing the entire (indefinitely-kept) perf log each
301
+ time even though it usually hasn't changed since the last call.
302
+ Cache the parsed records keyed by the file's mtime + size so repeat
303
+ calls between writes skip the read + per-line ``json.loads`` entirely;
304
+ a write bumps both, so the cache invalidates correctly.
305
+ """
306
+ key = str(path)
307
+ with _LOCK:
308
+ if not path.exists():
309
+ _READ_ALL_CACHE.pop(key, None)
310
+ return []
311
+ stat = path.stat()
312
+ cached = _READ_ALL_CACHE.get(key)
313
+ if (
314
+ cached is not None
315
+ and cached[0] == stat.st_mtime
316
+ and cached[1] == stat.st_size
317
+ ):
318
+ return list(cached[2])
319
+ records: list[dict] = []
320
+ with open(path, encoding="utf-8") as fh:
321
+ for raw in fh:
322
+ raw = raw.strip()
323
+ if raw:
324
+ try:
325
+ records.append(json.loads(raw))
326
+ except json.JSONDecodeError:
327
+ pass
328
+ _READ_ALL_CACHE[key] = (stat.st_mtime, stat.st_size, records)
329
+ return list(records)
330
+
331
+
332
+ # ---------------------------------------------------------------------------
333
+ # Basis function utilities
334
+ # ---------------------------------------------------------------------------
335
+
336
+
337
+ def count_basis_functions(atoms: list[str], basis: str) -> Optional[int]:
338
+ """
339
+ Return the total number of contracted basis functions for a molecule.
340
+
341
+ Args:
342
+ atoms: Element symbols (e.g. ``["O", "H", "H"]``).
343
+ basis: Basis set name (e.g. ``"STO-3G"``).
344
+
345
+ Returns:
346
+ Total basis function count, or ``None`` if the basis set or any
347
+ element is not in the lookup table.
348
+ """
349
+ table = _BASIS_FUNCTIONS.get(basis)
350
+ if table is None:
351
+ return None
352
+ total = 0
353
+ for atom in atoms:
354
+ n = table.get(atom)
355
+ if n is None:
356
+ return None
357
+ total += n
358
+ return total
359
+
360
+
361
+ # ---------------------------------------------------------------------------
362
+ # Statistical helpers (2026-05-25)
363
+ # ---------------------------------------------------------------------------
364
+
365
+
366
+ def _iqr_filter(values: list[float]) -> list[float]:
367
+ """Discard outliers outside [Q1 − 1.5·IQR, Q3 + 1.5·IQR].
368
+
369
+ The classic Tukey fence catches cold-cache outliers (single slow
370
+ runs that landed before BLAS / DFT grids were resident) and
371
+ thermal-throttled runs (a single overheated run pulled the median
372
+ high) without being overly aggressive on the legitimate spread
373
+ you'd expect across the perf-log timeline.
374
+
375
+ Returns the unmodified list when there are fewer than 4 samples —
376
+ IQR isn't meaningful on small N, and the median-based predictors
377
+ upstream already handle small-N gracefully.
378
+ """
379
+ if len(values) < 4:
380
+ return list(values)
381
+ sorted_v = sorted(values)
382
+ # Use the "inclusive" method (matches numpy/pandas default linear
383
+ # interpolation). "exclusive" places quartiles BETWEEN data points
384
+ # via n*p/(n+1) which lets a single small-N outlier pull Q3 high
385
+ # enough that its own value falls inside the fence — defeating the
386
+ # filter. "inclusive" anchors quartiles AT data points so the
387
+ # fence cleanly excludes the outlier.
388
+ q1 = statistics.quantiles(sorted_v, n=4, method="inclusive")[0]
389
+ q3 = statistics.quantiles(sorted_v, n=4, method="inclusive")[2]
390
+ iqr = q3 - q1
391
+ if iqr == 0:
392
+ # All-equal pool — no outliers to reject.
393
+ return list(values)
394
+ low = q1 - 1.5 * iqr
395
+ high = q3 + 1.5 * iqr
396
+ return [v for v in values if low <= v <= high]
397
+
398
+
399
+ def _coefficient_of_variation(values: list[float]) -> float:
400
+ """Return σ / |μ|. Returns 0.0 when the mean is zero or N < 2."""
401
+ if len(values) < 2:
402
+ return 0.0
403
+ mean = statistics.mean(values)
404
+ if mean == 0:
405
+ return 0.0
406
+ return statistics.stdev(values) / abs(mean)
407
+
408
+
409
+ def _confidence_label(values: list[float], n_samples: int) -> str:
410
+ """Variance-aware confidence label.
411
+
412
+ Combines coefficient of variation (CV) with sample count:
413
+
414
+ - CV < 0.15 → "high"
415
+ - 0.15 ≤ CV < 0.35 → "medium"
416
+ - CV ≥ 0.35 → "low"
417
+
418
+ Then capped by sample count: n < 3 always reports "low" (CV is
419
+ noisy on tiny pools); n < 5 caps at "medium" regardless of CV.
420
+
421
+ This is what catches the 1-min-predicted / 5-min-actual class —
422
+ even with many samples, a high-variance pool should report "low"
423
+ confidence so the user knows the prediction has wide error bars.
424
+ """
425
+ if n_samples < 3:
426
+ return "low"
427
+ cv = _coefficient_of_variation(values)
428
+ if cv < 0.15:
429
+ base = "high"
430
+ elif cv < 0.35:
431
+ base = "medium"
432
+ else:
433
+ base = "low"
434
+ # Sample-count cap.
435
+ if n_samples < 5 and base == "high":
436
+ return "medium"
437
+ return base
438
+
439
+
440
+ # ---------------------------------------------------------------------------
441
+ # Performance log
442
+ # ---------------------------------------------------------------------------
443
+
444
+
445
+ def log_calculation(
446
+ formula: str,
447
+ n_atoms: int,
448
+ n_electrons: int,
449
+ method: str,
450
+ basis: str,
451
+ n_iterations: Optional[int],
452
+ elapsed_s: float,
453
+ converged: bool,
454
+ n_basis: Optional[int] = None,
455
+ n_cores: Optional[int] = None,
456
+ calc_type: Optional[str] = None,
457
+ gpu_used: Optional[bool] = None,
458
+ gpu_name: Optional[str] = None,
459
+ n_steps: Optional[int] = None,
460
+ ) -> None:
461
+ """Append one performance record to ``perf_log.jsonl``.
462
+
463
+ ``gpu_used`` / ``gpu_name`` (added 2026-05-25) record whether GPU
464
+ offload was active for the run; reading these back lets
465
+ ``quantui.analytics.build_dashboard`` compute GPU-vs-CPU speedups
466
+ across runs of the same (method, basis, formula) tuple.
467
+ """
468
+ record: dict = {
469
+ "timestamp": datetime.now(timezone.utc).isoformat(),
470
+ "formula": formula,
471
+ "n_atoms": n_atoms,
472
+ "n_electrons": n_electrons,
473
+ "method": method,
474
+ "basis": basis,
475
+ "n_iterations": n_iterations,
476
+ "elapsed_s": round(elapsed_s, 3),
477
+ "converged": converged,
478
+ }
479
+ if n_basis is not None:
480
+ record["n_basis"] = n_basis
481
+ if n_cores is not None:
482
+ record["n_cores"] = n_cores
483
+ if calc_type is not None:
484
+ record["calc_type"] = calc_type
485
+ if gpu_used is not None:
486
+ record["gpu_used"] = bool(gpu_used)
487
+ if gpu_name is not None:
488
+ record["gpu_name"] = gpu_name
489
+ # Outer-loop step count (geom-opt BFGS steps / PES scan points). Enables
490
+ # a history-based "step k / ~N" prior for the live progress fraction.
491
+ if n_steps is not None:
492
+ record["n_steps"] = n_steps
493
+ _append(_perf_path(), record)
494
+
495
+
496
+ #: Hessian-cost multipliers used by the frequency cost model.
497
+ #: PySCF's analytical Hessian for HF/DFT runs in ~2-3× SCF time; for
498
+ #: post-HF methods it falls back to numerical Hessian which is much
499
+ #: more expensive (effectively 6N SCFs by itself, on top of the IR
500
+ #: intensity 6N SCFs). The constants below are empirical defaults that
501
+ #: tier-3/4 calibration data can refine — they're load-bearing only
502
+ #: when no direct frequency-calc history exists for the (method, basis)
503
+ #: tuple. Once the user has run a tier-4 freq, strategies 1-4 use real
504
+ #: data and the cost model is skipped entirely.
505
+ _HESSIAN_MULTIPLIER_HF_DFT: float = 2.0
506
+ _HESSIAN_MULTIPLIER_POST_HF: float = 6.0
507
+ _POST_HF_METHODS: frozenset = frozenset({"MP2", "CCSD", "CCSD(T)"})
508
+
509
+
510
+ def _estimate_frequency_cost(
511
+ n_atoms: int,
512
+ n_electrons: int,
513
+ method: str,
514
+ basis: str,
515
+ n_basis: Optional[int] = None,
516
+ n_cores: Optional[int] = None,
517
+ gpu_used: Optional[bool] = None,
518
+ ) -> Optional[dict]:
519
+ """Structured frequency-time estimate from an SP anchor.
520
+
521
+ Decomposition::
522
+
523
+ freq_total ≈ scf_anchor + hessian_term + ir_intensity_term
524
+
525
+ where:
526
+
527
+ - ``scf_anchor`` — predicted single-point time for the same
528
+ ``(method, basis, n_atoms, gpu_used)`` profile, derived via
529
+ :func:`estimate_time` with ``calc_type="single_point"``.
530
+ - ``hessian_term`` — empirical multiple of ``scf_anchor`` (~2× for
531
+ HF/DFT analytical, ~6× for post-HF numerical).
532
+ - ``ir_intensity_term`` — the 6N inner SCFs that compute ∂μ/∂R for
533
+ IR intensities, divided by ``effective_workers`` when the
534
+ ``QUANTUI_FREQ_PARALLEL`` cross-displacement worker pool is gated
535
+ on (requires no GPU + ≥4 cores + ≥6 displacements). On a GPU host
536
+ the inner SCFs are already accelerated by gpu4pyscf, so parallel
537
+ adds little and stays serial.
538
+
539
+ Returns ``None`` when the SP anchor can't be produced (no usable
540
+ history for the SP profile). In that case ``estimate_time``'s
541
+ overall return value stays ``None`` and the UI shows
542
+ "no estimate available — run a calibration".
543
+
544
+ The model's confidence is inherited from the SP anchor — we don't
545
+ have direct freq variance data to claim independently, and the
546
+ cost decomposition itself is a fixed structural assumption.
547
+ """
548
+ if n_atoms <= 0:
549
+ return None
550
+
551
+ sp_est = estimate_time(
552
+ n_atoms=n_atoms,
553
+ n_electrons=n_electrons,
554
+ method=method,
555
+ basis=basis,
556
+ n_basis=n_basis,
557
+ n_cores=n_cores,
558
+ calc_type="single_point",
559
+ gpu_used=gpu_used,
560
+ )
561
+ if sp_est is None:
562
+ return None
563
+ scf_anchor_s = float(sp_est["seconds"])
564
+
565
+ # Hessian term.
566
+ method_upper = method.strip().upper()
567
+ hessian_mult = (
568
+ _HESSIAN_MULTIPLIER_POST_HF
569
+ if method_upper in _POST_HF_METHODS
570
+ else _HESSIAN_MULTIPLIER_HF_DFT
571
+ )
572
+ hessian_term_s = hessian_mult * scf_anchor_s
573
+
574
+ # IR intensity term — 6N inner SCFs, possibly parallelized.
575
+ displacement_count = 6 * n_atoms
576
+ effective_workers = 1
577
+ try:
578
+ from quantui.freq_ir_workers import (
579
+ parallel_enabled_for_run,
580
+ pick_worker_count,
581
+ )
582
+
583
+ cpu_count = n_cores if n_cores is not None else (os.cpu_count() or 1)
584
+ if parallel_enabled_for_run(
585
+ cpu_count=cpu_count,
586
+ displacement_count=displacement_count,
587
+ gpu_available=bool(gpu_used),
588
+ ):
589
+ effective_workers = pick_worker_count(cpu_count, displacement_count)
590
+ except Exception: # noqa: BLE001 — gating is best-effort
591
+ effective_workers = 1
592
+ ir_term_s = displacement_count * scf_anchor_s / max(1, effective_workers)
593
+
594
+ total_s = scf_anchor_s + hessian_term_s + ir_term_s
595
+ return {
596
+ "seconds": total_s,
597
+ # Cost model adds structural assumptions but no new data — don't
598
+ # claim more confidence than the SP anchor it leans on.
599
+ "confidence": sp_est["confidence"],
600
+ "n_samples": sp_est["n_samples"],
601
+ }
602
+
603
+
604
+ def estimate_time(
605
+ n_atoms: int,
606
+ n_electrons: int,
607
+ method: str,
608
+ basis: str,
609
+ n_basis: Optional[int] = None,
610
+ n_cores: Optional[int] = None,
611
+ calc_type: Optional[str] = None,
612
+ gpu_used: Optional[bool] = None,
613
+ ) -> Optional[dict]:
614
+ """
615
+ Return a time estimate dict, or ``None`` if there is insufficient data.
616
+
617
+ The returned dict has keys:
618
+
619
+ * ``seconds`` – estimated wall time as a float
620
+ * ``confidence`` – ``"high"``, ``"medium"``, or ``"low"``
621
+ * ``n_samples`` – number of historical records used
622
+
623
+ Prediction strategy (in priority order):
624
+
625
+ 1. **Exact method + basis, basis-function efficiency** (≥ 2 records with
626
+ ``n_basis``): Computes a normalised efficiency
627
+ ``eff = elapsed_s × n_cores_hist / n_basis_hist^β`` for each record,
628
+ then predicts ``median(eff) × n_basis_new^β / n_cores_current``.
629
+ β is method-specific (RHF/DFT ≈ 3.5, MP2 = 5.0, CCSD = 6.0, …).
630
+ Confidence: high (≥ 5 samples) or medium (2–4 samples).
631
+
632
+ 2. **Exact method + basis, electron-count fallback** (≥ 2 records):
633
+ Median elapsed time scaled by ``(n_electrons / median_n_e)^2.7``.
634
+ Used when older records lack ``n_basis``.
635
+ Confidence: high / medium.
636
+
637
+ 3. **Same basis, any method, basis-function efficiency** (≥ 2 records with
638
+ ``n_basis``): Like strategy 1, plus a method-cost correction factor.
639
+ Confidence: low.
640
+
641
+ 4. **Same basis, any method, electron-count fallback** (≥ 2 records):
642
+ Same as the original strategy 2. Confidence: low.
643
+
644
+ ``calc_type`` narrows the candidate pool so that expensive workflows
645
+ (for example, Frequency) are not predicted from cheap workflows
646
+ (for example, Single Point). Legacy records without ``calc_type`` are
647
+ only included when estimating ``single_point``.
648
+
649
+ **GPU-aware filtering** (2026-05-25): when ``gpu_used``
650
+ is passed, the candidate pool is partitioned by device — GPU-history
651
+ predicts GPU runs and CPU-history predicts CPU runs. Older records
652
+ don't have ``gpu_used`` at all; those are treated
653
+ as "device unknown" and admitted only when ``gpu_used=False`` is
654
+ requested (the conservative assumption, since QuantUI was CPU-only
655
+ before GPU offload shipped). When ``gpu_used=None`` (default), the device
656
+ axis is ignored and all records are eligible — back-compat with
657
+ callers that don't know which device the upcoming run will use.
658
+
659
+ If GPU partitioning leaves fewer than 2 records in the pool, the
660
+ function falls back to the unpartitioned pool with the confidence
661
+ label downgraded one notch — better an approximate estimate from
662
+ cross-device data than no estimate at all.
663
+
664
+ Returns ``None`` when fewer than 2 converged records are available for
665
+ the scoped candidate pool.
666
+ """
667
+ records = _read_all(_perf_path())
668
+ converged = [r for r in records if r.get("converged")]
669
+ if not converged:
670
+ return None
671
+
672
+ if calc_type is None:
673
+ scoped = converged
674
+ elif calc_type == "single_point":
675
+ # Back-compat bridge: older records did not store calc_type.
676
+ scoped = [
677
+ r
678
+ for r in converged
679
+ if r.get("calc_type") == "single_point" or r.get("calc_type") is None
680
+ ]
681
+ else:
682
+ scoped = [r for r in converged if r.get("calc_type") == calc_type]
683
+
684
+ if len(scoped) < 2:
685
+ # Frequency calcs can still produce a prediction via the
686
+ # SP-anchored cost model even when direct freq history is empty.
687
+ # The cost model lives at the end of this function — fall through
688
+ # for freq, bail for everything else.
689
+ if calc_type != "frequency":
690
+ return None
691
+ # Continue with empty/small ``scoped``: the four direct strategies
692
+ # will all no-op (their pool checks require len >= 2), and the
693
+ # freq cost-model fallback at the end will fire.
694
+
695
+ # Partition by device when the caller specified one.
696
+ # Older records don't carry ``gpu_used`` — admit them
697
+ # only into the CPU pool, since QuantUI was CPU-only when they were
698
+ # written. Track whether we downgraded for the fall-back path below.
699
+ _gpu_filtered = False
700
+ if gpu_used is True:
701
+ gpu_scoped = [r for r in scoped if r.get("gpu_used") is True]
702
+ if len(gpu_scoped) >= 2:
703
+ scoped = gpu_scoped
704
+ _gpu_filtered = True
705
+ # else: fall through to the unpartitioned pool; caller's
706
+ # confidence will be downgraded below.
707
+ elif gpu_used is False:
708
+ cpu_scoped = [
709
+ r for r in scoped if r.get("gpu_used") is False or "gpu_used" not in r
710
+ ]
711
+ if len(cpu_scoped) >= 2:
712
+ scoped = cpu_scoped
713
+ _gpu_filtered = True
714
+
715
+ def _maybe_downgrade(conf: str) -> str:
716
+ """Downgrade confidence one notch if device-partition fell back."""
717
+ if gpu_used is None or _gpu_filtered:
718
+ return conf
719
+ return {"high": "medium", "medium": "low", "low": "low"}[conf]
720
+
721
+ beta_new = _METHOD_SCALE_EXP.get(method, 3.5)
722
+ n_cores_current = n_cores if n_cores is not None else 1
723
+
724
+ def _eff(r: dict) -> Optional[float]:
725
+ """Normalised efficiency: elapsed_s × n_cores / n_basis^β."""
726
+ nb: float = float(r.get("n_basis") or 0)
727
+ if not nb:
728
+ return None
729
+ rc: float = float(r.get("n_cores") or 1)
730
+ r_method: str = str(r.get("method") or method)
731
+ beta: float = _METHOD_SCALE_EXP.get(r_method, 3.5)
732
+ elapsed: float = float(r["elapsed_s"])
733
+ return float(elapsed * rc / (nb**beta))
734
+
735
+ # ── Strategy 1: exact method + basis, basis-function efficiency ──────────
736
+ if n_basis is not None:
737
+ exact_nb = [
738
+ r
739
+ for r in scoped
740
+ if r.get("method") == method
741
+ and r.get("basis") == basis
742
+ and r.get("n_basis") is not None
743
+ ]
744
+ effs = [e for r in exact_nb for e in [_eff(r)] if e is not None]
745
+ if len(effs) >= 2:
746
+ # Drop Tukey outliers before computing the predictor.
747
+ # The variance of the *filtered* pool drives confidence.
748
+ filtered_effs = _iqr_filter(effs)
749
+ predicted = (
750
+ statistics.median(filtered_effs) * (n_basis**beta_new) / n_cores_current
751
+ )
752
+ return {
753
+ "seconds": predicted,
754
+ "confidence": _maybe_downgrade(
755
+ _confidence_label(filtered_effs, len(filtered_effs))
756
+ ),
757
+ "n_samples": len(filtered_effs),
758
+ }
759
+
760
+ # ── Strategy 2: exact method + basis, electron-count fallback ────────────
761
+ exact = [r for r in scoped if r.get("method") == method and r.get("basis") == basis]
762
+ if len(exact) >= 2:
763
+ elapsed_values = [float(r["elapsed_s"]) for r in exact]
764
+ filtered_elapsed = _iqr_filter(elapsed_values)
765
+ # Recompute electron-count median against the same filtered pool
766
+ # so the scale factor is consistent with the time median.
767
+ filtered_records = [
768
+ r for r in exact if float(r["elapsed_s"]) in filtered_elapsed
769
+ ]
770
+ median_ne = statistics.median(
771
+ r["n_electrons"] for r in (filtered_records or exact)
772
+ )
773
+ median_t = statistics.median(filtered_elapsed)
774
+ scale = (n_electrons / median_ne) ** 2.7 if median_ne > 0 else 1.0
775
+ return {
776
+ "seconds": median_t * scale,
777
+ "confidence": _maybe_downgrade(
778
+ _confidence_label(filtered_elapsed, len(filtered_elapsed))
779
+ ),
780
+ "n_samples": len(filtered_elapsed),
781
+ }
782
+
783
+ # ── Strategy 3: same basis, any method, basis-function efficiency ─────────
784
+ if n_basis is not None:
785
+ same_basis_nb = [
786
+ r
787
+ for r in scoped
788
+ if r.get("basis") == basis and r.get("n_basis") is not None
789
+ ]
790
+ effs = [e for r in same_basis_nb for e in [_eff(r)] if e is not None]
791
+ if len(effs) >= 2:
792
+ ref_cost = statistics.median(
793
+ _METHOD_COST.get(r.get("method", "RHF"), 1.0) for r in same_basis_nb
794
+ )
795
+ tgt_cost = _METHOD_COST.get(method, 1.0)
796
+ cost_factor = tgt_cost / ref_cost if ref_cost > 0 else 1.0
797
+ predicted = (
798
+ statistics.median(effs)
799
+ * (n_basis**beta_new)
800
+ * cost_factor
801
+ / n_cores_current
802
+ )
803
+ return {
804
+ "seconds": predicted,
805
+ "confidence": "low",
806
+ "n_samples": len(effs),
807
+ }
808
+
809
+ # ── Strategy 4: same basis, any method, electron-count fallback ───────────
810
+ same_basis = [r for r in scoped if r.get("basis") == basis]
811
+ if len(same_basis) >= 2:
812
+ median_ne = statistics.median(r["n_electrons"] for r in same_basis)
813
+ median_t = statistics.median(r["elapsed_s"] for r in same_basis)
814
+ ref_cost = statistics.median(
815
+ _METHOD_COST.get(r.get("method", "RHF"), 1.0) for r in same_basis
816
+ )
817
+ tgt_cost = _METHOD_COST.get(method, 1.0)
818
+ ne_scale = (n_electrons / median_ne) ** 2.7 if median_ne > 0 else 1.0
819
+ cost_scale = tgt_cost / ref_cost if ref_cost > 0 else 1.0
820
+ return {
821
+ "seconds": median_t * ne_scale * cost_scale,
822
+ "confidence": "low",
823
+ "n_samples": len(same_basis),
824
+ }
825
+
826
+ # ── Frequency cost-model fallback ─────────────────────────────────────────
827
+ # When all four direct-history strategies fail for a freq calc, fall
828
+ # back to the structural decomposition: SP anchor + Hessian + 6N
829
+ # inner SCFs. The SP anchor comes from the much richer single-point
830
+ # history pool, which is usually populated even on a fresh install
831
+ # (tier 1 is SP-only). Confidence is inherited from the SP anchor.
832
+ if calc_type == "frequency":
833
+ cost_est = _estimate_frequency_cost(
834
+ n_atoms=n_atoms,
835
+ n_electrons=n_electrons,
836
+ method=method,
837
+ basis=basis,
838
+ n_basis=n_basis,
839
+ n_cores=n_cores,
840
+ gpu_used=gpu_used,
841
+ )
842
+ if cost_est is not None:
843
+ return cost_est
844
+
845
+ return None
846
+
847
+
848
+ def estimate_opt_steps(
849
+ method: str, basis: str, calc_type: str = "geometry_opt"
850
+ ) -> Optional[float]:
851
+ """Median historical outer-step count for *calc_type* (progress prior).
852
+
853
+ Reads ``perf_log`` for converged records of *calc_type* that recorded
854
+ ``n_steps``. Prefers exact method+basis (>= 2 records), falls back
855
+ to same-basis (>= 2), then to all matching-calc_type records. Returns the
856
+ median or ``None`` when there is no usable history — a rough prior used only
857
+ to seed / floor the live progress fraction, not a hard prediction.
858
+ """
859
+ try:
860
+ records = _read_all(_perf_path())
861
+ except Exception:
862
+ return None
863
+
864
+ def _usable(r: dict) -> bool:
865
+ return (
866
+ r.get("calc_type") == calc_type
867
+ and bool(r.get("converged"))
868
+ and isinstance(r.get("n_steps"), (int, float))
869
+ and r["n_steps"] > 0
870
+ )
871
+
872
+ pool = [r for r in records if _usable(r)]
873
+ if not pool:
874
+ return None
875
+ exact = [r for r in pool if r.get("method") == method and r.get("basis") == basis]
876
+ same_basis = [r for r in pool if r.get("basis") == basis]
877
+ if len(exact) >= 2:
878
+ chosen = exact
879
+ elif len(same_basis) >= 2:
880
+ chosen = same_basis
881
+ else:
882
+ chosen = pool
883
+ try:
884
+ return float(statistics.median(r["n_steps"] for r in chosen))
885
+ except Exception:
886
+ return None
887
+
888
+
889
+ def format_estimate(est: Optional[dict]) -> str:
890
+ """
891
+ Return an HTML string summarising *est* for display in the notebook.
892
+
893
+ Returns an empty string when *est* is ``None``.
894
+ """
895
+ if est is None:
896
+ return ""
897
+ s = est["seconds"]
898
+ conf = est["confidence"]
899
+ n = est["n_samples"]
900
+
901
+ if s < 5:
902
+ time_str = "&lt; 5 s"
903
+ elif s < 60:
904
+ time_str = f"~{int(s)} s"
905
+ elif s < 3600:
906
+ time_str = f"~{int(s / 60)} min"
907
+ else:
908
+ time_str = f"~{s / 3600:.1f} hr"
909
+
910
+ colour = {"high": "#22c55e", "medium": "#f59e0b", "low": "#94a3b8"}[conf]
911
+ return (
912
+ f'<span style="font-size:12px;color:#64748b">'
913
+ f'Estimated time: <b style="color:{colour}">{time_str}</b>'
914
+ f'&ensp;<span style="color:#94a3b8">({conf} confidence, {n} similar '
915
+ f'run{"s" if n != 1 else ""})</span></span>'
916
+ )
917
+
918
+
919
+ def get_perf_history() -> list[dict]:
920
+ """Return all records from ``perf_log.jsonl`` as a list of dicts."""
921
+ return _read_all(_perf_path())
922
+
923
+
924
+ # ---------------------------------------------------------------------------
925
+ # Prediction log (2026-05-25)
926
+ # ---------------------------------------------------------------------------
927
+ #
928
+ # Captures one record per ``_do_run`` invocation with the estimator's
929
+ # pre-run prediction + the actual wall-clock outcome. Lets the analytics
930
+ # dashboard show prediction accuracy over time, broken down by calc-type
931
+ # and device, so the user can tell at a glance whether the estimator is
932
+ # working or whether it's time to re-calibrate.
933
+
934
+
935
+ def log_prediction(
936
+ predicted_s: Optional[float],
937
+ actual_s: float,
938
+ *,
939
+ method: str,
940
+ basis: str,
941
+ calc_type: str,
942
+ formula: str = "",
943
+ confidence: str = "unknown",
944
+ gpu_used: Optional[bool] = None,
945
+ ) -> None:
946
+ """Append one prediction record to ``prediction_log.jsonl``.
947
+
948
+ ``predicted_s`` is ``None`` when the estimator returned no estimate
949
+ (insufficient history at run-time). Both columns are still logged
950
+ so the dashboard can count "no-estimate" runs separately from
951
+ "estimate-was-way-off" runs — both are meaningful failure modes
952
+ for the predictor.
953
+
954
+ ``actual_s`` should match the value passed to ``log_calculation``
955
+ for the same run; the dashboard cross-references them via the
956
+ ``timestamp`` key. The two writes are not transactional — if one
957
+ side fails we'd rather have the perf-log record than no record
958
+ at all, so ``log_prediction`` is best-effort and the caller does
959
+ not depend on its return.
960
+ """
961
+ record: dict = {
962
+ "timestamp": datetime.now(timezone.utc).isoformat(),
963
+ "predicted_s": (
964
+ round(float(predicted_s), 3) if predicted_s is not None else None
965
+ ),
966
+ "actual_s": round(float(actual_s), 3),
967
+ "method": method,
968
+ "basis": basis,
969
+ "calc_type": calc_type,
970
+ "formula": formula,
971
+ "confidence": confidence,
972
+ }
973
+ if gpu_used is not None:
974
+ record["gpu_used"] = bool(gpu_used)
975
+ # Derived: signed error percentage. ``None`` when we had no estimate.
976
+ if predicted_s is not None and predicted_s > 0:
977
+ record["error_pct"] = round(
978
+ 100.0 * (float(actual_s) - float(predicted_s)) / float(predicted_s), 1
979
+ )
980
+ else:
981
+ record["error_pct"] = None
982
+ _append(_prediction_log_path(), record)
983
+
984
+
985
+ def get_prediction_history() -> list[dict]:
986
+ """Return all records from ``prediction_log.jsonl`` as a list of dicts."""
987
+ return _read_all(_prediction_log_path())
988
+
989
+
990
+ def reset_perf_log() -> None:
991
+ """Delete all records from ``perf_log.jsonl``.
992
+
993
+ Removes the file entirely. A fresh file is created automatically on the
994
+ next :func:`log_calculation` call. Time estimates will return ``None``
995
+ until enough new records accumulate.
996
+ """
997
+ path = _perf_path()
998
+ with _LOCK:
999
+ if path.exists():
1000
+ path.unlink()
1001
+
1002
+
1003
+ def clear_event_log() -> None:
1004
+ """Delete the session event log (``event_log.jsonl``).
1005
+
1006
+ Removes the file entirely. A fresh file is created automatically on the
1007
+ next :func:`log_event` call. ``perf_log.jsonl`` and ``issues.db`` are
1008
+ **not** affected.
1009
+ """
1010
+ path = _event_path()
1011
+ with _LOCK:
1012
+ if path.exists():
1013
+ path.unlink()
1014
+
1015
+
1016
+ # ---------------------------------------------------------------------------
1017
+ # Event log (7-day TTL)
1018
+ # ---------------------------------------------------------------------------
1019
+
1020
+ # Audit fix (2026-07-14): log_event() used to call prune_events() after
1021
+ # every single append, and prune_events() itself read the file (acquiring
1022
+ # and releasing _LOCK) and only later reacquired _LOCK to rewrite it. Two
1023
+ # problems:
1024
+ #
1025
+ # 1. Race: an append from another thread landing in the gap between the
1026
+ # read and the rewrite got silently discarded when the rewrite replaced
1027
+ # the whole file with the (now-stale) `kept` list computed before that
1028
+ # append happened.
1029
+ # 2. Cost: reading + rewriting the entire event log on every single write
1030
+ # is O(file size) per event, i.e. O(N^2) over a session as the log
1031
+ # grows — noticeable once a session has logged more than a few hundred
1032
+ # events.
1033
+ #
1034
+ # Fixed by (a) making prune_events() read + filter + rewrite as a single
1035
+ # lock-held critical section, so a concurrent append either completes
1036
+ # before the prune starts or blocks until it finishes — it can never be
1037
+ # silently lost — and (b) only running the full prune every
1038
+ # _PRUNE_EVERY_N_EVENTS appends instead of on every single one.
1039
+ _PRUNE_EVERY_N_EVENTS = 20
1040
+ _events_since_prune = 0
1041
+
1042
+
1043
+ def log_event(event_type: str, message: str, **extra: object) -> None:
1044
+ """
1045
+ Append one event to ``event_log.jsonl``; prune entries > 7 days old
1046
+ periodically (every :data:`_PRUNE_EVERY_N_EVENTS` calls, not every one).
1047
+
1048
+ Args:
1049
+ event_type: Short category string, e.g. ``"startup"``, ``"calc_done"``,
1050
+ ``"calc_error"``.
1051
+ message: Human-readable description.
1052
+ **extra: Any additional key-value pairs to include in the record.
1053
+ """
1054
+ global _events_since_prune
1055
+
1056
+ record: dict = {
1057
+ "timestamp": datetime.now(timezone.utc).isoformat(),
1058
+ "event": event_type,
1059
+ "message": message,
1060
+ **extra,
1061
+ }
1062
+ _append(_event_path(), record)
1063
+
1064
+ with _LOCK:
1065
+ _events_since_prune += 1
1066
+ due_for_prune = _events_since_prune >= _PRUNE_EVERY_N_EVENTS
1067
+ if due_for_prune:
1068
+ _events_since_prune = 0
1069
+ if due_for_prune:
1070
+ prune_events()
1071
+
1072
+
1073
+ def prune_events(days: int = 7) -> None:
1074
+ """Remove event-log entries older than *days* days (default: 7).
1075
+
1076
+ Reads, filters, and rewrites the file as a single lock-held critical
1077
+ section so a concurrent :func:`log_event` append can never be silently
1078
+ lost between the read and the rewrite (see module-level note above).
1079
+ """
1080
+ path = _event_path()
1081
+ cutoff = datetime.now(timezone.utc) - timedelta(days=days)
1082
+
1083
+ with _LOCK:
1084
+ if not path.exists():
1085
+ return
1086
+ records: list[dict] = []
1087
+ with open(path, encoding="utf-8") as fh:
1088
+ for raw in fh:
1089
+ raw = raw.strip()
1090
+ if raw:
1091
+ try:
1092
+ records.append(json.loads(raw))
1093
+ except json.JSONDecodeError:
1094
+ pass
1095
+
1096
+ kept: list[dict] = []
1097
+ for r in records:
1098
+ try:
1099
+ ts = datetime.fromisoformat(r["timestamp"])
1100
+ # fromisoformat on Python < 3.11 doesn't handle 'Z' suffix
1101
+ if ts.tzinfo is None:
1102
+ ts = ts.replace(tzinfo=timezone.utc)
1103
+ if ts >= cutoff:
1104
+ kept.append(r)
1105
+ except (KeyError, ValueError):
1106
+ kept.append(r) # keep malformed entries rather than silently drop
1107
+
1108
+ path.parent.mkdir(parents=True, exist_ok=True)
1109
+ with open(path, "w", encoding="utf-8") as fh:
1110
+ for r in kept:
1111
+ fh.write(json.dumps(r, ensure_ascii=False) + "\n")
1112
+
1113
+
1114
+ def get_recent_events(n: int = 100) -> list[dict]:
1115
+ """Return the *n* most recent entries from ``event_log.jsonl``."""
1116
+ return _read_all(_event_path())[-n:]