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.
Files changed (71) hide show
  1. {quantui-0.6.0 → quantui-0.6.1}/CHANGELOG.md +42 -1
  2. {quantui-0.6.0/quantui.egg-info → quantui-0.6.1}/PKG-INFO +1 -1
  3. {quantui-0.6.0 → quantui-0.6.1}/pyproject.toml +1 -1
  4. {quantui-0.6.0 → quantui-0.6.1}/quantui/__init__.py +1 -1
  5. {quantui-0.6.0 → quantui-0.6.1}/quantui/app.py +31 -0
  6. {quantui-0.6.0 → quantui-0.6.1}/quantui/app_analysis.py +104 -0
  7. {quantui-0.6.0 → quantui-0.6.1}/quantui/app_builders.py +57 -0
  8. {quantui-0.6.0 → quantui-0.6.1}/quantui/app_formatters.py +260 -23
  9. {quantui-0.6.0 → quantui-0.6.1}/quantui/app_runflow.py +16 -0
  10. {quantui-0.6.0 → quantui-0.6.1}/quantui/app_visualization.py +194 -0
  11. {quantui-0.6.0 → quantui-0.6.1}/quantui/reorganization_energy.py +93 -0
  12. {quantui-0.6.0 → quantui-0.6.1}/quantui/results_storage.py +67 -0
  13. {quantui-0.6.0 → quantui-0.6.1/quantui.egg-info}/PKG-INFO +1 -1
  14. {quantui-0.6.0 → quantui-0.6.1}/LICENSE +0 -0
  15. {quantui-0.6.0 → quantui-0.6.1}/MANIFEST.in +0 -0
  16. {quantui-0.6.0 → quantui-0.6.1}/README.md +0 -0
  17. {quantui-0.6.0 → quantui-0.6.1}/SECURITY.md +0 -0
  18. {quantui-0.6.0 → quantui-0.6.1}/quantui/analytics.py +0 -0
  19. {quantui-0.6.0 → quantui-0.6.1}/quantui/app_exports.py +0 -0
  20. {quantui-0.6.0 → quantui-0.6.1}/quantui/app_history.py +0 -0
  21. {quantui-0.6.0 → quantui-0.6.1}/quantui/ase_bridge.py +0 -0
  22. {quantui-0.6.0 → quantui-0.6.1}/quantui/benchmarks.py +0 -0
  23. {quantui-0.6.0 → quantui-0.6.1}/quantui/c_stderr.py +0 -0
  24. {quantui-0.6.0 → quantui-0.6.1}/quantui/cactus.py +0 -0
  25. {quantui-0.6.0 → quantui-0.6.1}/quantui/calc_log.py +0 -0
  26. {quantui-0.6.0 → quantui-0.6.1}/quantui/calculator.py +0 -0
  27. {quantui-0.6.0 → quantui-0.6.1}/quantui/cancellation.py +0 -0
  28. {quantui-0.6.0 → quantui-0.6.1}/quantui/cli.py +0 -0
  29. {quantui-0.6.0 → quantui-0.6.1}/quantui/comparison.py +0 -0
  30. {quantui-0.6.0 → quantui-0.6.1}/quantui/config.py +0 -0
  31. {quantui-0.6.0 → quantui-0.6.1}/quantui/data/js/3Dmol-min.js +0 -0
  32. {quantui-0.6.0 → quantui-0.6.1}/quantui/data/js/3Dmol-min.js.LICENSE.txt +0 -0
  33. {quantui-0.6.0 → quantui-0.6.1}/quantui/data/library/library.sqlite +0 -0
  34. {quantui-0.6.0 → quantui-0.6.1}/quantui/data/manifests/bulk_qm9.json +0 -0
  35. {quantui-0.6.0 → quantui-0.6.1}/quantui/data/manifests/curated.json +0 -0
  36. {quantui-0.6.0 → quantui-0.6.1}/quantui/data/manifests/presets.json +0 -0
  37. {quantui-0.6.0 → quantui-0.6.1}/quantui/descriptor_cards.py +0 -0
  38. {quantui-0.6.0 → quantui-0.6.1}/quantui/freq_calc.py +0 -0
  39. {quantui-0.6.0 → quantui-0.6.1}/quantui/freq_ir_workers.py +0 -0
  40. {quantui-0.6.0 → quantui-0.6.1}/quantui/gpu_offload.py +0 -0
  41. {quantui-0.6.0 → quantui-0.6.1}/quantui/help_content.py +0 -0
  42. {quantui-0.6.0 → quantui-0.6.1}/quantui/ir_plot.py +0 -0
  43. {quantui-0.6.0 → quantui-0.6.1}/quantui/issue_tracker.py +0 -0
  44. {quantui-0.6.0 → quantui-0.6.1}/quantui/live_log.py +0 -0
  45. {quantui-0.6.0 → quantui-0.6.1}/quantui/log_utils.py +0 -0
  46. {quantui-0.6.0 → quantui-0.6.1}/quantui/molecule.py +0 -0
  47. {quantui-0.6.0 → quantui-0.6.1}/quantui/molecule_library.py +0 -0
  48. {quantui-0.6.0 → quantui-0.6.1}/quantui/nmr_calc.py +0 -0
  49. {quantui-0.6.0 → quantui-0.6.1}/quantui/optimizer.py +0 -0
  50. {quantui-0.6.0 → quantui-0.6.1}/quantui/orbital_visualization.py +0 -0
  51. {quantui-0.6.0 → quantui-0.6.1}/quantui/pes_scan.py +0 -0
  52. {quantui-0.6.0 → quantui-0.6.1}/quantui/preopt.py +0 -0
  53. {quantui-0.6.0 → quantui-0.6.1}/quantui/progress.py +0 -0
  54. {quantui-0.6.0 → quantui-0.6.1}/quantui/pubchem.py +0 -0
  55. {quantui-0.6.0 → quantui-0.6.1}/quantui/security.py +0 -0
  56. {quantui-0.6.0 → quantui-0.6.1}/quantui/session_calc.py +0 -0
  57. {quantui-0.6.0 → quantui-0.6.1}/quantui/structure_providers.py +0 -0
  58. {quantui-0.6.0 → quantui-0.6.1}/quantui/tddft_calc.py +0 -0
  59. {quantui-0.6.0 → quantui-0.6.1}/quantui/theme.py +0 -0
  60. {quantui-0.6.0 → quantui-0.6.1}/quantui/user_settings.py +0 -0
  61. {quantui-0.6.0 → quantui-0.6.1}/quantui/utils.py +0 -0
  62. {quantui-0.6.0 → quantui-0.6.1}/quantui/vib_cache.py +0 -0
  63. {quantui-0.6.0 → quantui-0.6.1}/quantui/visualization_py3dmol.py +0 -0
  64. {quantui-0.6.0 → quantui-0.6.1}/quantui/viz_assets.py +0 -0
  65. {quantui-0.6.0 → quantui-0.6.1}/quantui/viz_backend_router.py +0 -0
  66. {quantui-0.6.0 → quantui-0.6.1}/quantui.egg-info/SOURCES.txt +0 -0
  67. {quantui-0.6.0 → quantui-0.6.1}/quantui.egg-info/dependency_links.txt +0 -0
  68. {quantui-0.6.0 → quantui-0.6.1}/quantui.egg-info/entry_points.txt +0 -0
  69. {quantui-0.6.0 → quantui-0.6.1}/quantui.egg-info/requires.txt +0 -0
  70. {quantui-0.6.0 → quantui-0.6.1}/quantui.egg-info/top_level.txt +0 -0
  71. {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.0...HEAD
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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: quantui
3
- Version: 0.6.0
3
+ Version: 0.6.1
4
4
  Summary: An open-source frontend for DFT and post-HF quantum chemistry with PySCF
5
5
  Author-email: Jonathan Schultz <nccu-schultz-lab@users.noreply.github.com>
6
6
  License: MIT License
@@ -8,7 +8,7 @@ build-backend = "setuptools.build_meta"
8
8
 
9
9
  [project]
10
10
  name = "quantui"
11
- version = "0.6.0"
11
+ version = "0.6.1"
12
12
  description = "An open-source frontend for DFT and post-HF quantum chemistry with PySCF"
13
13
  readme = "README.md"
14
14
  requires-python = ">=3.9"
@@ -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.0"
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
- def format_reorg_result(r: Any) -> str:
371
- """Format a reorganization-energy (Marcus 4-point) result card."""
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
- def _channel_block(ch: Any) -> str:
376
- rows = "".join(
377
- f'<tr><td style="padding:2px 18px 2px 0;color:#444">{k}</td>'
378
- f'<td style="color:#000;font-family:monospace">{v}</td></tr>'
379
- for k, v in [
380
- ("λ", f"{ch.lambda_ev:.4f} eV ({ch.lambda_kcal:.2f} kcal/mol)"),
381
- ("λ₁ ion relaxation", f"{ch.lambda1_hartree * 27.211386245988:.4f} eV"),
382
- (
383
- "λ₂ neutral relaxation",
384
- f"{ch.lambda2_hartree * 27.211386245988:.4f} eV",
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
- "Ion state",
388
- f"charge {ch.ion_charge:+d}, mult {ch.ion_multiplicity}",
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
- return (
577
+ blocks.append(
393
578
  f'<div style="margin-top:8px">'
394
- f'<b style="font-size:13px;color:#166534">{ch.label}</b>'
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"{rows}</table></div>"
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
- _channels_html = "".join(_channel_block(ch) for ch in r.channels)
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"]} &mdash; {data["method"]}/{data["basis"]}</b>'
492
729
  f'&ensp;<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></div>"
731
+ f"{_rows}{_extra}</table>{_reorg_html}</div>"
495
732
  )