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/app_history.py
ADDED
|
@@ -0,0 +1,624 @@
|
|
|
1
|
+
"""History-loading helpers used by QuantUIApp."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import datetime as _dt
|
|
6
|
+
import json as _json
|
|
7
|
+
import time
|
|
8
|
+
from contextlib import contextmanager
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any, Optional
|
|
11
|
+
|
|
12
|
+
import ipywidgets as widgets
|
|
13
|
+
from IPython.display import HTML, display
|
|
14
|
+
|
|
15
|
+
# ══ HISTORY SEARCH / FACETED FILTER (HIST.7) ══════════════════════════════
|
|
16
|
+
#
|
|
17
|
+
# The History browser caches the parsed ``result.json`` of every saved calc as
|
|
18
|
+
# a list of entry dicts on ``app._history_entries`` (built in
|
|
19
|
+
# ``refresh_results_browser``). Filtering re-narrows that in-memory list — no
|
|
20
|
+
# per-keystroke disk access — and repopulates ``past_dd`` client-side.
|
|
21
|
+
|
|
22
|
+
# Calc-type facet chips, in display order: (badge label, canonical calc_type key).
|
|
23
|
+
# Mirrors ``_calc_type_badge`` in app_runflow so chips read like the dropdown labels.
|
|
24
|
+
HISTORY_CALC_TYPE_FACETS = [
|
|
25
|
+
("SP", "single_point"),
|
|
26
|
+
("GeoOpt", "geometry_opt"),
|
|
27
|
+
("Freq", "frequency"),
|
|
28
|
+
("UV-Vis", "tddft"),
|
|
29
|
+
("NMR", "nmr"),
|
|
30
|
+
("PES", "pes_scan"),
|
|
31
|
+
("Reorg", "reorganization_energy"),
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
# Status facet chips: (label, key).
|
|
35
|
+
HISTORY_STATUS_FACETS = [
|
|
36
|
+
("Converged", "converged"),
|
|
37
|
+
("Not converged", "not_converged"),
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def entry_date(timestamp: Any) -> Optional[_dt.date]:
|
|
42
|
+
"""Parse a result timestamp (``YYYY-MM-DD_...``) into a ``date``, or None."""
|
|
43
|
+
try:
|
|
44
|
+
return _dt.date.fromisoformat(str(timestamp)[:10])
|
|
45
|
+
except (ValueError, TypeError):
|
|
46
|
+
return None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def filter_history_entries(
|
|
50
|
+
entries: list[dict[str, Any]],
|
|
51
|
+
*,
|
|
52
|
+
text: str = "",
|
|
53
|
+
name_formulas: Optional[Any] = None,
|
|
54
|
+
calc_types: Optional[Any] = None,
|
|
55
|
+
method: Optional[str] = None,
|
|
56
|
+
basis: Optional[str] = None,
|
|
57
|
+
date_from: Optional[_dt.date] = None,
|
|
58
|
+
date_to: Optional[_dt.date] = None,
|
|
59
|
+
statuses: Optional[Any] = None,
|
|
60
|
+
) -> list[dict[str, Any]]:
|
|
61
|
+
"""Return the subset of *entries* matching every active facet.
|
|
62
|
+
|
|
63
|
+
Pure function — no widget or disk access, so it is unit-testable in
|
|
64
|
+
isolation. *entries* is the list of dicts built by
|
|
65
|
+
``refresh_results_browser`` (keys: ``formula``, ``name``, ``calc_type``,
|
|
66
|
+
``method``, ``basis``, ``timestamp``, ``date``, ``converged`` ...).
|
|
67
|
+
|
|
68
|
+
An empty / falsy facet means "no constraint" for that facet:
|
|
69
|
+
|
|
70
|
+
- ``text``: case-insensitive substring match against formula + molecule name.
|
|
71
|
+
- ``name_formulas``: formulas resolved from *text* via the molecule library
|
|
72
|
+
(so a chemical-name query like "benzene" also matches ``C6H6`` results).
|
|
73
|
+
An entry passes the text facet if it matches *either* the substring *or*
|
|
74
|
+
one of these formulas. The DB lookup lives in the caller
|
|
75
|
+
(``apply_history_filter``) to keep this function pure.
|
|
76
|
+
- ``calc_types``: iterable of canonical ``calc_type`` keys; entry passes if
|
|
77
|
+
its ``calc_type`` is in the set.
|
|
78
|
+
- ``method`` / ``basis``: exact match when truthy.
|
|
79
|
+
- ``date_from`` / ``date_to``: inclusive ``date`` bounds on the entry's
|
|
80
|
+
parsed timestamp date (entries with an unparseable date are excluded once
|
|
81
|
+
any bound is set).
|
|
82
|
+
- ``statuses``: subset of ``{"converged", "not_converged"}``.
|
|
83
|
+
"""
|
|
84
|
+
needle = (text or "").strip().lower()
|
|
85
|
+
formula_set = {str(f).lower() for f in (name_formulas or [])}
|
|
86
|
+
ct_set = set(calc_types) if calc_types else None
|
|
87
|
+
status_set = set(statuses) if statuses else None
|
|
88
|
+
out: list[dict[str, Any]] = []
|
|
89
|
+
for e in entries:
|
|
90
|
+
if needle:
|
|
91
|
+
hay = f"{e.get('formula', '')} {e.get('name', '')}".lower()
|
|
92
|
+
matched = needle in hay
|
|
93
|
+
if not matched and formula_set:
|
|
94
|
+
matched = str(e.get("formula", "")).lower() in formula_set
|
|
95
|
+
if not matched:
|
|
96
|
+
continue
|
|
97
|
+
if ct_set is not None and e.get("calc_type") not in ct_set:
|
|
98
|
+
continue
|
|
99
|
+
if method and e.get("method") != method:
|
|
100
|
+
continue
|
|
101
|
+
if basis and e.get("basis") != basis:
|
|
102
|
+
continue
|
|
103
|
+
if date_from or date_to:
|
|
104
|
+
d = e.get("date") or entry_date(e.get("timestamp", ""))
|
|
105
|
+
if d is None:
|
|
106
|
+
continue
|
|
107
|
+
if date_from and d < date_from:
|
|
108
|
+
continue
|
|
109
|
+
if date_to and d > date_to:
|
|
110
|
+
continue
|
|
111
|
+
if status_set is not None:
|
|
112
|
+
key = "converged" if e.get("converged") else "not_converged"
|
|
113
|
+
if key not in status_set:
|
|
114
|
+
continue
|
|
115
|
+
out.append(e)
|
|
116
|
+
return out
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def resolve_query_formulas(query: str) -> set[str]:
|
|
120
|
+
"""Map a free-text chemical-name query to the set of formulas it names, so
|
|
121
|
+
a search like "benzene" also finds ``C6H6`` results.
|
|
122
|
+
|
|
123
|
+
Two precise sources, unioned. Neither matches on synonyms — which would
|
|
124
|
+
wrongly pull "methylbenzene"=toluene into a "benzene" search:
|
|
125
|
+
|
|
126
|
+
1. The curated ``config.COMMON_NAME_TO_FORMULA`` map — covers the simple
|
|
127
|
+
molecules the bundled library names by formula (benzene, water, …).
|
|
128
|
+
2. Exact library-*name* matches — covers named organics the library carries
|
|
129
|
+
properly (toluene, aspirin, caffeine). Exact (not substring) so
|
|
130
|
+
"benzene" can't drag in "ethylbenzene"/"nitrobenzene" etc.
|
|
131
|
+
|
|
132
|
+
Returns an empty set for a blank query, or if nothing resolves. Failures
|
|
133
|
+
(missing library) are swallowed so search never breaks.
|
|
134
|
+
"""
|
|
135
|
+
q = (query or "").strip().lower()
|
|
136
|
+
if not q:
|
|
137
|
+
return set()
|
|
138
|
+
formulas: set[str] = set()
|
|
139
|
+
try:
|
|
140
|
+
from quantui import config
|
|
141
|
+
|
|
142
|
+
# Curated names use substring so type-to-narrow works within the map
|
|
143
|
+
# (e.g. "hydrogen" surfaces H2 + the hydrogen-X molecules as you type).
|
|
144
|
+
for name, formula in config.COMMON_NAME_TO_FORMULA.items():
|
|
145
|
+
if q in name:
|
|
146
|
+
formulas.add(formula)
|
|
147
|
+
except Exception:
|
|
148
|
+
pass
|
|
149
|
+
try:
|
|
150
|
+
from quantui import molecule_library as _ml
|
|
151
|
+
|
|
152
|
+
# Library: exact entry-*name* match only. Substring/synonym matching
|
|
153
|
+
# would wrongly pull derivatives ("ethylbenzene", "methylbenzene")
|
|
154
|
+
# into a "benzene" search.
|
|
155
|
+
for r in _ml.search(q, limit=200):
|
|
156
|
+
if r.get("formula") and str(r.get("name", "")).lower() == q:
|
|
157
|
+
formulas.add(r["formula"])
|
|
158
|
+
except Exception:
|
|
159
|
+
pass
|
|
160
|
+
return formulas
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def refresh_history_facet_options(app: Any, entries: list[dict[str, Any]]) -> None:
|
|
164
|
+
"""Repopulate the Method / Basis facet dropdowns from the distinct values
|
|
165
|
+
present in *entries*, preserving the current selection when it survives."""
|
|
166
|
+
methods = sorted(
|
|
167
|
+
{e["method"] for e in entries if e.get("method") and e["method"] != "?"}
|
|
168
|
+
)
|
|
169
|
+
bases = sorted(
|
|
170
|
+
{e["basis"] for e in entries if e.get("basis") and e["basis"] != "?"}
|
|
171
|
+
)
|
|
172
|
+
method_dd = getattr(app, "history_method_dd", None)
|
|
173
|
+
if method_dd is not None:
|
|
174
|
+
cur = method_dd.value
|
|
175
|
+
method_dd.options = [("Any method", "")] + [(m, m) for m in methods]
|
|
176
|
+
method_dd.value = cur if cur in methods else ""
|
|
177
|
+
basis_dd = getattr(app, "history_basis_dd", None)
|
|
178
|
+
if basis_dd is not None:
|
|
179
|
+
cur = basis_dd.value
|
|
180
|
+
basis_dd.options = [("Any basis", "")] + [(b, b) for b in bases]
|
|
181
|
+
basis_dd.value = cur if cur in bases else ""
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def apply_history_filter(app: Any) -> None:
|
|
185
|
+
"""Re-narrow the cached history entries by the current facet-widget state
|
|
186
|
+
and repopulate ``past_dd`` — no disk access.
|
|
187
|
+
|
|
188
|
+
Preserves the index-0 placeholder and (via ipywidgets value-preservation)
|
|
189
|
+
the current selection when it survives the filter. Shows an explicit
|
|
190
|
+
"no matches" option when every entry is filtered out.
|
|
191
|
+
"""
|
|
192
|
+
if getattr(app, "_history_filter_suspend", False):
|
|
193
|
+
return
|
|
194
|
+
entries = getattr(app, "_history_entries", None)
|
|
195
|
+
if not entries:
|
|
196
|
+
# Nothing cached yet (pre-scan) — leave whatever placeholder is set.
|
|
197
|
+
return
|
|
198
|
+
calc_types = [
|
|
199
|
+
key for key, btn in getattr(app, "_history_calc_chips", {}).items() if btn.value
|
|
200
|
+
]
|
|
201
|
+
statuses = [
|
|
202
|
+
key
|
|
203
|
+
for key, btn in getattr(app, "_history_status_chips", {}).items()
|
|
204
|
+
if btn.value
|
|
205
|
+
]
|
|
206
|
+
text = getattr(getattr(app, "history_search", None), "value", "") or ""
|
|
207
|
+
matches = filter_history_entries(
|
|
208
|
+
entries,
|
|
209
|
+
text=text,
|
|
210
|
+
name_formulas=resolve_query_formulas(text),
|
|
211
|
+
calc_types=calc_types or None,
|
|
212
|
+
method=getattr(getattr(app, "history_method_dd", None), "value", "") or None,
|
|
213
|
+
basis=getattr(getattr(app, "history_basis_dd", None), "value", "") or None,
|
|
214
|
+
date_from=getattr(getattr(app, "history_date_from", None), "value", None),
|
|
215
|
+
date_to=getattr(getattr(app, "history_date_to", None), "value", None),
|
|
216
|
+
statuses=statuses or None,
|
|
217
|
+
)
|
|
218
|
+
placeholder = ("(select a calculation to view)", "")
|
|
219
|
+
if matches:
|
|
220
|
+
app.past_dd.options = [placeholder] + [(e["label"], e["path"]) for e in matches]
|
|
221
|
+
else:
|
|
222
|
+
app.past_dd.options = [placeholder, ("(no matches for current filters)", "")]
|
|
223
|
+
count_lbl = getattr(app, "history_count_lbl", None)
|
|
224
|
+
if count_lbl is not None:
|
|
225
|
+
count_lbl.value = (
|
|
226
|
+
'<span style="color:#888;font-size:12px">'
|
|
227
|
+
f"{len(matches)} of {len(entries)} shown</span>"
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
class _LoadTimer:
|
|
232
|
+
"""Per-stage timing collector for a history-load operation.
|
|
233
|
+
|
|
234
|
+
Used as: open one ``_LoadTimer`` at the top of each loader, wrap each
|
|
235
|
+
interesting sub-stage in ``with timer.stage("name"):``, then call
|
|
236
|
+
``timer.emit(status=...)`` exactly once (from the loader's ``finally``
|
|
237
|
+
block). One ``history_load_timing`` event is appended to
|
|
238
|
+
``event_log.jsonl`` per load with the total elapsed time and a per-stage
|
|
239
|
+
breakdown. The data drives the latency-optimization pass — until
|
|
240
|
+
we know which stage dominates, we don't know which to optimize.
|
|
241
|
+
|
|
242
|
+
Failures inside ``calc_log.log_event`` are swallowed: telemetry must
|
|
243
|
+
never block the actual load.
|
|
244
|
+
"""
|
|
245
|
+
|
|
246
|
+
def __init__(self, op_name: str, result_dir: Path) -> None:
|
|
247
|
+
self.op_name = op_name
|
|
248
|
+
self.result_dir = result_dir
|
|
249
|
+
self._t0 = time.perf_counter()
|
|
250
|
+
self._stages: dict[str, float] = {}
|
|
251
|
+
|
|
252
|
+
@contextmanager
|
|
253
|
+
def stage(self, name: str):
|
|
254
|
+
s0 = time.perf_counter()
|
|
255
|
+
try:
|
|
256
|
+
yield
|
|
257
|
+
finally:
|
|
258
|
+
self._stages[name] = round((time.perf_counter() - s0) * 1000.0, 2)
|
|
259
|
+
|
|
260
|
+
def emit(self, status: str = "ok") -> None:
|
|
261
|
+
total_ms = round((time.perf_counter() - self._t0) * 1000.0, 2)
|
|
262
|
+
try:
|
|
263
|
+
from quantui import calc_log as _clog
|
|
264
|
+
|
|
265
|
+
stage_msg = " ".join(f"{k}={v}ms" for k, v in self._stages.items())
|
|
266
|
+
_clog.log_event(
|
|
267
|
+
"history_load_timing",
|
|
268
|
+
f"{self.op_name} {self.result_dir.name} "
|
|
269
|
+
f"total={total_ms}ms status={status} {stage_msg}".rstrip(),
|
|
270
|
+
op=self.op_name,
|
|
271
|
+
result_dir=self.result_dir.name,
|
|
272
|
+
total_ms=total_ms,
|
|
273
|
+
status=status,
|
|
274
|
+
**{f"{k}_ms": v for k, v in self._stages.items()},
|
|
275
|
+
)
|
|
276
|
+
except Exception:
|
|
277
|
+
pass
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def on_past_dd_changed(app: Any, change: dict[str, Any], *, layout_fn: Any) -> None:
|
|
281
|
+
"""Handle history dropdown selection changes."""
|
|
282
|
+
path_str = change["new"]
|
|
283
|
+
# Hide result-specific panels whenever the selection changes so stale
|
|
284
|
+
# content from a previous "View log" click doesn't persist.
|
|
285
|
+
app._deactivate_all_ana_panels()
|
|
286
|
+
app._pending_traj_result = None
|
|
287
|
+
app._result_log_accordion.layout.display = "none"
|
|
288
|
+
app._result_dir_label.layout.display = "none"
|
|
289
|
+
app._iso_generate_btn.disabled = True
|
|
290
|
+
if not path_str:
|
|
291
|
+
app.past_output.clear_output()
|
|
292
|
+
return
|
|
293
|
+
app.past_output.clear_output()
|
|
294
|
+
with app.past_output:
|
|
295
|
+
try:
|
|
296
|
+
from quantui import load_result
|
|
297
|
+
|
|
298
|
+
result_dir = Path(path_str)
|
|
299
|
+
data = load_result(result_dir)
|
|
300
|
+
display(HTML(app._format_past_result(data, result_dir=result_dir)))
|
|
301
|
+
btn_results = widgets.Button(
|
|
302
|
+
description="-> View Results",
|
|
303
|
+
button_style="success",
|
|
304
|
+
layout=layout_fn(width="130px"),
|
|
305
|
+
tooltip="Show this result in the Results tab",
|
|
306
|
+
)
|
|
307
|
+
btn_analysis = widgets.Button(
|
|
308
|
+
description="-> View Analysis",
|
|
309
|
+
button_style="info",
|
|
310
|
+
layout=layout_fn(width="140px"),
|
|
311
|
+
tooltip="Load analysis panels and navigate to the Analysis tab",
|
|
312
|
+
)
|
|
313
|
+
btn_results.on_click(
|
|
314
|
+
lambda _, d=data, rd=result_dir, br=btn_results, ba=btn_analysis: (
|
|
315
|
+
app._history_load_results(d, rd, source_btns=(br, ba))
|
|
316
|
+
)
|
|
317
|
+
)
|
|
318
|
+
btn_analysis.on_click(
|
|
319
|
+
lambda _, rd=result_dir, br=btn_results, ba=btn_analysis: (
|
|
320
|
+
app._history_load_analysis(rd, source_btns=(br, ba))
|
|
321
|
+
)
|
|
322
|
+
)
|
|
323
|
+
display(
|
|
324
|
+
widgets.HBox(
|
|
325
|
+
[btn_results, btn_analysis],
|
|
326
|
+
layout=layout_fn(gap="8px", margin="6px 0 0"),
|
|
327
|
+
)
|
|
328
|
+
)
|
|
329
|
+
except Exception as exc:
|
|
330
|
+
print(f"Could not load result: {exc}")
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def on_view_log(app: Any, btn: Any) -> None:
|
|
334
|
+
"""Handle View Log action for a selected history result."""
|
|
335
|
+
path_str = app.past_dd.value
|
|
336
|
+
if not path_str:
|
|
337
|
+
return
|
|
338
|
+
result_dir = Path(path_str)
|
|
339
|
+
app._last_result_dir = result_dir
|
|
340
|
+
try:
|
|
341
|
+
app._export_bundle_btn.disabled = False
|
|
342
|
+
except Exception:
|
|
343
|
+
pass
|
|
344
|
+
try:
|
|
345
|
+
import quantui.calc_log as _calc_log
|
|
346
|
+
|
|
347
|
+
_calc_log.log_event(
|
|
348
|
+
"history_view",
|
|
349
|
+
result_dir.name,
|
|
350
|
+
result_dir=result_dir.name,
|
|
351
|
+
session_id=app._session_id,
|
|
352
|
+
)
|
|
353
|
+
except Exception:
|
|
354
|
+
pass
|
|
355
|
+
|
|
356
|
+
# Read log text and populate log panel
|
|
357
|
+
log_path = result_dir / "pyscf.log"
|
|
358
|
+
if log_path.exists():
|
|
359
|
+
text = log_path.read_text(encoding="utf-8", errors="replace")
|
|
360
|
+
label = result_dir.name
|
|
361
|
+
else:
|
|
362
|
+
text = "(No pyscf.log found for this result.)"
|
|
363
|
+
label = ""
|
|
364
|
+
app._update_log_panel(text, label)
|
|
365
|
+
app._show_result_log(result_dir, text)
|
|
366
|
+
|
|
367
|
+
# Build analysis context from disk and apply via registry
|
|
368
|
+
ctx = app._build_history_context(result_dir)
|
|
369
|
+
if ctx is not None:
|
|
370
|
+
data_stub = {"calc_type": ctx.calc_type, "spectra": ctx.spectra_data}
|
|
371
|
+
try:
|
|
372
|
+
mol = app._mol_from_result_dir(result_dir, data_stub)
|
|
373
|
+
if mol is not None:
|
|
374
|
+
app._show_result_3d(mol, extra_output=app._analysis_mol_output)
|
|
375
|
+
else:
|
|
376
|
+
app._analysis_mol_output.clear_output()
|
|
377
|
+
except Exception:
|
|
378
|
+
pass
|
|
379
|
+
app._apply_analysis_context(ctx)
|
|
380
|
+
|
|
381
|
+
app._goto_output_tab()
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def mol_from_result_dir(result_dir: Path, data: dict[str, Any]) -> Any:
|
|
385
|
+
"""Try to reconstruct a displayable Molecule from a saved result directory.
|
|
386
|
+
|
|
387
|
+
Returns a Molecule or None if geometry data is not available.
|
|
388
|
+
Tries sources in order: frequency spectra -> orbitals_meta -> trajectory.
|
|
389
|
+
"""
|
|
390
|
+
from quantui.molecule import Molecule
|
|
391
|
+
|
|
392
|
+
calc_type = data.get("calc_type", "")
|
|
393
|
+
|
|
394
|
+
# Frequency: geometry stored inside spectra.molecule
|
|
395
|
+
if calc_type == "frequency":
|
|
396
|
+
mol_data = data.get("spectra", {}).get("molecule", {})
|
|
397
|
+
if mol_data.get("atoms") and mol_data.get("coords"):
|
|
398
|
+
try:
|
|
399
|
+
return Molecule(
|
|
400
|
+
atoms=mol_data["atoms"],
|
|
401
|
+
coordinates=mol_data["coords"],
|
|
402
|
+
charge=mol_data.get("charge", 0),
|
|
403
|
+
multiplicity=mol_data.get("multiplicity", 1),
|
|
404
|
+
)
|
|
405
|
+
except Exception:
|
|
406
|
+
pass
|
|
407
|
+
|
|
408
|
+
# Single point / Geo opt: atom list from orbitals_meta.json
|
|
409
|
+
meta_path = result_dir / "orbitals_meta.json"
|
|
410
|
+
if meta_path.exists():
|
|
411
|
+
try:
|
|
412
|
+
meta = _json.loads(meta_path.read_text())
|
|
413
|
+
mol_atom = meta.get("mol_atom")
|
|
414
|
+
if mol_atom:
|
|
415
|
+
atoms = [sym for sym, _ in mol_atom]
|
|
416
|
+
coords = [coords for _, coords in mol_atom]
|
|
417
|
+
return Molecule(atoms=atoms, coordinates=coords)
|
|
418
|
+
except Exception:
|
|
419
|
+
pass
|
|
420
|
+
|
|
421
|
+
# Geo opt fallback: last step of trajectory.json
|
|
422
|
+
if calc_type == "geometry_opt":
|
|
423
|
+
traj_path = result_dir / "trajectory.json"
|
|
424
|
+
if traj_path.exists():
|
|
425
|
+
try:
|
|
426
|
+
traj_data = _json.loads(traj_path.read_text())
|
|
427
|
+
steps = traj_data.get("steps", [])
|
|
428
|
+
if steps:
|
|
429
|
+
return Molecule(
|
|
430
|
+
atoms=traj_data["atoms"],
|
|
431
|
+
coordinates=steps[-1]["coords"],
|
|
432
|
+
charge=traj_data.get("charge", 0),
|
|
433
|
+
multiplicity=traj_data.get("multiplicity", 1),
|
|
434
|
+
)
|
|
435
|
+
except Exception:
|
|
436
|
+
pass
|
|
437
|
+
|
|
438
|
+
return None
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def _begin_history_load(app: Any, message: str, source_btns: tuple) -> None:
|
|
442
|
+
"""Show immediate feedback when a history-load action starts.
|
|
443
|
+
|
|
444
|
+
Lights the toolbar activity indicator and disables the source buttons so
|
|
445
|
+
a second click can't fire a parallel load. Both actions are best-effort —
|
|
446
|
+
failure to update a button (e.g. it was already destroyed) must not block
|
|
447
|
+
the actual load.
|
|
448
|
+
"""
|
|
449
|
+
for btn in source_btns:
|
|
450
|
+
try:
|
|
451
|
+
btn.disabled = True
|
|
452
|
+
except Exception:
|
|
453
|
+
pass
|
|
454
|
+
try:
|
|
455
|
+
app._activity_begin(message, kind="ui")
|
|
456
|
+
except Exception:
|
|
457
|
+
pass
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
def _end_history_load(app: Any, source_btns: tuple) -> None:
|
|
461
|
+
"""Restore UI state after a history-load action finishes.
|
|
462
|
+
|
|
463
|
+
Mirrors :func:`_begin_history_load`. Called from the loader's ``finally``
|
|
464
|
+
block so the activity indicator + buttons are always restored, even when
|
|
465
|
+
the load raises.
|
|
466
|
+
"""
|
|
467
|
+
try:
|
|
468
|
+
app._activity_end(kind="ui")
|
|
469
|
+
except Exception:
|
|
470
|
+
pass
|
|
471
|
+
for btn in source_btns:
|
|
472
|
+
try:
|
|
473
|
+
btn.disabled = False
|
|
474
|
+
except Exception:
|
|
475
|
+
pass
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
def history_load_results(
|
|
479
|
+
app: Any,
|
|
480
|
+
data: dict[str, Any],
|
|
481
|
+
result_dir: Path,
|
|
482
|
+
*,
|
|
483
|
+
source_btns: tuple = (),
|
|
484
|
+
) -> None:
|
|
485
|
+
"""Display a history result card in the Results tab and navigate there.
|
|
486
|
+
|
|
487
|
+
``source_btns`` is an optional tuple of button widgets to disable while
|
|
488
|
+
the load is in flight (immediate-loading-feedback contract). Tests
|
|
489
|
+
and callers that don't have a button reference can omit it.
|
|
490
|
+
|
|
491
|
+
Stage timings are emitted as a single ``history_load_timing`` event on
|
|
492
|
+
completion (drives latency-optimization decisions).
|
|
493
|
+
"""
|
|
494
|
+
_begin_history_load(app, "Loading history result…", source_btns)
|
|
495
|
+
timer = _LoadTimer("history_load_results", result_dir)
|
|
496
|
+
status = "ok"
|
|
497
|
+
try:
|
|
498
|
+
app._last_result_dir = result_dir
|
|
499
|
+
try:
|
|
500
|
+
app._export_bundle_btn.disabled = False
|
|
501
|
+
except Exception:
|
|
502
|
+
pass
|
|
503
|
+
with timer.stage("format_result_html"):
|
|
504
|
+
app.result_output.clear_output()
|
|
505
|
+
with app.result_output:
|
|
506
|
+
display(HTML(app._format_past_result(data, result_dir=result_dir)))
|
|
507
|
+
app._result_dir_label.layout.display = "none"
|
|
508
|
+
with timer.stage("mol_reconstruction"):
|
|
509
|
+
mol = app._mol_from_result_dir(result_dir, data)
|
|
510
|
+
if mol is not None:
|
|
511
|
+
with timer.stage("show_result_3d"):
|
|
512
|
+
app._show_result_3d(mol)
|
|
513
|
+
# Also populate the Analysis tab so the two tabs stay in sync.
|
|
514
|
+
# Without this, clicking "View Results" left Analysis showing the
|
|
515
|
+
# previously-loaded calc (or empty panels), which surprised users
|
|
516
|
+
# who expected loading a history item to refresh both views.
|
|
517
|
+
with timer.stage("build_context"):
|
|
518
|
+
ctx = app._build_history_context(result_dir)
|
|
519
|
+
if ctx is not None:
|
|
520
|
+
with timer.stage("analysis_mol_render"):
|
|
521
|
+
try:
|
|
522
|
+
if mol is not None:
|
|
523
|
+
app._show_result_3d(mol, extra_output=app._analysis_mol_output)
|
|
524
|
+
else:
|
|
525
|
+
app._analysis_mol_output.clear_output()
|
|
526
|
+
except Exception:
|
|
527
|
+
pass
|
|
528
|
+
with timer.stage("apply_analysis_context"):
|
|
529
|
+
app._apply_analysis_context(ctx)
|
|
530
|
+
with timer.stage("nav_tab"):
|
|
531
|
+
app.root_tab.selected_index = 1
|
|
532
|
+
except Exception:
|
|
533
|
+
status = "error"
|
|
534
|
+
raise
|
|
535
|
+
finally:
|
|
536
|
+
timer.emit(status=status)
|
|
537
|
+
_end_history_load(app, source_btns)
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
def history_load_analysis(
|
|
541
|
+
app: Any,
|
|
542
|
+
result_dir: Path,
|
|
543
|
+
*,
|
|
544
|
+
source_btns: tuple = (),
|
|
545
|
+
) -> None:
|
|
546
|
+
"""Load analysis panels for a history result and navigate to Analysis tab.
|
|
547
|
+
|
|
548
|
+
``source_btns`` is an optional tuple of button widgets to disable while
|
|
549
|
+
the load is in flight (immediate-loading-feedback contract). Tests
|
|
550
|
+
and callers that don't have a button reference can omit it.
|
|
551
|
+
|
|
552
|
+
Stage timings are emitted as a single ``history_load_timing`` event on
|
|
553
|
+
completion (drives latency-optimization decisions). Stages cover
|
|
554
|
+
the four expected hotspots: pyscf.log read, context build, molecule
|
|
555
|
+
reconstruction, 3D viewer render, and the analysis-context registry walk.
|
|
556
|
+
"""
|
|
557
|
+
_begin_history_load(app, "Loading analysis from history…", source_btns)
|
|
558
|
+
timer = _LoadTimer("history_load_analysis", result_dir)
|
|
559
|
+
status = "ok"
|
|
560
|
+
try:
|
|
561
|
+
app._last_result_dir = result_dir
|
|
562
|
+
try:
|
|
563
|
+
app._export_bundle_btn.disabled = False
|
|
564
|
+
except Exception:
|
|
565
|
+
pass
|
|
566
|
+
with timer.stage("read_pyscf_log"):
|
|
567
|
+
log_path = result_dir / "pyscf.log"
|
|
568
|
+
text = (
|
|
569
|
+
log_path.read_text(encoding="utf-8", errors="replace")
|
|
570
|
+
if log_path.exists()
|
|
571
|
+
else "(No pyscf.log found for this result.)"
|
|
572
|
+
)
|
|
573
|
+
with timer.stage("update_log_panel"):
|
|
574
|
+
app._update_log_panel(result_dir.name if log_path.exists() else "", text)
|
|
575
|
+
app._show_result_log(result_dir, text)
|
|
576
|
+
|
|
577
|
+
with timer.stage("build_context"):
|
|
578
|
+
ctx = app._build_history_context(result_dir)
|
|
579
|
+
if ctx is not None:
|
|
580
|
+
data_stub = {"calc_type": ctx.calc_type, "spectra": ctx.spectra_data}
|
|
581
|
+
with timer.stage("mol_reconstruction"):
|
|
582
|
+
try:
|
|
583
|
+
mol = app._mol_from_result_dir(result_dir, data_stub)
|
|
584
|
+
except Exception:
|
|
585
|
+
mol = None
|
|
586
|
+
with timer.stage("show_result_3d"):
|
|
587
|
+
try:
|
|
588
|
+
if mol is not None:
|
|
589
|
+
app._show_result_3d(mol, extra_output=app._analysis_mol_output)
|
|
590
|
+
else:
|
|
591
|
+
app._analysis_mol_output.clear_output()
|
|
592
|
+
except Exception:
|
|
593
|
+
pass
|
|
594
|
+
with timer.stage("apply_analysis_context"):
|
|
595
|
+
app._apply_analysis_context(ctx)
|
|
596
|
+
|
|
597
|
+
with timer.stage("nav_tab"):
|
|
598
|
+
app.root_tab.selected_index = 2
|
|
599
|
+
except Exception:
|
|
600
|
+
status = "error"
|
|
601
|
+
raise
|
|
602
|
+
finally:
|
|
603
|
+
timer.emit(status=status)
|
|
604
|
+
_end_history_load(app, source_btns)
|
|
605
|
+
|
|
606
|
+
|
|
607
|
+
def build_history_context(result_dir: Path, *, context_cls: Any) -> Optional[Any]:
|
|
608
|
+
"""Load result.json from result_dir and return an analysis context."""
|
|
609
|
+
try:
|
|
610
|
+
from quantui import load_result
|
|
611
|
+
|
|
612
|
+
data = load_result(result_dir)
|
|
613
|
+
except Exception:
|
|
614
|
+
return None
|
|
615
|
+
return context_cls(
|
|
616
|
+
calc_type=data.get("calc_type", ""),
|
|
617
|
+
formula=data.get("formula", result_dir.name),
|
|
618
|
+
method=data.get("method", ""),
|
|
619
|
+
basis=data.get("basis", ""),
|
|
620
|
+
result_dir=result_dir,
|
|
621
|
+
spectra_data=data.get("spectra", {}),
|
|
622
|
+
timestamp=data.get("timestamp", ""),
|
|
623
|
+
source="history",
|
|
624
|
+
)
|