quantui 0.5.1__py3-none-any.whl

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