quantui 0.6.0__tar.gz → 0.7.0__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 (73) hide show
  1. {quantui-0.6.0 → quantui-0.7.0}/CHANGELOG.md +104 -1
  2. {quantui-0.6.0/quantui.egg-info → quantui-0.7.0}/PKG-INFO +1 -1
  3. {quantui-0.6.0 → quantui-0.7.0}/pyproject.toml +1 -1
  4. {quantui-0.6.0 → quantui-0.7.0}/quantui/__init__.py +1 -1
  5. {quantui-0.6.0 → quantui-0.7.0}/quantui/app.py +311 -2
  6. {quantui-0.6.0 → quantui-0.7.0}/quantui/app_analysis.py +104 -0
  7. {quantui-0.6.0 → quantui-0.7.0}/quantui/app_builders.py +120 -0
  8. {quantui-0.6.0 → quantui-0.7.0}/quantui/app_formatters.py +260 -23
  9. {quantui-0.6.0 → quantui-0.7.0}/quantui/app_runflow.py +351 -8
  10. {quantui-0.6.0 → quantui-0.7.0}/quantui/app_visualization.py +194 -0
  11. {quantui-0.6.0 → quantui-0.7.0}/quantui/benchmarks.py +20 -2
  12. {quantui-0.6.0 → quantui-0.7.0}/quantui/calc_log.py +132 -8
  13. quantui-0.7.0/quantui/checkpoint.py +650 -0
  14. quantui-0.7.0/quantui/estimator_eval.py +272 -0
  15. {quantui-0.6.0 → quantui-0.7.0}/quantui/help_content.py +70 -0
  16. {quantui-0.6.0 → quantui-0.7.0}/quantui/optimizer.py +130 -3
  17. {quantui-0.6.0 → quantui-0.7.0}/quantui/pes_scan.py +117 -1
  18. {quantui-0.6.0 → quantui-0.7.0}/quantui/reorganization_energy.py +93 -0
  19. {quantui-0.6.0 → quantui-0.7.0}/quantui/results_storage.py +67 -0
  20. {quantui-0.6.0 → quantui-0.7.0}/quantui/session_calc.py +107 -1
  21. {quantui-0.6.0 → quantui-0.7.0/quantui.egg-info}/PKG-INFO +1 -1
  22. {quantui-0.6.0 → quantui-0.7.0}/quantui.egg-info/SOURCES.txt +2 -0
  23. {quantui-0.6.0 → quantui-0.7.0}/LICENSE +0 -0
  24. {quantui-0.6.0 → quantui-0.7.0}/MANIFEST.in +0 -0
  25. {quantui-0.6.0 → quantui-0.7.0}/README.md +0 -0
  26. {quantui-0.6.0 → quantui-0.7.0}/SECURITY.md +0 -0
  27. {quantui-0.6.0 → quantui-0.7.0}/quantui/analytics.py +0 -0
  28. {quantui-0.6.0 → quantui-0.7.0}/quantui/app_exports.py +0 -0
  29. {quantui-0.6.0 → quantui-0.7.0}/quantui/app_history.py +0 -0
  30. {quantui-0.6.0 → quantui-0.7.0}/quantui/ase_bridge.py +0 -0
  31. {quantui-0.6.0 → quantui-0.7.0}/quantui/c_stderr.py +0 -0
  32. {quantui-0.6.0 → quantui-0.7.0}/quantui/cactus.py +0 -0
  33. {quantui-0.6.0 → quantui-0.7.0}/quantui/calculator.py +0 -0
  34. {quantui-0.6.0 → quantui-0.7.0}/quantui/cancellation.py +0 -0
  35. {quantui-0.6.0 → quantui-0.7.0}/quantui/cli.py +0 -0
  36. {quantui-0.6.0 → quantui-0.7.0}/quantui/comparison.py +0 -0
  37. {quantui-0.6.0 → quantui-0.7.0}/quantui/config.py +0 -0
  38. {quantui-0.6.0 → quantui-0.7.0}/quantui/data/js/3Dmol-min.js +0 -0
  39. {quantui-0.6.0 → quantui-0.7.0}/quantui/data/js/3Dmol-min.js.LICENSE.txt +0 -0
  40. {quantui-0.6.0 → quantui-0.7.0}/quantui/data/library/library.sqlite +0 -0
  41. {quantui-0.6.0 → quantui-0.7.0}/quantui/data/manifests/bulk_qm9.json +0 -0
  42. {quantui-0.6.0 → quantui-0.7.0}/quantui/data/manifests/curated.json +0 -0
  43. {quantui-0.6.0 → quantui-0.7.0}/quantui/data/manifests/presets.json +0 -0
  44. {quantui-0.6.0 → quantui-0.7.0}/quantui/descriptor_cards.py +0 -0
  45. {quantui-0.6.0 → quantui-0.7.0}/quantui/freq_calc.py +0 -0
  46. {quantui-0.6.0 → quantui-0.7.0}/quantui/freq_ir_workers.py +0 -0
  47. {quantui-0.6.0 → quantui-0.7.0}/quantui/gpu_offload.py +0 -0
  48. {quantui-0.6.0 → quantui-0.7.0}/quantui/ir_plot.py +0 -0
  49. {quantui-0.6.0 → quantui-0.7.0}/quantui/issue_tracker.py +0 -0
  50. {quantui-0.6.0 → quantui-0.7.0}/quantui/live_log.py +0 -0
  51. {quantui-0.6.0 → quantui-0.7.0}/quantui/log_utils.py +0 -0
  52. {quantui-0.6.0 → quantui-0.7.0}/quantui/molecule.py +0 -0
  53. {quantui-0.6.0 → quantui-0.7.0}/quantui/molecule_library.py +0 -0
  54. {quantui-0.6.0 → quantui-0.7.0}/quantui/nmr_calc.py +0 -0
  55. {quantui-0.6.0 → quantui-0.7.0}/quantui/orbital_visualization.py +0 -0
  56. {quantui-0.6.0 → quantui-0.7.0}/quantui/preopt.py +0 -0
  57. {quantui-0.6.0 → quantui-0.7.0}/quantui/progress.py +0 -0
  58. {quantui-0.6.0 → quantui-0.7.0}/quantui/pubchem.py +0 -0
  59. {quantui-0.6.0 → quantui-0.7.0}/quantui/security.py +0 -0
  60. {quantui-0.6.0 → quantui-0.7.0}/quantui/structure_providers.py +0 -0
  61. {quantui-0.6.0 → quantui-0.7.0}/quantui/tddft_calc.py +0 -0
  62. {quantui-0.6.0 → quantui-0.7.0}/quantui/theme.py +0 -0
  63. {quantui-0.6.0 → quantui-0.7.0}/quantui/user_settings.py +0 -0
  64. {quantui-0.6.0 → quantui-0.7.0}/quantui/utils.py +0 -0
  65. {quantui-0.6.0 → quantui-0.7.0}/quantui/vib_cache.py +0 -0
  66. {quantui-0.6.0 → quantui-0.7.0}/quantui/visualization_py3dmol.py +0 -0
  67. {quantui-0.6.0 → quantui-0.7.0}/quantui/viz_assets.py +0 -0
  68. {quantui-0.6.0 → quantui-0.7.0}/quantui/viz_backend_router.py +0 -0
  69. {quantui-0.6.0 → quantui-0.7.0}/quantui.egg-info/dependency_links.txt +0 -0
  70. {quantui-0.6.0 → quantui-0.7.0}/quantui.egg-info/entry_points.txt +0 -0
  71. {quantui-0.6.0 → quantui-0.7.0}/quantui.egg-info/requires.txt +0 -0
  72. {quantui-0.6.0 → quantui-0.7.0}/quantui.egg-info/top_level.txt +0 -0
  73. {quantui-0.6.0 → quantui-0.7.0}/setup.cfg +0 -0
@@ -7,6 +7,108 @@ and this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.ht
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.7.0] - 2026-08-06
11
+
12
+ ### Fixed
13
+
14
+ - **Running the test suite no longer corrupts your time estimates.** QuantUI's
15
+ own tests were writing their runs into `~/.quantui/logs/perf_log.jsonl` — the
16
+ file the runtime estimator learns from. Because those tests use a *simulated*
17
+ calculation, each one recorded a fabricated timing: 2 773 records were in the
18
+ log and roughly four fifths of them were test artifacts, including "water
19
+ frequency" runs whose recorded times ranged from 0.34 s to 143 s for identical
20
+ chemistry. This is the reason the pre-run estimate had been unreliable. Only
21
+ developers running the test suite were affected; the fabricated records are
22
+ now superseded automatically as real runs accumulate, so no manual cleanup is
23
+ needed.
24
+ - **Calibration now times the calculation, not the process.** Each calibration
25
+ step runs in a fresh subprocess and its stopwatch started before PySCF was
26
+ even imported, so every calibration record was inflated by startup cost that
27
+ a real run in an open session never pays. Import time is still recorded, just
28
+ separately.
29
+
30
+ ### Added
31
+
32
+ - **Checkpoints — an interrupted calculation can pick up where it stopped.**
33
+ Geometry Optimization saves its trajectory and the optimizer's accumulated
34
+ curvature after every step; PES Scan banks each point as it finishes. If a
35
+ run is cancelled, crashes, or the machine goes to sleep, the Calculate tab
36
+ offers to resume it — and says how much is already done ("8 of 20 scan points
37
+ already computed") rather than just asking. The offer appears only when the
38
+ calculation you have configured is *exactly* the interrupted one, geometry
39
+ included, so a resume can never splice two different runs together.
40
+ - **An "Unfinished calculations" list on the History tab.** After restarting
41
+ QuantUI you no longer have to remember what you were running: every
42
+ interrupted calculation is listed with its molecule, type, level of theory,
43
+ how much finished and how long ago. **Load these settings** puts the molecule
44
+ and all its settings back on the Calculate tab, ready to resume; **Discard**
45
+ removes one you are done with. Hidden entirely when nothing is unfinished.
46
+ - **Checkpoint activity is recorded in the saved output log.** Opening a
47
+ checkpoint, every save, completion and discarding all appear as
48
+ `[checkpoint]` lines in `pyscf.log`. Resuming writes a banner stating plainly
49
+ that the log covers only the continuation and that the earlier output lives
50
+ in the interrupted run's result directory — without it the file would read
51
+ as a calculation that started from the geometry at the top. A warm start now
52
+ names the file its initial density came from, since the SCF iteration count
53
+ is only interpretable if you know what it started from.
54
+ - **Help topic: "Resuming an interrupted calculation."** Covers how to resume,
55
+ why the offer disappears if you change a setting, which calculation types can
56
+ be resumed, and where checkpoints are stored.
57
+ - **Warm-started SCF.** A converged density from an earlier run of the same
58
+ molecule, charge, method and basis is reused as the starting guess, which
59
+ usually cuts several SCF cycles. The geometry does not have to match — a
60
+ density from a nearby geometry is a good guess, which is what a geometry
61
+ optimization relies on internally.
62
+ - **Calculation records say how they were measured.** Runs launched from the app
63
+ and runs measured by the calibration tool are now labelled as such, and the
64
+ estimator keeps them apart instead of averaging two populations that measure
65
+ different things. Records also carry a per-stage time breakdown (SCF, Hessian,
66
+ excited-state solve, …), which is the groundwork for stage-aware estimates.
67
+ - **`python -m quantui.estimator_eval`** — replays your recorded history through
68
+ the estimator and reports how accurate it would have been, split by
69
+ calculation type. Reports coverage alongside accuracy, so a model that stays
70
+ silent can't look good by refusing to answer.
71
+
72
+ ## [0.6.1] - 2026-08-05
73
+
74
+ ### Fixed
75
+
76
+ - **Reorganization-energy results now survive History.** Reloading one from
77
+ History showed the calculation's headline numbers as missing — λ, the
78
+ four-point energies and the per-channel breakdown were never written to disk,
79
+ so there was nothing to redisplay. Both the live and History result cards now
80
+ render from the same saved data, so anything visible during a run is visible
81
+ after reloading it.
82
+ **Results saved before this cannot be recovered** — λ requires two geometry
83
+ optimizations and four SCF energies. Those results now say so on the card and
84
+ tell you to re-run, rather than appearing silently incomplete.
85
+ - **3-D backgrounds follow the theme** in the new geometry viewers, matching the
86
+ fix applied to the other viewers in 0.6.0.
87
+
88
+ ### Added
89
+
90
+ - **A Geometries panel** for reorganization energy, on the Analysis tab — which
91
+ previously showed nothing at all for this calculation type. Step through the
92
+ distinct geometries behind the four energies, or overlay two of them with
93
+ arrows marking how each atom moved. The arrows can be scaled ×1–×10 for small
94
+ relaxations; the structures always show true computed positions.
95
+ - **Geometry relaxation on the result card** — RMSD and the largest single-atom
96
+ shift between the neutral and ion geometries, which is what λ physically
97
+ measures.
98
+ - **Compare λ across saved results.** Selecting several reorganization-energy
99
+ runs in the Compare tab now shows a λ table with a column per channel, for
100
+ screening candidates by how much they reorganize.
101
+
102
+ ### Changed
103
+
104
+ - The NCShare GPU diagnostic notebook now reports the **CPU affinity mask**
105
+ alongside the core count, and states plainly when CPU timings cannot be
106
+ trusted. A job can be granted 6 cores while still *seeing* 192; whether that
107
+ matters depends on how the scheduler constrains it, and the core count alone
108
+ cannot distinguish the two cases.
109
+ - `apptainer/README.md` records the first verified H200 run — driver, compute
110
+ capability, partition, and the measured CPU/GPU crossover.
111
+
10
112
  ## [0.6.0] - 2026-08-04
11
113
 
12
114
  ### Added
@@ -563,7 +665,8 @@ Initial public scaffolding of the QuantUI package: `quantui` package with
563
665
  `calculator.py`, basic notebook launcher, Apptainer container definition,
564
666
  MIT license, and project metadata.
565
667
 
566
- [Unreleased]: https://github.com/The-Schultz-Lab/QuantUI/compare/v0.6.0...HEAD
668
+ [Unreleased]: https://github.com/The-Schultz-Lab/QuantUI/compare/v0.6.1...HEAD
669
+ [0.6.1]: https://github.com/The-Schultz-Lab/QuantUI/compare/v0.6.0...v0.6.1
567
670
  [0.6.0]: https://github.com/The-Schultz-Lab/QuantUI/compare/v0.5.2...v0.6.0
568
671
  [0.5.2]: https://github.com/The-Schultz-Lab/QuantUI/compare/v0.5.1...v0.5.2
569
672
  [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.7.0
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.7.0"
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.7.0"
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
  )
@@ -688,6 +694,9 @@ _RE_CYCLE = re.compile(
688
694
  )
689
695
  _RE_CONV = re.compile(r"converged SCF energy\s*=\s*([\-\d\.]+)")
690
696
  _RE_Q_STATUS = re.compile(r"\[QuantUI_STATUS\]\s*(.+)")
697
+ # Step/point/state counters inside a status message. Removed before the
698
+ # message is used as a per-stage timing key — see _LogCapture._stage_key.
699
+ _RE_STAGE_NUMBERS = re.compile(r"\d+(?:[./]\d+)*")
691
700
 
692
701
  # ── Silent-phase heartbeat (M-PROGRESS Phase D) ──────────────────────────────
693
702
  #
@@ -751,6 +760,60 @@ class _LogCapture:
751
760
  self._hb_started_t = self._last_write_t
752
761
  self._hb_stop = threading.Event()
753
762
  self._hb_thread: Optional[threading.Thread] = None
763
+ # Per-stage wall times (M-PROGRESS Phase C, deferred from B3).
764
+ # Every calc type already announces its phases through
765
+ # log_utils.emit_status, and every one of those announcements passes
766
+ # through this object — so stage boundaries can be timed here without
767
+ # threading a timer through optimizer/freq/tddft/nmr one by one.
768
+ self._stage_times: dict[str, float] = {}
769
+ self._stage_name: Optional[str] = None
770
+ self._stage_started_t = self._last_write_t
771
+
772
+ # ── Per-stage timing ────────────────────────────────────────────────────
773
+
774
+ @staticmethod
775
+ def _stage_key(message: str) -> str:
776
+ """Collapse a live status message to a stable stage name.
777
+
778
+ Status messages carry per-step detail — "Opt step 7 — SCF…",
779
+ "Solving TD-DFT excited states (10)…" — which is exactly right for
780
+ the user watching the run and exactly wrong as a dictionary key: it
781
+ would turn one stage into one entry per step. Stripping the numbers
782
+ leaves the phase itself, which is the unit a cost model reasons in.
783
+ """
784
+ text = _RE_STAGE_NUMBERS.sub(" ", message)
785
+ text = text.replace("…", " ").replace("(", " ").replace(")", " ")
786
+ text = " ".join(text.split())
787
+ return text.strip(" -—:·").lower()
788
+
789
+ def _enter_stage(self, name: str) -> None:
790
+ """Close the stage in progress and start *name*.
791
+
792
+ Repeated announcements of the same stage (the optimizer re-announces
793
+ every step) are treated as one continuous stage, so the recorded
794
+ breakdown stays at the granularity a cost model can use rather than
795
+ exploding into one entry per step.
796
+ """
797
+ now = time.monotonic()
798
+ if self._stage_name is not None and name != self._stage_name:
799
+ prev = self._stage_times.get(self._stage_name, 0.0)
800
+ self._stage_times[self._stage_name] = prev + (now - self._stage_started_t)
801
+ if name != self._stage_name:
802
+ self._stage_name = name
803
+ self._stage_started_t = now
804
+
805
+ def stage_timings(self) -> dict[str, float]:
806
+ """Return ``{stage: seconds}``, including the stage still running.
807
+
808
+ Safe to call mid-run: the open stage is measured up to now rather
809
+ than omitted, so a caller that logs this at the end of a calc gets a
810
+ breakdown that actually sums to the run.
811
+ """
812
+ out = dict(self._stage_times)
813
+ if self._stage_name is not None:
814
+ elapsed = time.monotonic() - self._stage_started_t
815
+ out[self._stage_name] = out.get(self._stage_name, 0.0) + elapsed
816
+ return out
754
817
 
755
818
  # ── Silent-phase heartbeat ──────────────────────────────────────────────
756
819
 
@@ -824,8 +887,11 @@ class _LogCapture:
824
887
  while "\n" in self._line_buf:
825
888
  line, self._line_buf = self._line_buf.split("\n", 1)
826
889
  m = _RE_Q_STATUS.search(line)
827
- if m and self._status is not None:
828
- self._status.value = m.group(1).strip()
890
+ if m:
891
+ message = m.group(1).strip()
892
+ self._enter_stage(self._stage_key(message))
893
+ if self._status is not None:
894
+ self._status.value = message
829
895
  continue
830
896
  m = _RE_CYCLE.search(line)
831
897
  if m and self._status is not None:
@@ -854,6 +920,7 @@ class _LogCapture:
854
920
  step k…") during silent (``verbose=0``) phases, without cluttering the
855
921
  output log the way a ``[QuantUI_STATUS]`` stream line would.
856
922
  """
923
+ self._enter_stage(self._stage_key(message))
857
924
  if self._status is not None:
858
925
  try:
859
926
  self._status.value = message
@@ -974,6 +1041,14 @@ class QuantUIApp:
974
1041
  _reset_confirm_html: Any
975
1042
  _reset_confirm_no: Any
976
1043
  _reset_confirm_yes: Any
1044
+ _resume_cb: Any
1045
+ _resume_discard_btn: Any
1046
+ _resume_entries: Any
1047
+ _resume_list_box: Any
1048
+ _resume_list_dd: Any
1049
+ _resume_list_html: Any
1050
+ _resume_notice_html: Any
1051
+ _resume_restore_btn: Any
977
1052
  _status_html: Any
978
1053
  _status_tab_panel: Any
979
1054
  _theme_style: Any
@@ -1230,6 +1305,12 @@ class QuantUIApp:
1230
1305
  # The active run's _LogCapture, so the ticker can read the
1231
1306
  # completion fraction calc modules report onto it. None between runs.
1232
1307
  self._active_log: Optional[_LogCapture] = None
1308
+ # Calc types this session has already completed once. The first run
1309
+ # of a type pays import costs later ones don't (PySCF loads its
1310
+ # Hessian module on the first Frequency, for instance), so the
1311
+ # perf record carries a warm/cold flag rather than silently mixing
1312
+ # the two populations. See calc_log.log_calculation.
1313
+ self._warm_calc_types: set[str] = set()
1233
1314
  # Relaxed molecule from a pending pre-opt preview, awaiting Keep/Revert.
1234
1315
  self._preopt_relaxed_mol: Optional[Molecule] = None
1235
1316
  # Cache kernel io_loop once on the main thread so worker threads can
@@ -1348,9 +1429,14 @@ class QuantUIApp:
1348
1429
  if loop is not None:
1349
1430
  loop.add_callback(self._refresh_results_browser)
1350
1431
  loop.add_callback(self._populate_compare_list)
1432
+ # Startup is the moment that matters for CHK.6: after a restart
1433
+ # the targeted resume offer can't fire, because nothing is
1434
+ # configured yet.
1435
+ loop.add_callback(self._refresh_resume_list)
1351
1436
  else:
1352
1437
  self._refresh_results_browser()
1353
1438
  self._populate_compare_list()
1439
+ self._refresh_resume_list()
1354
1440
 
1355
1441
  def display(self) -> None:
1356
1442
  """Inject global CSS and render the application widget."""
@@ -1630,6 +1716,7 @@ class QuantUIApp:
1630
1716
  ("IR Spectrum", "_ir_accordion", "Frequency"),
1631
1717
  ("PES Scan", "_pes_scan_accordion", "PES Scan"),
1632
1718
  ("Isosurface", "_iso_accordion", "Single Point (Linux/WSL only)"),
1719
+ ("Geometries", "_reorg_geom_accordion", "Reorganization Energy"),
1633
1720
  ("UV-Vis", "_tddft_accordion", "UV-Vis (TD-DFT)"),
1634
1721
  ("NMR", "_nmr_accordion", "NMR Shielding"),
1635
1722
  ]
@@ -1678,6 +1765,16 @@ class QuantUIApp:
1678
1765
  "nmr": [
1679
1766
  ("NMR", "_pop_nmr_shielding", True),
1680
1767
  ],
1768
+ # reorganization_energy had NO entry at all until 2026-08-05, so the
1769
+ # Analysis tab populated nothing for these runs — not a missing panel,
1770
+ # no panels. Order matters twice over: the FIRST auto_select=True that
1771
+ # returns True wins, AND _pop_energies loads the orbital state
1772
+ # _pop_isosurface checks, so Energies must precede Isosurface.
1773
+ "reorganization_energy": [
1774
+ ("Energies", "_pop_energies", False),
1775
+ ("Geometries", "_pop_reorg_geometries", True),
1776
+ ("Isosurface", "_pop_isosurface", False),
1777
+ ],
1681
1778
  "pes_scan": [
1682
1779
  ("PES Scan", "_pop_pes_plot", True),
1683
1780
  ("Trajectory", "_pop_pes_trajectory", False),
@@ -1693,6 +1790,9 @@ class QuantUIApp:
1693
1790
  def _pop_energies(self, ctx: _AnalysisContext) -> bool:
1694
1791
  return _ana_pop_energies(self, ctx)
1695
1792
 
1793
+ def _pop_reorg_geometries(self, ctx: _AnalysisContext) -> bool:
1794
+ return _ana_pop_reorg_geometries(self, ctx)
1795
+
1696
1796
  def _pop_isosurface(self, ctx: _AnalysisContext) -> bool:
1697
1797
  return _ana_pop_isosurface(self, ctx)
1698
1798
 
@@ -1881,6 +1981,12 @@ class QuantUIApp:
1881
1981
  self.mult_si.observe(self._safe_cb(self._update_notes), names="value")
1882
1982
  self.method_dd.observe(self._safe_cb(self._update_estimate), names="value")
1883
1983
  self.basis_dd.observe(self._safe_cb(self._update_estimate), names="value")
1984
+ # Unfinished-calculations list (CHK.6)
1985
+ self._resume_list_dd.observe(
1986
+ self._safe_cb(self._on_resume_entry_changed), names="value"
1987
+ )
1988
+ self._resume_restore_btn.on_click(self._on_resume_restore)
1989
+ self._resume_discard_btn.on_click(self._on_resume_discard)
1884
1990
  # Help buttons
1885
1991
  self.method_help_btn.on_click(self._on_method_help)
1886
1992
  self.basis_help_btn.on_click(self._on_basis_help)
@@ -2014,6 +2120,14 @@ class QuantUIApp:
2014
2120
  # Cube + bundle exports
2015
2121
  self._iso_export_cube_btn.on_click(self._on_iso_export_cube)
2016
2122
  self._iso_cancel_btn.on_click(self._safe_cb(self._on_iso_cancel))
2123
+ # Reorg geometry views (REORG.3): both redraw from data already in
2124
+ # memory, so they respond directly rather than behind an Apply button.
2125
+ for _w in (
2126
+ self._reorg_view_toggle,
2127
+ self._reorg_overlay_pair,
2128
+ self._reorg_exaggerate,
2129
+ ):
2130
+ _w.observe(self._safe_cb(self._on_reorg_view_changed), names="value")
2017
2131
  # PNG capture arrives from the browser, so there is no button to bind
2018
2132
  # here — the viewer's own Save-PNG button posts into this Textarea and
2019
2133
  # ipywidgets syncs it back, firing this observer (ORBX.1).
@@ -3378,6 +3492,9 @@ class QuantUIApp:
3378
3492
  def _on_export_pdb(self, btn) -> None:
3379
3493
  _exp_on_export_pdb(self, btn)
3380
3494
 
3495
+ def _on_reorg_view_changed(self, change) -> None:
3496
+ _ana_on_reorg_view_changed(self, change)
3497
+
3381
3498
  def _on_iso_cancel(self, btn) -> None:
3382
3499
  _viz_on_iso_cancel(self, btn)
3383
3500
 
@@ -4387,6 +4504,7 @@ class QuantUIApp:
4387
4504
  n_basis=_nb_for_est,
4388
4505
  calc_type=_ct_for_est,
4389
4506
  gpu_used=_predicted_gpu_used,
4507
+ source="app",
4390
4508
  )
4391
4509
  if _est is not None:
4392
4510
  _predicted_run_s = float(_est["seconds"])
@@ -4460,6 +4578,28 @@ class QuantUIApp:
4460
4578
  # and keep writing into a finished log.
4461
4579
  log.start_heartbeat()
4462
4580
 
4581
+ # --- Checkpoint for this run (M-CHECKPOINT) ---
4582
+ # Opened before any calculation starts, because the runs worth
4583
+ # checkpointing are exactly the ones that never reach the end. Failure
4584
+ # to open one leaves ``_ckpt`` as None and the calc runs
4585
+ # uncheckpointed, which is the pre-M-CHECKPOINT behaviour.
4586
+ #
4587
+ # Deliberately after ``log`` exists: the checkpoint writes its own
4588
+ # provenance lines into the run log, and those lines are the only
4589
+ # record that a resumed run did not start from the geometry at the
4590
+ # top of the file.
4591
+ _ckpt = self._begin_run_checkpoint(log)
4592
+ # Resume only when there is genuinely something to continue. The
4593
+ # checkbox defaults to checked and is *hidden* when nothing is
4594
+ # resumable, so consulting it alone would ask every ordinary run to
4595
+ # resume — and the optimizer would answer with a "no usable
4596
+ # checkpoint" warning on a calculation the user started from scratch.
4597
+ _resume = bool(
4598
+ _ckpt is not None
4599
+ and self._resume_cb.value
4600
+ and getattr(self, "_checkpoint_resumable", False)
4601
+ )
4602
+
4463
4603
  # The run header (structured banner) is written synchronously + atomically
4464
4604
  # on the main thread by ``on_run_clicked`` → ``_write_run_header`` BEFORE
4465
4605
  # this background thread starts. Writing it here (bg thread) instead was
@@ -4578,6 +4718,8 @@ class QuantUIApp:
4578
4718
  expected_steps=(
4579
4719
  int(round(_expected_steps)) if _expected_steps else None
4580
4720
  ),
4721
+ checkpoint=_ckpt,
4722
+ resume=_resume,
4581
4723
  )
4582
4724
  _sp_result = _run_required_final_single_point(
4583
4725
  result.molecule,
@@ -4850,6 +4992,8 @@ class QuantUIApp:
4850
4992
  stop=self._scan_stop.value,
4851
4993
  steps=self._scan_steps.value,
4852
4994
  progress_stream=log, # type: ignore[arg-type]
4995
+ checkpoint=_ckpt,
4996
+ resume=_resume,
4853
4997
  )
4854
4998
  result_html = self._format_pes_scan_result(result)
4855
4999
  save_spectra = {
@@ -4905,6 +5049,7 @@ class QuantUIApp:
4905
5049
  basis=self.basis_dd.value,
4906
5050
  progress_stream=log, # type: ignore[arg-type]
4907
5051
  solvent=_solvent,
5052
+ checkpoint=_ckpt,
4908
5053
  )
4909
5054
  result_html = self._format_result(result)
4910
5055
  save_spectra, save_type = {}, "single_point"
@@ -5127,6 +5272,8 @@ class QuantUIApp:
5127
5272
  _mark("perf_begin")
5128
5273
  try:
5129
5274
  _elapsed_for_est = time.perf_counter() - _run_wall_t
5275
+ _was_warm = save_type in self._warm_calc_types
5276
+ self._warm_calc_types.add(save_type)
5130
5277
  _calc_log.log_calculation(
5131
5278
  formula=result.formula,
5132
5279
  n_atoms=len(calc_mol.atoms),
@@ -5144,6 +5291,9 @@ class QuantUIApp:
5144
5291
  gpu_used=getattr(result, "gpu_used", None),
5145
5292
  gpu_name=getattr(result, "gpu_name", None),
5146
5293
  n_steps=getattr(result, "n_steps", None),
5294
+ source="app",
5295
+ warm=_was_warm,
5296
+ stages=log.stage_timings(),
5147
5297
  )
5148
5298
  _calc_log.log_event(
5149
5299
  "calc_done",
@@ -5325,6 +5475,7 @@ class QuantUIApp:
5325
5475
  "Tips: try a smaller basis set (STO-3G), use a geometry-optimized "
5326
5476
  "structure first, or check for unusually long/short bonds in your "
5327
5477
  "XYZ input. Full error details are in the <b>Output</b> tab.</small>"
5478
+ f"{self._resume_hint_html(_ckpt)}"
5328
5479
  "</div>"
5329
5480
  )
5330
5481
  self.result_output.append_display_data(HTML(_err_html))
@@ -5347,6 +5498,7 @@ class QuantUIApp:
5347
5498
  log.stop_heartbeat()
5348
5499
  except Exception: # noqa: BLE001 — teardown must not mask a failure
5349
5500
  pass
5501
+ self._finish_run_checkpoint(_ckpt)
5350
5502
  self._activity_end(kind="compute")
5351
5503
 
5352
5504
  # ── Live elapsed ticker ────────────────────────────────────────────────
@@ -5437,6 +5589,163 @@ class QuantUIApp:
5437
5589
  def _update_notes(self, change=None) -> None:
5438
5590
  _run_update_notes(self, change)
5439
5591
 
5592
+ def _begin_run_checkpoint(self, log_stream: Optional[Any] = None) -> Optional[Any]:
5593
+ """Open a checkpoint for the run about to start, or return ``None``.
5594
+
5595
+ Returning ``None`` — no molecule, an unavailable checkpoint module, an
5596
+ unwritable directory — means the calculation runs without one. A
5597
+ checkpoint is an optimisation for the failure case; it must never be
5598
+ the reason a calculation doesn't start.
5599
+ """
5600
+ try:
5601
+ from quantui.app_runflow import checkpoint_identity
5602
+ from quantui.checkpoint import Checkpoint
5603
+
5604
+ identity = checkpoint_identity(self)
5605
+ if identity is None:
5606
+ self._checkpoint_resumable = False
5607
+ return None
5608
+ ckpt = Checkpoint(identity, log_stream=log_stream)
5609
+ # Read resumability BEFORE begin() — begin() rewrites the metadata
5610
+ # with a fresh "running" status, so asking afterwards would
5611
+ # describe the run about to start rather than the one that stopped.
5612
+ self._checkpoint_resumable = ckpt.resumable_state() is not None
5613
+ extra: dict = {}
5614
+ if self.calc_type_dd.value == "PES Scan":
5615
+ # Lets the resume offer say "8 of 20" rather than just "8".
5616
+ extra["total_points"] = int(self._scan_steps.value)
5617
+ # Scan geometry isn't part of the checkpoint identity, so
5618
+ # without these a restore would reinstate the molecule and
5619
+ # method but silently leave a different scan range — and the
5620
+ # stored points, matched by coordinate value, would all miss.
5621
+ extra["settings"] = {
5622
+ "scan_type": self._scan_type_dd.value,
5623
+ "scan_atom1": int(self._scan_atom1.value),
5624
+ "scan_atom2": int(self._scan_atom2.value),
5625
+ "scan_atom3": int(self._scan_atom3.value),
5626
+ "scan_atom4": int(self._scan_atom4.value),
5627
+ "scan_start": float(self._scan_start.value),
5628
+ "scan_stop": float(self._scan_stop.value),
5629
+ "scan_steps": int(self._scan_steps.value),
5630
+ }
5631
+ if not ckpt.begin(**extra):
5632
+ return None
5633
+ return ckpt
5634
+ except Exception as exc: # noqa: BLE001 — never block a run
5635
+ self._checkpoint_resumable = False
5636
+ try:
5637
+ _calc_log.log_event(
5638
+ "checkpoint_unavailable", f"{type(exc).__name__}: {exc}"[:200]
5639
+ )
5640
+ except Exception: # noqa: BLE001 — telemetry self-guard
5641
+ pass
5642
+ return None
5643
+
5644
+ def _refresh_resume_list(self) -> None:
5645
+ """Rebuild the History tab's unfinished-calculations list."""
5646
+ try:
5647
+ from quantui.app_runflow import refresh_resume_list
5648
+
5649
+ refresh_resume_list(self)
5650
+ except Exception: # noqa: BLE001 — never break the History tab
5651
+ pass
5652
+
5653
+ def _on_resume_entry_changed(self, _change=None) -> None:
5654
+ from quantui.app_runflow import describe_resume_entry
5655
+
5656
+ describe_resume_entry(self, _change)
5657
+
5658
+ def _on_resume_restore(self, _btn=None) -> None:
5659
+ """Load the selected checkpoint's settings and go to Calculate."""
5660
+ from quantui.app_runflow import restore_resume_entry
5661
+
5662
+ if not restore_resume_entry(self):
5663
+ return
5664
+ # Send the user where the settings just landed. Restoring without
5665
+ # moving them leaves the effect invisible on a tab they aren't
5666
+ # looking at, which reads as the button having done nothing.
5667
+ try:
5668
+ self.root_tab.selected_index = 1
5669
+ except Exception: # noqa: BLE001 — tab index is cosmetic
5670
+ pass
5671
+
5672
+ def _on_resume_discard(self, _btn=None) -> None:
5673
+ """Delete the selected checkpoint and refresh the list."""
5674
+ try:
5675
+ import shutil
5676
+
5677
+ selected = self._resume_list_dd.value
5678
+ if selected and selected in (getattr(self, "_resume_entries", None) or {}):
5679
+ shutil.rmtree(selected, ignore_errors=True)
5680
+ except Exception: # noqa: BLE001 — discarding is best-effort
5681
+ pass
5682
+ self._refresh_resume_list()
5683
+ try:
5684
+ from quantui.app_runflow import refresh_resume_notice
5685
+
5686
+ refresh_resume_notice(self)
5687
+ except Exception: # noqa: BLE001 — a stale notice is not fatal
5688
+ pass
5689
+
5690
+ def _resume_hint_html(self, ckpt: Optional[Any]) -> str:
5691
+ """Return a "you can resume this" line for the failure card, or ``""``.
5692
+
5693
+ The resume offer itself lives up by the Run button, which is not where
5694
+ anyone is looking after a calculation fails. Saying it here, next to
5695
+ the error, is the difference between the feature being discovered and
5696
+ it quietly never being used.
5697
+ """
5698
+ try:
5699
+ if ckpt is None or ckpt.resumable_state() is None:
5700
+ return ""
5701
+ points = len(ckpt.completed_points())
5702
+ done = f"{points} completed scan point{'s' if points != 1 else ''}"
5703
+ if not points:
5704
+ done = "the steps completed so far"
5705
+ return (
5706
+ '<br><br><small style="color:#991b1b">'
5707
+ f"&#9851; <b>This run can be resumed.</b> {done} "
5708
+ "were saved. Leave the settings as they are and press "
5709
+ "<b>Run</b> again — the <b>Resume from checkpoint</b> box "
5710
+ "above the Run button is already ticked.</small>"
5711
+ )
5712
+ except Exception: # noqa: BLE001 — a hint must never mask the error
5713
+ return ""
5714
+
5715
+ def _finish_run_checkpoint(self, ckpt: Optional[Any]) -> None:
5716
+ """Close out *ckpt* after a run ends, however it ended.
5717
+
5718
+ Runs from the ``finally`` of ``_do_run``, so it is reached on success,
5719
+ on failure and on cancel. It deliberately does **not** decide whether
5720
+ the run succeeded — the calc modules mark completion themselves, since
5721
+ only they know whether "finished" means converged, and a run that
5722
+ stopped early must keep its resumable state.
5723
+
5724
+ What happens here is bookkeeping: refresh the resume offer so it
5725
+ reflects reality, and prune old checkpoints so the directory doesn't
5726
+ grow without bound.
5727
+ """
5728
+ try:
5729
+ if ckpt is not None and self._cancel_event.is_set():
5730
+ ckpt.mark_interrupted()
5731
+ except Exception: # noqa: BLE001 — teardown must not mask a failure
5732
+ pass
5733
+ try:
5734
+ from quantui.checkpoint import prune
5735
+
5736
+ prune()
5737
+ except Exception: # noqa: BLE001 — retention is best-effort
5738
+ pass
5739
+ try:
5740
+ from quantui.app_runflow import refresh_resume_notice
5741
+
5742
+ refresh_resume_notice(self)
5743
+ except Exception: # noqa: BLE001 — a stale notice is not fatal
5744
+ pass
5745
+ # A run that just failed becomes a new listing entry; one that
5746
+ # succeeded removes itself. Either way the list is now stale.
5747
+ self._refresh_resume_list()
5748
+
5440
5749
  def _update_estimate(self, change=None) -> None:
5441
5750
  _run_update_estimate(self, calc_log_mod=_calc_log, change=change)
5442
5751