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_analysis.py
ADDED
|
@@ -0,0 +1,662 @@
|
|
|
1
|
+
"""Analysis panel state and population helpers used by QuantUIApp."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import html as _html_mod
|
|
6
|
+
import types as _types_mod
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import ipywidgets as widgets
|
|
10
|
+
|
|
11
|
+
_PANEL_UNAVAILABLE_STYLE = (
|
|
12
|
+
"padding:12px 16px;color:#6b7280;font-size:13px;font-style:italic"
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
_CALC_TYPE_LABELS = {
|
|
16
|
+
"single_point": "Single Point",
|
|
17
|
+
"geometry_opt": "Geometry Opt",
|
|
18
|
+
"frequency": "Frequency",
|
|
19
|
+
"tddft": "UV-Vis (TD-DFT)",
|
|
20
|
+
"nmr": "NMR Shielding",
|
|
21
|
+
"pes_scan": "PES Scan",
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
_CALC_TYPE_BADGES = {
|
|
25
|
+
"single_point": "Single Point",
|
|
26
|
+
"geometry_opt": "Geometry Optimization",
|
|
27
|
+
"frequency": "Frequency Analysis",
|
|
28
|
+
"tddft": "UV-Vis (TD-DFT)",
|
|
29
|
+
"nmr": "NMR Shielding",
|
|
30
|
+
"pes_scan": "PES Scan",
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _panel_unavailable_html(message: str) -> str:
|
|
35
|
+
return f'<div style="{_PANEL_UNAVAILABLE_STYLE}">{_html_mod.escape(message)}</div>'
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _set_panel_unavailable_message(app: Any, panel_name: str, message: str) -> None:
|
|
39
|
+
panel = app._ana_unavail_msgs.get(panel_name)
|
|
40
|
+
if panel is not None:
|
|
41
|
+
panel.value = _panel_unavailable_html(message)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _reset_unavailable_messages_for_context(app: Any, ctx: Any) -> None:
|
|
45
|
+
expected_panels = {
|
|
46
|
+
panel_name
|
|
47
|
+
for panel_name, _method_name, _auto in app._PANEL_REGISTRY.get(
|
|
48
|
+
ctx.calc_type, []
|
|
49
|
+
)
|
|
50
|
+
}
|
|
51
|
+
calc_label = _CALC_TYPE_LABELS.get(
|
|
52
|
+
ctx.calc_type,
|
|
53
|
+
str(ctx.calc_type).replace("_", " ").title(),
|
|
54
|
+
)
|
|
55
|
+
for panel_name in app._ana_panel_names:
|
|
56
|
+
if panel_name in expected_panels:
|
|
57
|
+
_set_panel_unavailable_message(
|
|
58
|
+
app,
|
|
59
|
+
panel_name,
|
|
60
|
+
(
|
|
61
|
+
f"Not available for this {calc_label} result: "
|
|
62
|
+
"required data is missing or could not be loaded."
|
|
63
|
+
),
|
|
64
|
+
)
|
|
65
|
+
continue
|
|
66
|
+
when = app._ana_when_map.get(panel_name, "relevant")
|
|
67
|
+
_set_panel_unavailable_message(
|
|
68
|
+
app,
|
|
69
|
+
panel_name,
|
|
70
|
+
f"Not available - run a {when} calculation first.",
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _analysis_heading_label(ctx: Any) -> str:
|
|
75
|
+
"""Return the analysis heading text aligned with history dropdown labels."""
|
|
76
|
+
badge = _CALC_TYPE_BADGES.get(ctx.calc_type, str(ctx.calc_type or "Unknown"))
|
|
77
|
+
core = f"[{badge}] {ctx.label}"
|
|
78
|
+
ts = str(getattr(ctx, "timestamp", "") or "").strip()
|
|
79
|
+
return f"{ts} · {core}" if ts else core
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def build_ana_switcher(app: Any, *, layout_fn: Any) -> None:
|
|
83
|
+
"""Initialise analysis panel state and wire accordion re-render observers."""
|
|
84
|
+
panel_meta = [
|
|
85
|
+
(name, getattr(app, attr), when) for name, attr, when in app._PANEL_META
|
|
86
|
+
]
|
|
87
|
+
app._ana_when_map = {name: when for name, _acc, when in panel_meta}
|
|
88
|
+
app._ana_panel_names = [m[0] for m in panel_meta]
|
|
89
|
+
app._ana_accordions = [m[1] for m in panel_meta]
|
|
90
|
+
app._ana_available = set()
|
|
91
|
+
app._ana_active = ""
|
|
92
|
+
app._ana_unavail_html = widgets.HTML(
|
|
93
|
+
value="",
|
|
94
|
+
layout=layout_fn(display="none", margin="4px 0 8px"),
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
# Wrap each accordion child with both an unavailable message and real content.
|
|
98
|
+
app._ana_unavail_msgs = {}
|
|
99
|
+
app._ana_content_boxes = {}
|
|
100
|
+
for name, acc, when in panel_meta:
|
|
101
|
+
unavail = widgets.HTML(
|
|
102
|
+
value=_panel_unavailable_html(
|
|
103
|
+
f"Not available - run a {when} calculation first."
|
|
104
|
+
),
|
|
105
|
+
layout=layout_fn(display=""),
|
|
106
|
+
)
|
|
107
|
+
content = acc.children[0]
|
|
108
|
+
app._ana_unavail_msgs[name] = unavail
|
|
109
|
+
app._ana_content_boxes[name] = content
|
|
110
|
+
content.layout.display = "none"
|
|
111
|
+
acc.children = (widgets.VBox([unavail, content]),)
|
|
112
|
+
acc.layout.display = "" # always in the DOM
|
|
113
|
+
acc.selected_index = None # collapsed until activated
|
|
114
|
+
|
|
115
|
+
# Re-render Plotly charts when their accordion is expanded by header click.
|
|
116
|
+
app._ir_accordion.observe(
|
|
117
|
+
app._safe_cb(app._on_ir_accordion_show), names=["selected_index"]
|
|
118
|
+
)
|
|
119
|
+
app._tddft_accordion.observe(
|
|
120
|
+
app._safe_cb(app._on_tddft_accordion_show), names=["selected_index"]
|
|
121
|
+
)
|
|
122
|
+
app._orb_accordion.observe(
|
|
123
|
+
app._safe_cb(app._on_orb_accordion_show), names=["selected_index"]
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def select_ana_panel(app: Any, name: str) -> None:
|
|
128
|
+
"""Expand the named panel and collapse all others."""
|
|
129
|
+
app._ana_active = name
|
|
130
|
+
app._ana_unavail_html.layout.display = "none"
|
|
131
|
+
for panel_name, acc in zip(app._ana_panel_names, app._ana_accordions):
|
|
132
|
+
acc.selected_index = 0 if panel_name == name else None
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def activate_ana_panel(app: Any, name: str, auto_select: bool = True) -> None:
|
|
136
|
+
"""Mark a panel as available and reveal its content."""
|
|
137
|
+
app._ana_available.add(name)
|
|
138
|
+
if name in app._ana_unavail_msgs:
|
|
139
|
+
app._ana_unavail_msgs[name].layout.display = "none"
|
|
140
|
+
app._ana_content_boxes[name].layout.display = ""
|
|
141
|
+
if auto_select:
|
|
142
|
+
app._select_ana_panel(name)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def deactivate_all_ana_panels(app: Any) -> None:
|
|
146
|
+
"""Reset all panels to collapsed/unavailable for a new run/context."""
|
|
147
|
+
app._ana_available.clear()
|
|
148
|
+
app._ana_active = ""
|
|
149
|
+
app._ana_unavail_html.layout.display = "none"
|
|
150
|
+
for name, acc in zip(app._ana_panel_names, app._ana_accordions):
|
|
151
|
+
if name in app._ana_unavail_msgs:
|
|
152
|
+
app._ana_unavail_msgs[name].layout.display = ""
|
|
153
|
+
app._ana_content_boxes[name].layout.display = "none"
|
|
154
|
+
acc.selected_index = None
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def apply_analysis_context(app: Any, ctx: Any) -> None:
|
|
158
|
+
"""Populate Analysis panels from context and activate panels with data."""
|
|
159
|
+
app._deactivate_all_ana_panels()
|
|
160
|
+
_reset_unavailable_messages_for_context(app, ctx)
|
|
161
|
+
app._pending_traj_result = None
|
|
162
|
+
# Safety-net cache so on_traj_expand can recover if the initial render's
|
|
163
|
+
# outputs are missing from traj_output by the time the user views the
|
|
164
|
+
# accordion. Cleared here at context reset; re-set by each
|
|
165
|
+
# _pop_*_trajectory populate method.
|
|
166
|
+
app._last_traj_result = None
|
|
167
|
+
app._traj_render_token = int(getattr(app, "_traj_render_token", 0)) + 1
|
|
168
|
+
app._iso_render_token = int(getattr(app, "_iso_render_token", 0)) + 1
|
|
169
|
+
# Orbital state is consumed by pop_isosurface (and ana_pop_iso_generate
|
|
170
|
+
# when the user clicks Generate). Reset here so a context that doesn't
|
|
171
|
+
# populate these fields (history result without orbitals.npz)
|
|
172
|
+
# cannot leak the prior calc's orbital arrays into the Isosurface panel
|
|
173
|
+
# of an unrelated molecule. Each populate method that wants the panel
|
|
174
|
+
# to activate re-sets these in show_orbital_diagram.
|
|
175
|
+
app._last_orb_info = None
|
|
176
|
+
app._last_orb_mo_coeff = None
|
|
177
|
+
app._last_orb_mo_occ = None
|
|
178
|
+
app._last_orb_mol_atom = None
|
|
179
|
+
app._last_orb_mol_basis = None
|
|
180
|
+
app.traj_accordion.set_title(0, "Trajectory Viewer")
|
|
181
|
+
# traj_output is a VBox (see app_builders.py traj_output construction);
|
|
182
|
+
# clear children instead of clear_output.
|
|
183
|
+
app.traj_output.children = ()
|
|
184
|
+
app._orb_iso_output.clear_output()
|
|
185
|
+
|
|
186
|
+
first_auto_selected = False
|
|
187
|
+
expected_panels = {
|
|
188
|
+
panel_name
|
|
189
|
+
for panel_name, _method_name, _want_auto in app._PANEL_REGISTRY.get(
|
|
190
|
+
ctx.calc_type, []
|
|
191
|
+
)
|
|
192
|
+
}
|
|
193
|
+
for panel_name, method_name, want_auto in app._PANEL_REGISTRY.get(
|
|
194
|
+
ctx.calc_type, []
|
|
195
|
+
):
|
|
196
|
+
try:
|
|
197
|
+
ok = bool(getattr(app, method_name)(ctx))
|
|
198
|
+
except Exception as panel_exc:
|
|
199
|
+
ok = False
|
|
200
|
+
try:
|
|
201
|
+
from quantui import calc_log as _clog
|
|
202
|
+
|
|
203
|
+
_clog.log_event(
|
|
204
|
+
"ana_panel_error",
|
|
205
|
+
f"{method_name}: {type(panel_exc).__name__}: {panel_exc}"[:300],
|
|
206
|
+
)
|
|
207
|
+
except Exception:
|
|
208
|
+
pass
|
|
209
|
+
if ok:
|
|
210
|
+
do_auto = want_auto and not first_auto_selected
|
|
211
|
+
app._activate_ana_panel(panel_name, auto_select=do_auto)
|
|
212
|
+
if do_auto:
|
|
213
|
+
first_auto_selected = True
|
|
214
|
+
|
|
215
|
+
missing_expected = sorted(expected_panels - app._ana_available)
|
|
216
|
+
if missing_expected:
|
|
217
|
+
try:
|
|
218
|
+
from quantui import calc_log as _clog
|
|
219
|
+
|
|
220
|
+
_clog.log_event(
|
|
221
|
+
"ana_expected_panel_missing",
|
|
222
|
+
f"{ctx.calc_type}: {', '.join(missing_expected)}"[:300],
|
|
223
|
+
calc_type=ctx.calc_type,
|
|
224
|
+
source=ctx.source,
|
|
225
|
+
missing_panels=missing_expected,
|
|
226
|
+
)
|
|
227
|
+
except Exception:
|
|
228
|
+
pass
|
|
229
|
+
|
|
230
|
+
source_suffix = " (from History)" if ctx.source == "history" else ""
|
|
231
|
+
heading = _analysis_heading_label(ctx)
|
|
232
|
+
app._analysis_context_lbl.value = (
|
|
233
|
+
f'<p style="color:#555;font-size:13px;margin:4px 0 12px">'
|
|
234
|
+
f"Analysing: {_html_mod.escape(heading)}{source_suffix}</p>"
|
|
235
|
+
)
|
|
236
|
+
has_any = bool(app._ana_available)
|
|
237
|
+
app._to_analysis_btn.layout.display = "" if has_any else "none"
|
|
238
|
+
app._analysis_empty_html.layout.display = "none" if has_any else ""
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def pop_energies(app: Any, ctx: Any) -> bool:
|
|
242
|
+
"""Populate Energies panel from live result or history orbitals."""
|
|
243
|
+
result = ctx.live_result
|
|
244
|
+
if result is None and ctx.result_dir is not None:
|
|
245
|
+
try:
|
|
246
|
+
from quantui.results_storage import load_orbitals
|
|
247
|
+
|
|
248
|
+
orb = load_orbitals(ctx.result_dir)
|
|
249
|
+
orb.formula = ctx.formula
|
|
250
|
+
result = orb
|
|
251
|
+
except Exception:
|
|
252
|
+
return False
|
|
253
|
+
return bool(app._show_orbital_diagram(result))
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def pop_isosurface(app: Any, ctx: Any) -> bool:
|
|
257
|
+
"""Populate Isosurface availability from orbital state.
|
|
258
|
+
|
|
259
|
+
Uses ``getattr(..., None)`` for the orbital state fields rather than
|
|
260
|
+
direct attribute access. The attributes are initialized in
|
|
261
|
+
``QuantUIApp.__init__`` and reset in ``apply_analysis_context`` so they
|
|
262
|
+
are always present in practice, but the defensive read mirrors the
|
|
263
|
+
pattern used by ``render_orbital_isosurface`` and keeps this populator
|
|
264
|
+
robust against future refactors that might call it before the context
|
|
265
|
+
reset has run.
|
|
266
|
+
"""
|
|
267
|
+
return (
|
|
268
|
+
getattr(app, "_last_orb_mo_coeff", None) is not None
|
|
269
|
+
and getattr(app, "_last_orb_mol_atom", None) is not None
|
|
270
|
+
and getattr(app, "_last_orb_mol_basis", None) is not None
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def pop_geo_trajectory(app: Any, ctx: Any) -> bool:
|
|
275
|
+
"""Populate Trajectory panel for geometry optimization contexts."""
|
|
276
|
+
traj = None
|
|
277
|
+
energies: list = []
|
|
278
|
+
if ctx.live_result is not None:
|
|
279
|
+
traj = getattr(ctx.live_result, "trajectory", None)
|
|
280
|
+
energies = list(getattr(ctx.live_result, "energies_hartree", []))
|
|
281
|
+
elif ctx.result_dir is not None:
|
|
282
|
+
traj_file = ctx.result_dir / "trajectory.json"
|
|
283
|
+
if not traj_file.exists():
|
|
284
|
+
_set_panel_unavailable_message(
|
|
285
|
+
app,
|
|
286
|
+
"Trajectory",
|
|
287
|
+
(
|
|
288
|
+
"Not available for this Geometry Opt history result: "
|
|
289
|
+
"trajectory.json is missing."
|
|
290
|
+
),
|
|
291
|
+
)
|
|
292
|
+
return False
|
|
293
|
+
try:
|
|
294
|
+
from quantui.results_storage import load_trajectory
|
|
295
|
+
|
|
296
|
+
traj, energies = load_trajectory(ctx.result_dir)
|
|
297
|
+
except Exception as exc:
|
|
298
|
+
_set_panel_unavailable_message(
|
|
299
|
+
app,
|
|
300
|
+
"Trajectory",
|
|
301
|
+
(
|
|
302
|
+
"Not available for this Geometry Opt history result: "
|
|
303
|
+
f"failed to load trajectory data ({type(exc).__name__})."
|
|
304
|
+
),
|
|
305
|
+
)
|
|
306
|
+
return False
|
|
307
|
+
if not traj or len(traj) < 2:
|
|
308
|
+
_set_panel_unavailable_message(
|
|
309
|
+
app,
|
|
310
|
+
"Trajectory",
|
|
311
|
+
(
|
|
312
|
+
"Not available for this Geometry Opt result: "
|
|
313
|
+
"trajectory data has fewer than 2 frames."
|
|
314
|
+
),
|
|
315
|
+
)
|
|
316
|
+
return False
|
|
317
|
+
stub = _types_mod.SimpleNamespace(
|
|
318
|
+
trajectory=traj,
|
|
319
|
+
energies_hartree=energies,
|
|
320
|
+
formula=ctx.formula,
|
|
321
|
+
)
|
|
322
|
+
app._pending_traj_result = stub
|
|
323
|
+
app._last_traj_result = stub
|
|
324
|
+
return True
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def pop_preopt_trajectory(app: Any, ctx: Any) -> bool:
|
|
328
|
+
"""Populate Trajectory panel for the frequency-time DFT geometry
|
|
329
|
+
optimization trajectory.
|
|
330
|
+
|
|
331
|
+
The wrapped operation is a full DFT geom-opt
|
|
332
|
+
at the user's method/basis, not the classical LJ pre-opt that lives
|
|
333
|
+
in ``quantui/preopt.py``. The function name + ``preopt_trajectory.json``
|
|
334
|
+
filename stay (renaming the saved file would break history replay of
|
|
335
|
+
older results) but user-facing strings now say "geometry optimization".
|
|
336
|
+
"""
|
|
337
|
+
if ctx.source == "live":
|
|
338
|
+
pre = ctx.preopt_result
|
|
339
|
+
if pre is None:
|
|
340
|
+
return False
|
|
341
|
+
traj = getattr(pre, "trajectory", None)
|
|
342
|
+
energies = list(getattr(pre, "energies_hartree", []))
|
|
343
|
+
else:
|
|
344
|
+
if ctx.result_dir is None:
|
|
345
|
+
return False
|
|
346
|
+
preopt_path = ctx.result_dir / "preopt_trajectory.json"
|
|
347
|
+
if not preopt_path.exists():
|
|
348
|
+
_set_panel_unavailable_message(
|
|
349
|
+
app,
|
|
350
|
+
"Trajectory",
|
|
351
|
+
(
|
|
352
|
+
"Not available for this Frequency history result: "
|
|
353
|
+
"preopt_trajectory.json is missing (geometry "
|
|
354
|
+
"optimization may have been disabled)."
|
|
355
|
+
),
|
|
356
|
+
)
|
|
357
|
+
return False
|
|
358
|
+
try:
|
|
359
|
+
from quantui.results_storage import load_trajectory
|
|
360
|
+
|
|
361
|
+
traj, energies = load_trajectory(
|
|
362
|
+
ctx.result_dir, filename="preopt_trajectory.json"
|
|
363
|
+
)
|
|
364
|
+
except Exception as exc:
|
|
365
|
+
from quantui import calc_log as _clog
|
|
366
|
+
|
|
367
|
+
_clog.log_event(
|
|
368
|
+
"pop_preopt_trajectory_error",
|
|
369
|
+
f"{type(exc).__name__}: {exc}"[:300],
|
|
370
|
+
)
|
|
371
|
+
_set_panel_unavailable_message(
|
|
372
|
+
app,
|
|
373
|
+
"Trajectory",
|
|
374
|
+
(
|
|
375
|
+
"Not available for this Frequency history result: "
|
|
376
|
+
f"failed to load geometry-optimization trajectory "
|
|
377
|
+
f"({type(exc).__name__})."
|
|
378
|
+
),
|
|
379
|
+
)
|
|
380
|
+
return False
|
|
381
|
+
if not traj or len(traj) < 2:
|
|
382
|
+
_set_panel_unavailable_message(
|
|
383
|
+
app,
|
|
384
|
+
"Trajectory",
|
|
385
|
+
(
|
|
386
|
+
"Not available for this Frequency result: "
|
|
387
|
+
"geometry-optimization trajectory has fewer than 2 frames."
|
|
388
|
+
),
|
|
389
|
+
)
|
|
390
|
+
return False
|
|
391
|
+
stub = _types_mod.SimpleNamespace(
|
|
392
|
+
trajectory=traj,
|
|
393
|
+
energies_hartree=energies,
|
|
394
|
+
formula=ctx.formula,
|
|
395
|
+
)
|
|
396
|
+
app._pending_traj_result = stub
|
|
397
|
+
app._last_traj_result = stub
|
|
398
|
+
app.traj_accordion.set_title(0, "Geometry Optimization Trajectory")
|
|
399
|
+
return True
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def pop_vibrational(app: Any, ctx: Any) -> bool:
|
|
403
|
+
"""Populate Vibrational panel from live or history frequency data."""
|
|
404
|
+
if ctx.live_result is not None:
|
|
405
|
+
freq_stub = ctx.live_result
|
|
406
|
+
mol = ctx.molecule
|
|
407
|
+
else:
|
|
408
|
+
ir = ctx.spectra_data.get("ir", {})
|
|
409
|
+
mol_data = ctx.spectra_data.get("molecule", {})
|
|
410
|
+
freqs = ir.get("frequencies_cm1")
|
|
411
|
+
ints = ir.get("ir_intensities")
|
|
412
|
+
disps = ir.get("displacements")
|
|
413
|
+
if not freqs:
|
|
414
|
+
_set_panel_unavailable_message(
|
|
415
|
+
app,
|
|
416
|
+
"Vibrational",
|
|
417
|
+
(
|
|
418
|
+
"Not available for this Frequency history result: "
|
|
419
|
+
"no frequency data was saved (`frequencies_cm1` empty "
|
|
420
|
+
"or missing). Re-run the Frequency calculation to "
|
|
421
|
+
"populate this panel."
|
|
422
|
+
),
|
|
423
|
+
)
|
|
424
|
+
return False
|
|
425
|
+
if not mol_data.get("atoms"):
|
|
426
|
+
_set_panel_unavailable_message(
|
|
427
|
+
app,
|
|
428
|
+
"Vibrational",
|
|
429
|
+
(
|
|
430
|
+
"Not available for this Frequency history result: "
|
|
431
|
+
"no molecule geometry was saved with the result. "
|
|
432
|
+
"Re-run the Frequency calculation to populate this panel."
|
|
433
|
+
),
|
|
434
|
+
)
|
|
435
|
+
return False
|
|
436
|
+
if not disps:
|
|
437
|
+
_set_panel_unavailable_message(
|
|
438
|
+
app,
|
|
439
|
+
"Vibrational",
|
|
440
|
+
(
|
|
441
|
+
"Not available for this Frequency history result: "
|
|
442
|
+
"per-mode atomic displacements were not persisted with "
|
|
443
|
+
"this calculation (a known limitation for older saved "
|
|
444
|
+
"results — displacements began being written to disk in "
|
|
445
|
+
"a later QuantUI version). Re-run the Frequency "
|
|
446
|
+
"calculation to enable the animation. "
|
|
447
|
+
"The IR Spectrum panel still works for this result."
|
|
448
|
+
),
|
|
449
|
+
)
|
|
450
|
+
return False
|
|
451
|
+
from quantui.molecule import Molecule as _Mol
|
|
452
|
+
|
|
453
|
+
mol = _Mol(
|
|
454
|
+
atoms=mol_data["atoms"],
|
|
455
|
+
coordinates=mol_data["coords"],
|
|
456
|
+
charge=mol_data.get("charge", 0),
|
|
457
|
+
multiplicity=mol_data.get("multiplicity", 1),
|
|
458
|
+
)
|
|
459
|
+
freq_stub = _types_mod.SimpleNamespace(
|
|
460
|
+
frequencies_cm1=freqs,
|
|
461
|
+
ir_intensities=ints,
|
|
462
|
+
displacements=disps,
|
|
463
|
+
)
|
|
464
|
+
return bool(app._show_vib_animation(freq_stub, mol))
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
def pop_ir_spectrum(app: Any, ctx: Any) -> bool:
|
|
468
|
+
"""Populate IR panel from live or history frequency data."""
|
|
469
|
+
if ctx.live_result is not None:
|
|
470
|
+
freq_stub = ctx.live_result
|
|
471
|
+
else:
|
|
472
|
+
ir = ctx.spectra_data.get("ir", {})
|
|
473
|
+
freqs = ir.get("frequencies_cm1")
|
|
474
|
+
if not freqs:
|
|
475
|
+
return False
|
|
476
|
+
freq_stub = _types_mod.SimpleNamespace(
|
|
477
|
+
frequencies_cm1=freqs,
|
|
478
|
+
ir_intensities=ir.get("ir_intensities") or [],
|
|
479
|
+
)
|
|
480
|
+
return bool(app._show_ir_spectrum(freq_stub))
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
def pop_uv_vis(app: Any, ctx: Any) -> bool:
|
|
484
|
+
"""Populate UV-Vis panel from live or history TDDFT data."""
|
|
485
|
+
if ctx.live_result is not None:
|
|
486
|
+
energies_ev = list(getattr(ctx.live_result, "excitation_energies_ev", []))
|
|
487
|
+
osc = list(getattr(ctx.live_result, "oscillator_strengths", []))
|
|
488
|
+
try:
|
|
489
|
+
wl = list(ctx.live_result.wavelengths_nm())
|
|
490
|
+
except Exception:
|
|
491
|
+
wl = [1240.0 / e for e in energies_ev if e > 0]
|
|
492
|
+
else:
|
|
493
|
+
uv = ctx.spectra_data.get("uv_vis", {})
|
|
494
|
+
energies_ev = list(uv.get("excitation_energies_ev") or [])
|
|
495
|
+
osc = list(uv.get("oscillator_strengths") or [])
|
|
496
|
+
wl = list(uv.get("wavelengths_nm") or [])
|
|
497
|
+
if not energies_ev or not osc:
|
|
498
|
+
return False
|
|
499
|
+
return bool(app._show_uv_vis_spectrum(energies_ev, osc, wl))
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
def pop_nmr_shielding(app: Any, ctx: Any) -> bool:
|
|
503
|
+
"""Populate NMR panel from live or history shielding data."""
|
|
504
|
+
if ctx.live_result is not None:
|
|
505
|
+
result = ctx.live_result
|
|
506
|
+
atom_symbols = list(getattr(result, "atom_symbols", []))
|
|
507
|
+
shielding = list(getattr(result, "shielding_iso_ppm", []))
|
|
508
|
+
try:
|
|
509
|
+
h_shifts = result.h_shifts()
|
|
510
|
+
c_shifts = result.c_shifts()
|
|
511
|
+
except Exception:
|
|
512
|
+
h_shifts, c_shifts = [], []
|
|
513
|
+
ref = getattr(result, "reference_compound", "TMS")
|
|
514
|
+
else:
|
|
515
|
+
nmr = ctx.spectra_data.get("nmr", {})
|
|
516
|
+
atom_symbols = nmr.get("atom_symbols", [])
|
|
517
|
+
shielding = nmr.get("shielding_iso_ppm", [])
|
|
518
|
+
chem = nmr.get("chemical_shifts_ppm", {})
|
|
519
|
+
ref = nmr.get("reference_compound", "TMS")
|
|
520
|
+
h_shifts = [
|
|
521
|
+
(int(i), d)
|
|
522
|
+
for i, d in chem.items()
|
|
523
|
+
if int(i) < len(atom_symbols) and atom_symbols[int(i)] == "H"
|
|
524
|
+
]
|
|
525
|
+
c_shifts = [
|
|
526
|
+
(int(i), d)
|
|
527
|
+
for i, d in chem.items()
|
|
528
|
+
if int(i) < len(atom_symbols) and atom_symbols[int(i)] == "C"
|
|
529
|
+
]
|
|
530
|
+
if not atom_symbols:
|
|
531
|
+
return False
|
|
532
|
+
|
|
533
|
+
def _shift_table(label: str, shifts: list, sym: str) -> str:
|
|
534
|
+
if not shifts:
|
|
535
|
+
return ""
|
|
536
|
+
rows = "".join(
|
|
537
|
+
f'<tr><td style="padding:2px 14px 2px 0;color:#555">{sym}-{n}</td>'
|
|
538
|
+
f'<td style="color:#000">{d:.2f} ppm</td></tr>'
|
|
539
|
+
for n, (_i, d) in enumerate(sorted(shifts, key=lambda x: x[0]), 1)
|
|
540
|
+
)
|
|
541
|
+
return (
|
|
542
|
+
f'<tr><td colspan="2" style="padding:8px 0 2px;font-weight:600">'
|
|
543
|
+
f"{label} shifts (vs. {ref}):</td></tr>"
|
|
544
|
+
f'<tr><th style="text-align:left;color:#555;font-size:12px;padding:2px 14px 2px 0">Atom</th>'
|
|
545
|
+
f'<th style="text-align:left;color:#555;font-size:12px">δ (ppm)</th></tr>'
|
|
546
|
+
+ rows
|
|
547
|
+
)
|
|
548
|
+
|
|
549
|
+
shielding_rows = "".join(
|
|
550
|
+
f'<tr><td style="padding:2px 10px 2px 0;color:#555">{sym}{i + 1}</td>'
|
|
551
|
+
f'<td style="color:#000">{s:.2f}</td></tr>'
|
|
552
|
+
for i, (sym, s) in enumerate(zip(atom_symbols, shielding))
|
|
553
|
+
)
|
|
554
|
+
html = (
|
|
555
|
+
f'<div style="font-size:13px">'
|
|
556
|
+
f'<table style="border-collapse:collapse;margin-bottom:8px">'
|
|
557
|
+
f'<tr><th style="text-align:left;color:#555;font-size:12px;padding:2px 10px 2px 0">Atom</th>'
|
|
558
|
+
f'<th style="text-align:left;color:#555;font-size:12px">σ (ppm)</th></tr>'
|
|
559
|
+
f"{shielding_rows}</table>"
|
|
560
|
+
f'<table style="border-collapse:collapse">'
|
|
561
|
+
f"{_shift_table('¹H', h_shifts, 'H')}"
|
|
562
|
+
f"{_shift_table('¹³C', c_shifts, 'C')}"
|
|
563
|
+
f"</table></div>"
|
|
564
|
+
)
|
|
565
|
+
app._nmr_output.value = html
|
|
566
|
+
return True
|
|
567
|
+
|
|
568
|
+
|
|
569
|
+
def pop_pes_plot(app: Any, ctx: Any) -> bool:
|
|
570
|
+
"""Populate PES plot panel from live or history scan data."""
|
|
571
|
+
result = ctx.live_result
|
|
572
|
+
if result is None:
|
|
573
|
+
scan = ctx.spectra_data.get("pes_scan", {})
|
|
574
|
+
if not scan or not scan.get("energies_hartree"):
|
|
575
|
+
return False
|
|
576
|
+
energies_ha = scan["energies_hartree"]
|
|
577
|
+
atom_indices = scan.get("atom_indices", [])
|
|
578
|
+
scan_type = scan.get("scan_type", "bond")
|
|
579
|
+
x_vals = scan.get("scan_parameter_values", [])
|
|
580
|
+
e_min = min(energies_ha)
|
|
581
|
+
hartree_to_kcal = 627.5094740631
|
|
582
|
+
e_rel = [(e - e_min) * hartree_to_kcal for e in energies_ha]
|
|
583
|
+
idx = [i + 1 for i in atom_indices]
|
|
584
|
+
if scan_type == "bond":
|
|
585
|
+
label = f"Bond {idx[0]}–{idx[1]} / Å" if len(idx) >= 2 else "Bond / Å"
|
|
586
|
+
elif scan_type == "angle":
|
|
587
|
+
label = (
|
|
588
|
+
f"Angle {idx[0]}–{idx[1]}–{idx[2]} / °"
|
|
589
|
+
if len(idx) >= 3
|
|
590
|
+
else "Angle / °"
|
|
591
|
+
)
|
|
592
|
+
else:
|
|
593
|
+
label = (
|
|
594
|
+
f"Dihedral {idx[0]}–{idx[1]}–{idx[2]}–{idx[3]} / °"
|
|
595
|
+
if len(idx) >= 4
|
|
596
|
+
else "Dihedral / °"
|
|
597
|
+
)
|
|
598
|
+
result = _types_mod.SimpleNamespace(
|
|
599
|
+
scan_type=scan_type,
|
|
600
|
+
atom_indices=atom_indices,
|
|
601
|
+
scan_parameter_values=x_vals,
|
|
602
|
+
energies_hartree=energies_ha,
|
|
603
|
+
energies_relative_kcal=e_rel,
|
|
604
|
+
scan_coordinate_label=label,
|
|
605
|
+
converged_all=True,
|
|
606
|
+
)
|
|
607
|
+
return bool(app._show_pes_scan_result(result))
|
|
608
|
+
|
|
609
|
+
|
|
610
|
+
def pop_pes_trajectory(app: Any, ctx: Any) -> bool:
|
|
611
|
+
"""Populate Trajectory panel from live or history PES scan data."""
|
|
612
|
+
traj: list = []
|
|
613
|
+
energies: list = []
|
|
614
|
+
if ctx.live_result is not None:
|
|
615
|
+
traj = list(getattr(ctx.live_result, "coordinates_list", []))
|
|
616
|
+
energies = list(getattr(ctx.live_result, "energies_hartree", []))
|
|
617
|
+
elif ctx.result_dir is not None:
|
|
618
|
+
traj_file = ctx.result_dir / "trajectory.json"
|
|
619
|
+
if not traj_file.exists():
|
|
620
|
+
_set_panel_unavailable_message(
|
|
621
|
+
app,
|
|
622
|
+
"Trajectory",
|
|
623
|
+
(
|
|
624
|
+
"Not available for this PES Scan history result: "
|
|
625
|
+
"trajectory.json is missing."
|
|
626
|
+
),
|
|
627
|
+
)
|
|
628
|
+
return False
|
|
629
|
+
try:
|
|
630
|
+
from quantui.results_storage import load_trajectory
|
|
631
|
+
|
|
632
|
+
traj, energies = load_trajectory(ctx.result_dir)
|
|
633
|
+
except Exception as exc:
|
|
634
|
+
_set_panel_unavailable_message(
|
|
635
|
+
app,
|
|
636
|
+
"Trajectory",
|
|
637
|
+
(
|
|
638
|
+
"Not available for this PES Scan history result: "
|
|
639
|
+
f"failed to load trajectory data ({type(exc).__name__})."
|
|
640
|
+
),
|
|
641
|
+
)
|
|
642
|
+
return False
|
|
643
|
+
if not traj or len(traj) < 2:
|
|
644
|
+
_set_panel_unavailable_message(
|
|
645
|
+
app,
|
|
646
|
+
"Trajectory",
|
|
647
|
+
(
|
|
648
|
+
"Not available for this PES Scan result: "
|
|
649
|
+
"trajectory data has fewer than 2 frames."
|
|
650
|
+
),
|
|
651
|
+
)
|
|
652
|
+
return False
|
|
653
|
+
stub = _types_mod.SimpleNamespace(
|
|
654
|
+
coordinates_list=traj,
|
|
655
|
+
energies_hartree=energies,
|
|
656
|
+
trajectory=None,
|
|
657
|
+
formula=ctx.formula,
|
|
658
|
+
)
|
|
659
|
+
app._pending_traj_result = stub
|
|
660
|
+
app._last_traj_result = stub
|
|
661
|
+
app.traj_accordion.set_title(0, "Geometry at Each Scan Point")
|
|
662
|
+
return True
|