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/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 = "< 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' <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:]
|