quantui 0.6.0__tar.gz → 0.6.1__tar.gz
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-0.6.0 → quantui-0.6.1}/CHANGELOG.md +42 -1
- {quantui-0.6.0/quantui.egg-info → quantui-0.6.1}/PKG-INFO +1 -1
- {quantui-0.6.0 → quantui-0.6.1}/pyproject.toml +1 -1
- {quantui-0.6.0 → quantui-0.6.1}/quantui/__init__.py +1 -1
- {quantui-0.6.0 → quantui-0.6.1}/quantui/app.py +31 -0
- {quantui-0.6.0 → quantui-0.6.1}/quantui/app_analysis.py +104 -0
- {quantui-0.6.0 → quantui-0.6.1}/quantui/app_builders.py +57 -0
- {quantui-0.6.0 → quantui-0.6.1}/quantui/app_formatters.py +260 -23
- {quantui-0.6.0 → quantui-0.6.1}/quantui/app_runflow.py +16 -0
- {quantui-0.6.0 → quantui-0.6.1}/quantui/app_visualization.py +194 -0
- {quantui-0.6.0 → quantui-0.6.1}/quantui/reorganization_energy.py +93 -0
- {quantui-0.6.0 → quantui-0.6.1}/quantui/results_storage.py +67 -0
- {quantui-0.6.0 → quantui-0.6.1/quantui.egg-info}/PKG-INFO +1 -1
- {quantui-0.6.0 → quantui-0.6.1}/LICENSE +0 -0
- {quantui-0.6.0 → quantui-0.6.1}/MANIFEST.in +0 -0
- {quantui-0.6.0 → quantui-0.6.1}/README.md +0 -0
- {quantui-0.6.0 → quantui-0.6.1}/SECURITY.md +0 -0
- {quantui-0.6.0 → quantui-0.6.1}/quantui/analytics.py +0 -0
- {quantui-0.6.0 → quantui-0.6.1}/quantui/app_exports.py +0 -0
- {quantui-0.6.0 → quantui-0.6.1}/quantui/app_history.py +0 -0
- {quantui-0.6.0 → quantui-0.6.1}/quantui/ase_bridge.py +0 -0
- {quantui-0.6.0 → quantui-0.6.1}/quantui/benchmarks.py +0 -0
- {quantui-0.6.0 → quantui-0.6.1}/quantui/c_stderr.py +0 -0
- {quantui-0.6.0 → quantui-0.6.1}/quantui/cactus.py +0 -0
- {quantui-0.6.0 → quantui-0.6.1}/quantui/calc_log.py +0 -0
- {quantui-0.6.0 → quantui-0.6.1}/quantui/calculator.py +0 -0
- {quantui-0.6.0 → quantui-0.6.1}/quantui/cancellation.py +0 -0
- {quantui-0.6.0 → quantui-0.6.1}/quantui/cli.py +0 -0
- {quantui-0.6.0 → quantui-0.6.1}/quantui/comparison.py +0 -0
- {quantui-0.6.0 → quantui-0.6.1}/quantui/config.py +0 -0
- {quantui-0.6.0 → quantui-0.6.1}/quantui/data/js/3Dmol-min.js +0 -0
- {quantui-0.6.0 → quantui-0.6.1}/quantui/data/js/3Dmol-min.js.LICENSE.txt +0 -0
- {quantui-0.6.0 → quantui-0.6.1}/quantui/data/library/library.sqlite +0 -0
- {quantui-0.6.0 → quantui-0.6.1}/quantui/data/manifests/bulk_qm9.json +0 -0
- {quantui-0.6.0 → quantui-0.6.1}/quantui/data/manifests/curated.json +0 -0
- {quantui-0.6.0 → quantui-0.6.1}/quantui/data/manifests/presets.json +0 -0
- {quantui-0.6.0 → quantui-0.6.1}/quantui/descriptor_cards.py +0 -0
- {quantui-0.6.0 → quantui-0.6.1}/quantui/freq_calc.py +0 -0
- {quantui-0.6.0 → quantui-0.6.1}/quantui/freq_ir_workers.py +0 -0
- {quantui-0.6.0 → quantui-0.6.1}/quantui/gpu_offload.py +0 -0
- {quantui-0.6.0 → quantui-0.6.1}/quantui/help_content.py +0 -0
- {quantui-0.6.0 → quantui-0.6.1}/quantui/ir_plot.py +0 -0
- {quantui-0.6.0 → quantui-0.6.1}/quantui/issue_tracker.py +0 -0
- {quantui-0.6.0 → quantui-0.6.1}/quantui/live_log.py +0 -0
- {quantui-0.6.0 → quantui-0.6.1}/quantui/log_utils.py +0 -0
- {quantui-0.6.0 → quantui-0.6.1}/quantui/molecule.py +0 -0
- {quantui-0.6.0 → quantui-0.6.1}/quantui/molecule_library.py +0 -0
- {quantui-0.6.0 → quantui-0.6.1}/quantui/nmr_calc.py +0 -0
- {quantui-0.6.0 → quantui-0.6.1}/quantui/optimizer.py +0 -0
- {quantui-0.6.0 → quantui-0.6.1}/quantui/orbital_visualization.py +0 -0
- {quantui-0.6.0 → quantui-0.6.1}/quantui/pes_scan.py +0 -0
- {quantui-0.6.0 → quantui-0.6.1}/quantui/preopt.py +0 -0
- {quantui-0.6.0 → quantui-0.6.1}/quantui/progress.py +0 -0
- {quantui-0.6.0 → quantui-0.6.1}/quantui/pubchem.py +0 -0
- {quantui-0.6.0 → quantui-0.6.1}/quantui/security.py +0 -0
- {quantui-0.6.0 → quantui-0.6.1}/quantui/session_calc.py +0 -0
- {quantui-0.6.0 → quantui-0.6.1}/quantui/structure_providers.py +0 -0
- {quantui-0.6.0 → quantui-0.6.1}/quantui/tddft_calc.py +0 -0
- {quantui-0.6.0 → quantui-0.6.1}/quantui/theme.py +0 -0
- {quantui-0.6.0 → quantui-0.6.1}/quantui/user_settings.py +0 -0
- {quantui-0.6.0 → quantui-0.6.1}/quantui/utils.py +0 -0
- {quantui-0.6.0 → quantui-0.6.1}/quantui/vib_cache.py +0 -0
- {quantui-0.6.0 → quantui-0.6.1}/quantui/visualization_py3dmol.py +0 -0
- {quantui-0.6.0 → quantui-0.6.1}/quantui/viz_assets.py +0 -0
- {quantui-0.6.0 → quantui-0.6.1}/quantui/viz_backend_router.py +0 -0
- {quantui-0.6.0 → quantui-0.6.1}/quantui.egg-info/SOURCES.txt +0 -0
- {quantui-0.6.0 → quantui-0.6.1}/quantui.egg-info/dependency_links.txt +0 -0
- {quantui-0.6.0 → quantui-0.6.1}/quantui.egg-info/entry_points.txt +0 -0
- {quantui-0.6.0 → quantui-0.6.1}/quantui.egg-info/requires.txt +0 -0
- {quantui-0.6.0 → quantui-0.6.1}/quantui.egg-info/top_level.txt +0 -0
- {quantui-0.6.0 → quantui-0.6.1}/setup.cfg +0 -0
|
@@ -7,6 +7,46 @@ and this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.ht
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.6.1] - 2026-08-05
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- **Reorganization-energy results now survive History.** Reloading one from
|
|
15
|
+
History showed the calculation's headline numbers as missing — λ, the
|
|
16
|
+
four-point energies and the per-channel breakdown were never written to disk,
|
|
17
|
+
so there was nothing to redisplay. Both the live and History result cards now
|
|
18
|
+
render from the same saved data, so anything visible during a run is visible
|
|
19
|
+
after reloading it.
|
|
20
|
+
**Results saved before this cannot be recovered** — λ requires two geometry
|
|
21
|
+
optimizations and four SCF energies. Those results now say so on the card and
|
|
22
|
+
tell you to re-run, rather than appearing silently incomplete.
|
|
23
|
+
- **3-D backgrounds follow the theme** in the new geometry viewers, matching the
|
|
24
|
+
fix applied to the other viewers in 0.6.0.
|
|
25
|
+
|
|
26
|
+
### Added
|
|
27
|
+
|
|
28
|
+
- **A Geometries panel** for reorganization energy, on the Analysis tab — which
|
|
29
|
+
previously showed nothing at all for this calculation type. Step through the
|
|
30
|
+
distinct geometries behind the four energies, or overlay two of them with
|
|
31
|
+
arrows marking how each atom moved. The arrows can be scaled ×1–×10 for small
|
|
32
|
+
relaxations; the structures always show true computed positions.
|
|
33
|
+
- **Geometry relaxation on the result card** — RMSD and the largest single-atom
|
|
34
|
+
shift between the neutral and ion geometries, which is what λ physically
|
|
35
|
+
measures.
|
|
36
|
+
- **Compare λ across saved results.** Selecting several reorganization-energy
|
|
37
|
+
runs in the Compare tab now shows a λ table with a column per channel, for
|
|
38
|
+
screening candidates by how much they reorganize.
|
|
39
|
+
|
|
40
|
+
### Changed
|
|
41
|
+
|
|
42
|
+
- The NCShare GPU diagnostic notebook now reports the **CPU affinity mask**
|
|
43
|
+
alongside the core count, and states plainly when CPU timings cannot be
|
|
44
|
+
trusted. A job can be granted 6 cores while still *seeing* 192; whether that
|
|
45
|
+
matters depends on how the scheduler constrains it, and the core count alone
|
|
46
|
+
cannot distinguish the two cases.
|
|
47
|
+
- `apptainer/README.md` records the first verified H200 run — driver, compute
|
|
48
|
+
capability, partition, and the measured CPU/GPU crossover.
|
|
49
|
+
|
|
10
50
|
## [0.6.0] - 2026-08-04
|
|
11
51
|
|
|
12
52
|
### Added
|
|
@@ -563,7 +603,8 @@ Initial public scaffolding of the QuantUI package: `quantui` package with
|
|
|
563
603
|
`calculator.py`, basic notebook launcher, Apptainer container definition,
|
|
564
604
|
MIT license, and project metadata.
|
|
565
605
|
|
|
566
|
-
[Unreleased]: https://github.com/The-Schultz-Lab/QuantUI/compare/v0.6.
|
|
606
|
+
[Unreleased]: https://github.com/The-Schultz-Lab/QuantUI/compare/v0.6.1...HEAD
|
|
607
|
+
[0.6.1]: https://github.com/The-Schultz-Lab/QuantUI/compare/v0.6.0...v0.6.1
|
|
567
608
|
[0.6.0]: https://github.com/The-Schultz-Lab/QuantUI/compare/v0.5.2...v0.6.0
|
|
568
609
|
[0.5.2]: https://github.com/The-Schultz-Lab/QuantUI/compare/v0.5.1...v0.5.2
|
|
569
610
|
[0.5.1]: https://github.com/The-Schultz-Lab/QuantUI/compare/v0.5.0...v0.5.1
|
|
@@ -7,7 +7,7 @@ Calculations run locally in the Jupyter session — no cluster or SLURM required
|
|
|
7
7
|
PySCF requires Linux/macOS/WSL. Windows users should use the Apptainer container.
|
|
8
8
|
"""
|
|
9
9
|
|
|
10
|
-
__version__ = "0.6.
|
|
10
|
+
__version__ = "0.6.1"
|
|
11
11
|
|
|
12
12
|
import logging
|
|
13
13
|
from typing import Any
|
|
@@ -45,6 +45,9 @@ from quantui.app_analysis import (
|
|
|
45
45
|
from quantui.app_analysis import (
|
|
46
46
|
deactivate_all_ana_panels as _ana_deactivate_all_ana_panels,
|
|
47
47
|
)
|
|
48
|
+
from quantui.app_analysis import (
|
|
49
|
+
on_reorg_view_changed as _ana_on_reorg_view_changed,
|
|
50
|
+
)
|
|
48
51
|
from quantui.app_analysis import (
|
|
49
52
|
pop_energies as _ana_pop_energies,
|
|
50
53
|
)
|
|
@@ -69,6 +72,9 @@ from quantui.app_analysis import (
|
|
|
69
72
|
from quantui.app_analysis import (
|
|
70
73
|
pop_preopt_trajectory as _ana_pop_preopt_trajectory,
|
|
71
74
|
)
|
|
75
|
+
from quantui.app_analysis import (
|
|
76
|
+
pop_reorg_geometries as _ana_pop_reorg_geometries,
|
|
77
|
+
)
|
|
72
78
|
from quantui.app_analysis import (
|
|
73
79
|
pop_uv_vis as _ana_pop_uv_vis,
|
|
74
80
|
)
|
|
@@ -1630,6 +1636,7 @@ class QuantUIApp:
|
|
|
1630
1636
|
("IR Spectrum", "_ir_accordion", "Frequency"),
|
|
1631
1637
|
("PES Scan", "_pes_scan_accordion", "PES Scan"),
|
|
1632
1638
|
("Isosurface", "_iso_accordion", "Single Point (Linux/WSL only)"),
|
|
1639
|
+
("Geometries", "_reorg_geom_accordion", "Reorganization Energy"),
|
|
1633
1640
|
("UV-Vis", "_tddft_accordion", "UV-Vis (TD-DFT)"),
|
|
1634
1641
|
("NMR", "_nmr_accordion", "NMR Shielding"),
|
|
1635
1642
|
]
|
|
@@ -1678,6 +1685,16 @@ class QuantUIApp:
|
|
|
1678
1685
|
"nmr": [
|
|
1679
1686
|
("NMR", "_pop_nmr_shielding", True),
|
|
1680
1687
|
],
|
|
1688
|
+
# reorganization_energy had NO entry at all until 2026-08-05, so the
|
|
1689
|
+
# Analysis tab populated nothing for these runs — not a missing panel,
|
|
1690
|
+
# no panels. Order matters twice over: the FIRST auto_select=True that
|
|
1691
|
+
# returns True wins, AND _pop_energies loads the orbital state
|
|
1692
|
+
# _pop_isosurface checks, so Energies must precede Isosurface.
|
|
1693
|
+
"reorganization_energy": [
|
|
1694
|
+
("Energies", "_pop_energies", False),
|
|
1695
|
+
("Geometries", "_pop_reorg_geometries", True),
|
|
1696
|
+
("Isosurface", "_pop_isosurface", False),
|
|
1697
|
+
],
|
|
1681
1698
|
"pes_scan": [
|
|
1682
1699
|
("PES Scan", "_pop_pes_plot", True),
|
|
1683
1700
|
("Trajectory", "_pop_pes_trajectory", False),
|
|
@@ -1693,6 +1710,9 @@ class QuantUIApp:
|
|
|
1693
1710
|
def _pop_energies(self, ctx: _AnalysisContext) -> bool:
|
|
1694
1711
|
return _ana_pop_energies(self, ctx)
|
|
1695
1712
|
|
|
1713
|
+
def _pop_reorg_geometries(self, ctx: _AnalysisContext) -> bool:
|
|
1714
|
+
return _ana_pop_reorg_geometries(self, ctx)
|
|
1715
|
+
|
|
1696
1716
|
def _pop_isosurface(self, ctx: _AnalysisContext) -> bool:
|
|
1697
1717
|
return _ana_pop_isosurface(self, ctx)
|
|
1698
1718
|
|
|
@@ -2014,6 +2034,14 @@ class QuantUIApp:
|
|
|
2014
2034
|
# Cube + bundle exports
|
|
2015
2035
|
self._iso_export_cube_btn.on_click(self._on_iso_export_cube)
|
|
2016
2036
|
self._iso_cancel_btn.on_click(self._safe_cb(self._on_iso_cancel))
|
|
2037
|
+
# Reorg geometry views (REORG.3): both redraw from data already in
|
|
2038
|
+
# memory, so they respond directly rather than behind an Apply button.
|
|
2039
|
+
for _w in (
|
|
2040
|
+
self._reorg_view_toggle,
|
|
2041
|
+
self._reorg_overlay_pair,
|
|
2042
|
+
self._reorg_exaggerate,
|
|
2043
|
+
):
|
|
2044
|
+
_w.observe(self._safe_cb(self._on_reorg_view_changed), names="value")
|
|
2017
2045
|
# PNG capture arrives from the browser, so there is no button to bind
|
|
2018
2046
|
# here — the viewer's own Save-PNG button posts into this Textarea and
|
|
2019
2047
|
# ipywidgets syncs it back, firing this observer (ORBX.1).
|
|
@@ -3378,6 +3406,9 @@ class QuantUIApp:
|
|
|
3378
3406
|
def _on_export_pdb(self, btn) -> None:
|
|
3379
3407
|
_exp_on_export_pdb(self, btn)
|
|
3380
3408
|
|
|
3409
|
+
def _on_reorg_view_changed(self, change) -> None:
|
|
3410
|
+
_ana_on_reorg_view_changed(self, change)
|
|
3411
|
+
|
|
3381
3412
|
def _on_iso_cancel(self, btn) -> None:
|
|
3382
3413
|
_viz_on_iso_cancel(self, btn)
|
|
3383
3414
|
|
|
@@ -253,6 +253,110 @@ def pop_energies(app: Any, ctx: Any) -> bool:
|
|
|
253
253
|
return bool(app._show_orbital_diagram(result))
|
|
254
254
|
|
|
255
255
|
|
|
256
|
+
def pop_reorg_geometries(app: Any, ctx: Any) -> bool:
|
|
257
|
+
"""Populate the Geometries panel for a reorganization-energy result.
|
|
258
|
+
|
|
259
|
+
Works from a live result OR a saved one by reading the same channel payload
|
|
260
|
+
both now carry — the shape REORG.1 introduced. That is deliberate: the
|
|
261
|
+
original bug was two paths reading different things, so this one never had
|
|
262
|
+
the chance to grow a second reader.
|
|
263
|
+
|
|
264
|
+
Returns False when the payload is absent, which is exactly the pre-REORG.1
|
|
265
|
+
case; the panel then stays unavailable rather than showing an empty viewer,
|
|
266
|
+
and the results card explains why.
|
|
267
|
+
"""
|
|
268
|
+
channels, neutral = _reorg_payload(app, ctx)
|
|
269
|
+
if not channels or not neutral:
|
|
270
|
+
_set_panel_unavailable_message(
|
|
271
|
+
app,
|
|
272
|
+
"Geometries",
|
|
273
|
+
(
|
|
274
|
+
"Not available for this result: the per-channel geometries were "
|
|
275
|
+
"not saved. Re-run the calculation to enable the comparison."
|
|
276
|
+
),
|
|
277
|
+
)
|
|
278
|
+
return False
|
|
279
|
+
|
|
280
|
+
from quantui.reorganization_energy import reorg_geometries
|
|
281
|
+
|
|
282
|
+
geoms = reorg_geometries(channels, neutral)
|
|
283
|
+
if len(geoms) < 2:
|
|
284
|
+
return False
|
|
285
|
+
app._reorg_geometries = geoms
|
|
286
|
+
app._reorg_overlay_pair.options = [
|
|
287
|
+
(f"{geoms[0]['label'].split(' — ')[0]} vs {g['label'].split(' — ')[0]}", i)
|
|
288
|
+
for i in range(1, len(geoms))
|
|
289
|
+
for g in [geoms[i]]
|
|
290
|
+
]
|
|
291
|
+
if app._reorg_overlay_pair.options:
|
|
292
|
+
app._reorg_overlay_pair.value = app._reorg_overlay_pair.options[0][1]
|
|
293
|
+
render_reorg_geometries(app)
|
|
294
|
+
return True
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def _reorg_payload(app: Any, ctx: Any) -> tuple[list, dict]:
|
|
298
|
+
"""Channel list + neutral geometry, from a live result or a saved one."""
|
|
299
|
+
live = getattr(ctx, "live_result", None)
|
|
300
|
+
if live is not None and getattr(live, "channels", None):
|
|
301
|
+
from quantui.results_storage import _reorg_channels_payload
|
|
302
|
+
|
|
303
|
+
payload = _reorg_channels_payload(live) or []
|
|
304
|
+
neutral = payload[0].get("neutral_geometry") if payload else None
|
|
305
|
+
return payload, neutral or {}
|
|
306
|
+
|
|
307
|
+
result_dir = getattr(ctx, "result_dir", None)
|
|
308
|
+
if result_dir is not None:
|
|
309
|
+
try:
|
|
310
|
+
from quantui import load_result
|
|
311
|
+
|
|
312
|
+
data = load_result(result_dir)
|
|
313
|
+
payload = data.get("reorg_channels") or []
|
|
314
|
+
neutral = payload[0].get("neutral_geometry") if payload else None
|
|
315
|
+
return payload, neutral or {}
|
|
316
|
+
except Exception: # noqa: BLE001 — a missing panel, never a crash
|
|
317
|
+
return [], {}
|
|
318
|
+
return [], {}
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def render_reorg_geometries(app: Any) -> None:
|
|
322
|
+
"""Draw the current view (stepper or overlay) into the Geometries panel."""
|
|
323
|
+
geoms = getattr(app, "_reorg_geometries", None)
|
|
324
|
+
if not geoms:
|
|
325
|
+
return
|
|
326
|
+
from quantui.app_visualization import (
|
|
327
|
+
build_reorg_geometry_viewer_html,
|
|
328
|
+
build_reorg_overlay_html,
|
|
329
|
+
)
|
|
330
|
+
|
|
331
|
+
bg = app._plotly_theme_colors()["scene_bgcolor"]
|
|
332
|
+
try:
|
|
333
|
+
if app._reorg_view_toggle.value == "overlay":
|
|
334
|
+
idx = int(app._reorg_overlay_pair.value or 1)
|
|
335
|
+
html = build_reorg_overlay_html(
|
|
336
|
+
geoms[0],
|
|
337
|
+
geoms[idx],
|
|
338
|
+
bgcolor=bg,
|
|
339
|
+
exaggerate=float(getattr(app._reorg_exaggerate, "value", 1.0)),
|
|
340
|
+
)
|
|
341
|
+
else:
|
|
342
|
+
html = build_reorg_geometry_viewer_html(geoms, bgcolor=bg)
|
|
343
|
+
app._set_html_output(app._reorg_geom_output, html)
|
|
344
|
+
except Exception as exc: # noqa: BLE001
|
|
345
|
+
app._set_html_output(
|
|
346
|
+
app._reorg_geom_output,
|
|
347
|
+
f'<p style="color:#b91c1c;padding:8px">Geometry view failed: {exc}</p>',
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def on_reorg_view_changed(app: Any, change: Any = None) -> None:
|
|
352
|
+
"""Toggle between stepper and overlay; the pair picker only applies to one."""
|
|
353
|
+
is_overlay = app._reorg_view_toggle.value == "overlay"
|
|
354
|
+
app._reorg_overlay_pair.layout.display = "" if is_overlay else "none"
|
|
355
|
+
# Only meaningful for the overlay — the stepper shows true positions.
|
|
356
|
+
app._reorg_exaggerate.layout.display = "" if is_overlay else "none"
|
|
357
|
+
render_reorg_geometries(app)
|
|
358
|
+
|
|
359
|
+
|
|
256
360
|
def pop_isosurface(app: Any, ctx: Any) -> bool:
|
|
257
361
|
"""Populate Isosurface availability from orbital state.
|
|
258
362
|
|
|
@@ -2073,6 +2073,58 @@ def build_results_section(app: Any, *, layout_fn: Any) -> None:
|
|
|
2073
2073
|
app._iso_accordion.set_title(0, "Orbital Isosurface")
|
|
2074
2074
|
app._iso_accordion.selected_index = None
|
|
2075
2075
|
|
|
2076
|
+
# ── Reorganization-energy geometries (M-REORG REORG.3) ──────────────
|
|
2077
|
+
# Two views of the same data, because they answer different questions:
|
|
2078
|
+
# stepping shows what each geometry looks like, overlaying shows what
|
|
2079
|
+
# MOVED — and displacement is what λ actually measures.
|
|
2080
|
+
app._reorg_geom_output = widgets.Output(
|
|
2081
|
+
layout=layout_fn(min_height="470px", overflow="hidden")
|
|
2082
|
+
)
|
|
2083
|
+
app._reorg_view_toggle = widgets.ToggleButtons(
|
|
2084
|
+
options=[("Step through", "step"), ("Overlay", "overlay")],
|
|
2085
|
+
value="step",
|
|
2086
|
+
style={"button_width": "120px"},
|
|
2087
|
+
layout=layout_fn(margin="0 0 6px 0"),
|
|
2088
|
+
)
|
|
2089
|
+
app._reorg_overlay_pair = widgets.Dropdown(
|
|
2090
|
+
options=[],
|
|
2091
|
+
description="Compare:",
|
|
2092
|
+
style={"description_width": "70px"},
|
|
2093
|
+
layout=layout_fn(width="380px", display="none"),
|
|
2094
|
+
)
|
|
2095
|
+
# λ relaxations are often a few hundredths of an Ångström — real, and
|
|
2096
|
+
# invisible at 1:1. Scaling the ARROWS (never the structures) is the same
|
|
2097
|
+
# convention vibrational-mode displays use, so nothing shown is fictional.
|
|
2098
|
+
app._reorg_exaggerate = widgets.Dropdown(
|
|
2099
|
+
options=[("True scale (×1)", 1.0), ("×3", 3.0), ("×5", 5.0), ("×10", 10.0)],
|
|
2100
|
+
value=1.0,
|
|
2101
|
+
description="Arrows:",
|
|
2102
|
+
style={"description_width": "70px"},
|
|
2103
|
+
layout=layout_fn(width="380px", display="none"),
|
|
2104
|
+
)
|
|
2105
|
+
app._reorg_geom_body = widgets.VBox(
|
|
2106
|
+
[
|
|
2107
|
+
widgets.HTML(
|
|
2108
|
+
'<p style="color:#555;font-size:12px;margin:0 0 8px">'
|
|
2109
|
+
"The Marcus 4-point scheme evaluates <b>four energies on two "
|
|
2110
|
+
"geometries per channel</b> — the optimized neutral and the "
|
|
2111
|
+
"optimized ion. λ is how far the molecule relaxed between "
|
|
2112
|
+
"them.</p>"
|
|
2113
|
+
),
|
|
2114
|
+
app._reorg_view_toggle,
|
|
2115
|
+
app._reorg_overlay_pair,
|
|
2116
|
+
app._reorg_exaggerate,
|
|
2117
|
+
app._reorg_geom_output,
|
|
2118
|
+
],
|
|
2119
|
+
layout=layout_fn(padding="8px"),
|
|
2120
|
+
)
|
|
2121
|
+
app._reorg_geom_accordion = widgets.Accordion(
|
|
2122
|
+
children=[app._reorg_geom_body],
|
|
2123
|
+
layout=layout_fn(display="none", margin="8px 0"),
|
|
2124
|
+
)
|
|
2125
|
+
app._reorg_geom_accordion.set_title(0, "Geometries (λ relaxation)")
|
|
2126
|
+
app._reorg_geom_accordion.selected_index = None
|
|
2127
|
+
|
|
2076
2128
|
app._uv_mode_toggle = widgets.ToggleButtons(
|
|
2077
2129
|
options=["Stick", "Broadened"],
|
|
2078
2130
|
value="Stick",
|
|
@@ -2281,6 +2333,11 @@ def build_results_section(app: Any, *, layout_fn: Any) -> None:
|
|
|
2281
2333
|
app.vib_accordion,
|
|
2282
2334
|
app._ir_accordion,
|
|
2283
2335
|
app._iso_accordion,
|
|
2336
|
+
# Creating the accordion and registering it in _PANEL_META is not
|
|
2337
|
+
# enough — it also has to be a child of this VBox or it can never
|
|
2338
|
+
# render. Missed on the first pass (2026-08-05): the panel existed,
|
|
2339
|
+
# the registry knew about it, and the Analysis tab showed nothing.
|
|
2340
|
+
app._reorg_geom_accordion,
|
|
2284
2341
|
app._tddft_accordion,
|
|
2285
2342
|
app._nmr_accordion,
|
|
2286
2343
|
]
|
|
@@ -367,36 +367,260 @@ def format_pes_scan_result(r: Any) -> str:
|
|
|
367
367
|
)
|
|
368
368
|
|
|
369
369
|
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
_conv = "Yes" if r.converged else "No (some steps did not converge)"
|
|
373
|
-
_cc = "green" if r.converged else "#c00"
|
|
370
|
+
_HARTREE_TO_EV = 27.211386245988
|
|
371
|
+
_HARTREE_TO_KCAL = 627.509474
|
|
374
372
|
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
373
|
+
|
|
374
|
+
def _attach_relaxation_from_saved(payload: list[dict], data: dict) -> None:
|
|
375
|
+
"""RMSD/displacement for a result loaded from disk.
|
|
376
|
+
|
|
377
|
+
The neutral geometry comes from the saved molecule rather than a live
|
|
378
|
+
result object; everything downstream is the same computation the live card
|
|
379
|
+
does, so the two cards report identical numbers.
|
|
380
|
+
"""
|
|
381
|
+
from quantui.molecule import Molecule
|
|
382
|
+
|
|
383
|
+
# The neutral geometry travels inside the channel payload — see
|
|
384
|
+
# _reorg_channels_payload for why it is not read from the top level.
|
|
385
|
+
geom = next(
|
|
386
|
+
(c.get("neutral_geometry") for c in payload if c.get("neutral_geometry")), None
|
|
387
|
+
)
|
|
388
|
+
if not geom:
|
|
389
|
+
return
|
|
390
|
+
try:
|
|
391
|
+
neutral = Molecule(
|
|
392
|
+
atoms=list(geom["atoms"]),
|
|
393
|
+
coordinates=[list(c) for c in geom["coordinates"]],
|
|
394
|
+
charge=geom.get("charge", 0),
|
|
395
|
+
multiplicity=geom.get("multiplicity", 1),
|
|
396
|
+
)
|
|
397
|
+
except Exception: # noqa: BLE001 — never break a history card
|
|
398
|
+
return
|
|
399
|
+
|
|
400
|
+
class _Holder:
|
|
401
|
+
molecule = neutral
|
|
402
|
+
|
|
403
|
+
_attach_relaxation(payload, _Holder())
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def _attach_relaxation(payload: list[dict], result: Any = None) -> None:
|
|
407
|
+
"""Add RMSD / largest-atom-shift to each channel, in place (REORG.4).
|
|
408
|
+
|
|
409
|
+
Computed at DISPLAY time from geometries that are already stored, rather
|
|
410
|
+
than persisted as another number that could drift out of step with the
|
|
411
|
+
coordinates it describes.
|
|
412
|
+
"""
|
|
413
|
+
from quantui.molecule import Molecule
|
|
414
|
+
from quantui.reorganization_energy import geometry_rmsd, max_atom_displacement
|
|
415
|
+
|
|
416
|
+
neutral = getattr(result, "molecule", None) if result is not None else None
|
|
417
|
+
for entry in payload:
|
|
418
|
+
geom = entry.get("ion_geometry")
|
|
419
|
+
if neutral is None or not geom:
|
|
420
|
+
continue
|
|
421
|
+
try:
|
|
422
|
+
ion = Molecule(
|
|
423
|
+
atoms=list(geom["atoms"]),
|
|
424
|
+
coordinates=[list(c) for c in geom["coordinates"]],
|
|
425
|
+
charge=geom.get("charge", 0),
|
|
426
|
+
multiplicity=geom.get("multiplicity", 1),
|
|
427
|
+
)
|
|
428
|
+
except Exception: # noqa: BLE001 — a readout must not break a card
|
|
429
|
+
continue
|
|
430
|
+
rmsd = geometry_rmsd(neutral, ion)
|
|
431
|
+
if rmsd is None:
|
|
432
|
+
continue
|
|
433
|
+
entry["relaxation"] = {
|
|
434
|
+
"rmsd": rmsd,
|
|
435
|
+
"max_atom": max_atom_displacement(neutral, ion),
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def reorg_comparison_html(entries: list[tuple[str, dict]]) -> str:
|
|
440
|
+
"""Side-by-side λ table for several saved reorganization-energy results.
|
|
441
|
+
|
|
442
|
+
``entries`` is ``[(label, loaded_result_dict), ...]``.
|
|
443
|
+
|
|
444
|
+
This is the workflow reorganization energy exists for: screening candidate
|
|
445
|
+
molecules by how much they reorganize. One λ in isolation is hard to judge —
|
|
446
|
+
it is only meaningful against other candidates — so a comparison view is
|
|
447
|
+
arguably the point of the calculation rather than a nicety.
|
|
448
|
+
|
|
449
|
+
Rendered as its own table rather than as extra columns on the general
|
|
450
|
+
comparison: λ is per-CHANNEL (hole and electron), so it does not fit a
|
|
451
|
+
one-row-per-result grid without either duplicating rows or inventing a
|
|
452
|
+
combined number that has no physical meaning.
|
|
453
|
+
|
|
454
|
+
Results with no channel payload are listed with a note rather than skipped —
|
|
455
|
+
silently omitting them would look like they were never selected.
|
|
456
|
+
"""
|
|
457
|
+
if not entries:
|
|
458
|
+
return ""
|
|
459
|
+
|
|
460
|
+
kinds: list[str] = []
|
|
461
|
+
for _, data in entries:
|
|
462
|
+
for ch in data.get("reorg_channels") or []:
|
|
463
|
+
if ch.get("kind") and ch["kind"] not in kinds:
|
|
464
|
+
kinds.append(ch["kind"])
|
|
465
|
+
if not kinds:
|
|
466
|
+
return ""
|
|
467
|
+
|
|
468
|
+
# theme.BORDER, not a hand-picked grey. Dark mode is a whole-page colour
|
|
469
|
+
# inversion, so a light grey rule inverts to near-black on a near-black page
|
|
470
|
+
# and vanishes — the defect THEME.5 fixed, and which this table reintroduced
|
|
471
|
+
# until test_retired_border_greys_are_gone_from_in_app_chrome caught it.
|
|
472
|
+
th = f"text-align:left;padding:6px 12px;border-bottom:2px solid {_theme.BORDER}"
|
|
473
|
+
td = f"padding:5px 12px;border-bottom:1px solid {_theme.BORDER}"
|
|
474
|
+
head = (
|
|
475
|
+
f'<th style="{th}">Result</th><th style="{th}">Method / basis</th>'
|
|
476
|
+
+ "".join(f'<th style="{th}">λ {k} (eV)</th>' for k in kinds)
|
|
477
|
+
+ f'<th style="{th}">Relaxation (Å)</th>'
|
|
478
|
+
)
|
|
479
|
+
|
|
480
|
+
rows = []
|
|
481
|
+
for label, data in entries:
|
|
482
|
+
channels = data.get("reorg_channels") or []
|
|
483
|
+
if not channels:
|
|
484
|
+
rows.append(
|
|
485
|
+
f'<tr><td style="{td}">{label}</td>'
|
|
486
|
+
f'<td style="{td}" colspan="{len(kinds) + 2}">'
|
|
487
|
+
'<span style="color:#92400e">λ not saved — re-run to compare'
|
|
488
|
+
"</span></td></tr>"
|
|
489
|
+
)
|
|
490
|
+
continue
|
|
491
|
+
_attach_relaxation_from_saved(channels, data)
|
|
492
|
+
by_kind = {c.get("kind"): c for c in channels}
|
|
493
|
+
cells = []
|
|
494
|
+
for k in kinds:
|
|
495
|
+
ch = by_kind.get(k)
|
|
496
|
+
lam = ch.get("lambda_hartree") if ch else None
|
|
497
|
+
cells.append(
|
|
498
|
+
f'<td style="{td};font-family:monospace">'
|
|
499
|
+
+ ("—" if lam is None else f"{lam * _HARTREE_TO_EV:.4f}")
|
|
500
|
+
+ "</td>"
|
|
501
|
+
)
|
|
502
|
+
rmsds = [c["relaxation"]["rmsd"] for c in channels if c.get("relaxation")]
|
|
503
|
+
relax = f"{max(rmsds):.4f}" if rmsds else "—"
|
|
504
|
+
rows.append(
|
|
505
|
+
f'<tr><td style="{td}">{label}</td>'
|
|
506
|
+
f'<td style="{td}">{data.get("method", "?")}/{data.get("basis", "?")}</td>'
|
|
507
|
+
+ "".join(cells)
|
|
508
|
+
+ f'<td style="{td};font-family:monospace">{relax}</td></tr>'
|
|
509
|
+
)
|
|
510
|
+
|
|
511
|
+
return (
|
|
512
|
+
'<div style="margin-top:14px">'
|
|
513
|
+
'<h4 style="margin:0 0 4px">Reorganization energy</h4>'
|
|
514
|
+
'<p style="color:#555;font-size:12px;margin:0 0 6px">'
|
|
515
|
+
"Lower λ means less geometric reorganization on charging — generally "
|
|
516
|
+
"favourable for charge transport. Relaxation is the largest per-channel "
|
|
517
|
+
"RMSD between the neutral and ion geometries.</p>"
|
|
518
|
+
'<table style="border-collapse:collapse;font-size:13px">'
|
|
519
|
+
f"<tr>{head}</tr>{''.join(rows)}</table></div>"
|
|
520
|
+
)
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
def reorg_channels_html(channels: list[dict]) -> str:
|
|
524
|
+
"""Render reorganization-energy channels from PLAIN DATA.
|
|
525
|
+
|
|
526
|
+
Both the live card and the History card go through here. They used to have
|
|
527
|
+
separate implementations — the live one reading attributes off a result
|
|
528
|
+
object, the history one having no channels at all — which is precisely how
|
|
529
|
+
the reported bug arose. A single renderer over plain dicts is what stops
|
|
530
|
+
them drifting again: the live path converts its objects to the same shape
|
|
531
|
+
the saved file holds, so anything that renders live renders after reload.
|
|
532
|
+
"""
|
|
533
|
+
if not channels:
|
|
534
|
+
return ""
|
|
535
|
+
|
|
536
|
+
def _label(kind: str) -> str:
|
|
537
|
+
return {
|
|
538
|
+
"hole": "Hole transfer (cation)",
|
|
539
|
+
"electron": "Electron transfer (anion)",
|
|
540
|
+
}.get(kind, str(kind).title())
|
|
541
|
+
|
|
542
|
+
blocks = []
|
|
543
|
+
for ch in channels:
|
|
544
|
+
lam = ch.get("lambda_hartree")
|
|
545
|
+
rows = [
|
|
546
|
+
(
|
|
547
|
+
"λ",
|
|
386
548
|
(
|
|
387
|
-
"
|
|
388
|
-
|
|
549
|
+
"—"
|
|
550
|
+
if lam is None
|
|
551
|
+
else f"{lam * _HARTREE_TO_EV:.4f} eV "
|
|
552
|
+
f"({lam * _HARTREE_TO_KCAL:.2f} kcal/mol)"
|
|
389
553
|
),
|
|
390
|
-
|
|
554
|
+
),
|
|
555
|
+
("λ₁ ion relaxation", _ev(ch.get("lambda1_hartree"))),
|
|
556
|
+
("λ₂ neutral relaxation", _ev(ch.get("lambda2_hartree"))),
|
|
557
|
+
(
|
|
558
|
+
"Ion state",
|
|
559
|
+
f"charge {ch.get('ion_charge', 0):+d}, "
|
|
560
|
+
f"mult {ch.get('ion_multiplicity', '?')}",
|
|
561
|
+
),
|
|
562
|
+
]
|
|
563
|
+
# Geometry relaxation (REORG.4): what λ physically measures. Only
|
|
564
|
+
# present once the ion geometry is saved, so older results simply omit
|
|
565
|
+
# these rows rather than showing blanks.
|
|
566
|
+
relax = ch.get("relaxation")
|
|
567
|
+
if relax:
|
|
568
|
+
rows.append(("Geometry RMSD", f"{relax['rmsd']:.4f} Å"))
|
|
569
|
+
if relax.get("max_atom") is not None:
|
|
570
|
+
idx, dist = relax["max_atom"]
|
|
571
|
+
rows.append(("Largest atom shift", f"{dist:.4f} Å (atom {idx + 1})"))
|
|
572
|
+
body = "".join(
|
|
573
|
+
f'<tr><td style="padding:2px 18px 2px 0;color:#444">{k}</td>'
|
|
574
|
+
f'<td style="color:#000;font-family:monospace">{v}</td></tr>'
|
|
575
|
+
for k, v in rows
|
|
391
576
|
)
|
|
392
|
-
|
|
577
|
+
blocks.append(
|
|
393
578
|
f'<div style="margin-top:8px">'
|
|
394
|
-
f'<b style="font-size:13px;color:#166534">{ch.
|
|
579
|
+
f'<b style="font-size:13px;color:#166534">{_label(ch.get("kind", ""))}</b>'
|
|
395
580
|
f'<table style="margin-top:2px;font-size:13px;border-collapse:collapse">'
|
|
396
|
-
f"{
|
|
581
|
+
f"{body}</table></div>"
|
|
397
582
|
)
|
|
583
|
+
return "".join(blocks)
|
|
584
|
+
|
|
585
|
+
|
|
586
|
+
def _ev(hartree: Any) -> str:
|
|
587
|
+
return "—" if hartree is None else f"{hartree * _HARTREE_TO_EV:.4f} eV"
|
|
588
|
+
|
|
398
589
|
|
|
399
|
-
|
|
590
|
+
def reorg_missing_data_notice() -> str:
|
|
591
|
+
"""Shown on a reorganization-energy result saved before REORG.1.
|
|
592
|
+
|
|
593
|
+
Those runs never wrote their channel data, and it cannot be recovered — λ
|
|
594
|
+
is two geometry optimizations and four SCF energies. So the card says so
|
|
595
|
+
and names the remedy, rather than rendering an empty section that reads
|
|
596
|
+
like a rendering failure.
|
|
597
|
+
|
|
598
|
+
Detected by ABSENCE of the payload, not by version or timestamp: a result
|
|
599
|
+
re-saved or imported from elsewhere would defeat a version cutoff.
|
|
600
|
+
"""
|
|
601
|
+
return (
|
|
602
|
+
'<div style="margin-top:8px;padding:8px 10px;border-radius:6px;'
|
|
603
|
+
'background:#fef3c7;border:1px solid #f59e0b;font-size:13px;color:#78350f">'
|
|
604
|
+
"<b>⚠ Reorganization-energy details were not saved for this result.</b><br>"
|
|
605
|
+
"Results produced before QuantUI gained λ persistence did not store the "
|
|
606
|
+
"per-channel energies or geometries, and they cannot be recovered from "
|
|
607
|
+
"what was saved. <b>Re-run this calculation</b> to see the λ breakdown, "
|
|
608
|
+
"the four-point energies and the geometry comparison."
|
|
609
|
+
"</div>"
|
|
610
|
+
)
|
|
611
|
+
|
|
612
|
+
|
|
613
|
+
def format_reorg_result(r: Any) -> str:
|
|
614
|
+
"""Format a reorganization-energy (Marcus 4-point) result card."""
|
|
615
|
+
_conv = "Yes" if r.converged else "No (some steps did not converge)"
|
|
616
|
+
_cc = "green" if r.converged else "#c00"
|
|
617
|
+
|
|
618
|
+
# Same renderer, same shape as the saved payload — see reorg_channels_html.
|
|
619
|
+
from quantui.results_storage import _reorg_channels_payload
|
|
620
|
+
|
|
621
|
+
_payload = _reorg_channels_payload(r) or []
|
|
622
|
+
_attach_relaxation(_payload, r)
|
|
623
|
+
_channels_html = reorg_channels_html(_payload)
|
|
400
624
|
return (
|
|
401
625
|
f'<div style="background:#f0fff0;border-left:4px solid #4CAF50;'
|
|
402
626
|
f'padding:10px 14px;border-radius:4px;margin:6px 0">'
|
|
@@ -483,6 +707,19 @@ def format_past_result(data: dict[str, Any], result_dir: Optional[Path] = None)
|
|
|
483
707
|
f'border:1px solid {_theme.BORDER}" width="173" height="108" />'
|
|
484
708
|
)
|
|
485
709
|
|
|
710
|
+
# Reorganization-energy channels (REORG.1). This is the reported bug: the
|
|
711
|
+
# card came back without the numbers the calculation exists to produce.
|
|
712
|
+
# Keyed on the calc type AND the payload, so a reorg result saved before λ
|
|
713
|
+
# persistence gets an explanation instead of a silently incomplete card.
|
|
714
|
+
_reorg_html = ""
|
|
715
|
+
if ct == "reorganization_energy":
|
|
716
|
+
_channels = data.get("reorg_channels")
|
|
717
|
+
if _channels:
|
|
718
|
+
_attach_relaxation_from_saved(_channels, data)
|
|
719
|
+
_reorg_html = reorg_channels_html(_channels)
|
|
720
|
+
else:
|
|
721
|
+
_reorg_html = reorg_missing_data_notice()
|
|
722
|
+
|
|
486
723
|
return (
|
|
487
724
|
f'<div style="background:#f0fff0;border-left:4px solid #4CAF50;'
|
|
488
725
|
f'padding:10px 14px;border-radius:4px;margin:6px 0;overflow:hidden">'
|
|
@@ -491,5 +728,5 @@ def format_past_result(data: dict[str, Any], result_dir: Optional[Path] = None)
|
|
|
491
728
|
f'<b>{data["formula"]} — {data["method"]}/{data["basis"]}</b>'
|
|
492
729
|
f' <small style="color:#777">{ts}</small>'
|
|
493
730
|
f'<table style="margin-top:8px;font-size:14px;border-collapse:collapse">'
|
|
494
|
-
f"{_rows}{_extra}</table
|
|
731
|
+
f"{_rows}{_extra}</table>{_reorg_html}</div>"
|
|
495
732
|
)
|