quantui 0.6.1__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.1 → quantui-0.7.0}/CHANGELOG.md +62 -0
  2. {quantui-0.6.1/quantui.egg-info → quantui-0.7.0}/PKG-INFO +1 -1
  3. {quantui-0.6.1 → quantui-0.7.0}/pyproject.toml +1 -1
  4. {quantui-0.6.1 → quantui-0.7.0}/quantui/__init__.py +1 -1
  5. {quantui-0.6.1 → quantui-0.7.0}/quantui/app.py +280 -2
  6. {quantui-0.6.1 → quantui-0.7.0}/quantui/app_builders.py +63 -0
  7. {quantui-0.6.1 → quantui-0.7.0}/quantui/app_runflow.py +335 -8
  8. {quantui-0.6.1 → quantui-0.7.0}/quantui/benchmarks.py +20 -2
  9. {quantui-0.6.1 → quantui-0.7.0}/quantui/calc_log.py +132 -8
  10. quantui-0.7.0/quantui/checkpoint.py +650 -0
  11. quantui-0.7.0/quantui/estimator_eval.py +272 -0
  12. {quantui-0.6.1 → quantui-0.7.0}/quantui/help_content.py +70 -0
  13. {quantui-0.6.1 → quantui-0.7.0}/quantui/optimizer.py +130 -3
  14. {quantui-0.6.1 → quantui-0.7.0}/quantui/pes_scan.py +117 -1
  15. {quantui-0.6.1 → quantui-0.7.0}/quantui/session_calc.py +107 -1
  16. {quantui-0.6.1 → quantui-0.7.0/quantui.egg-info}/PKG-INFO +1 -1
  17. {quantui-0.6.1 → quantui-0.7.0}/quantui.egg-info/SOURCES.txt +2 -0
  18. {quantui-0.6.1 → quantui-0.7.0}/LICENSE +0 -0
  19. {quantui-0.6.1 → quantui-0.7.0}/MANIFEST.in +0 -0
  20. {quantui-0.6.1 → quantui-0.7.0}/README.md +0 -0
  21. {quantui-0.6.1 → quantui-0.7.0}/SECURITY.md +0 -0
  22. {quantui-0.6.1 → quantui-0.7.0}/quantui/analytics.py +0 -0
  23. {quantui-0.6.1 → quantui-0.7.0}/quantui/app_analysis.py +0 -0
  24. {quantui-0.6.1 → quantui-0.7.0}/quantui/app_exports.py +0 -0
  25. {quantui-0.6.1 → quantui-0.7.0}/quantui/app_formatters.py +0 -0
  26. {quantui-0.6.1 → quantui-0.7.0}/quantui/app_history.py +0 -0
  27. {quantui-0.6.1 → quantui-0.7.0}/quantui/app_visualization.py +0 -0
  28. {quantui-0.6.1 → quantui-0.7.0}/quantui/ase_bridge.py +0 -0
  29. {quantui-0.6.1 → quantui-0.7.0}/quantui/c_stderr.py +0 -0
  30. {quantui-0.6.1 → quantui-0.7.0}/quantui/cactus.py +0 -0
  31. {quantui-0.6.1 → quantui-0.7.0}/quantui/calculator.py +0 -0
  32. {quantui-0.6.1 → quantui-0.7.0}/quantui/cancellation.py +0 -0
  33. {quantui-0.6.1 → quantui-0.7.0}/quantui/cli.py +0 -0
  34. {quantui-0.6.1 → quantui-0.7.0}/quantui/comparison.py +0 -0
  35. {quantui-0.6.1 → quantui-0.7.0}/quantui/config.py +0 -0
  36. {quantui-0.6.1 → quantui-0.7.0}/quantui/data/js/3Dmol-min.js +0 -0
  37. {quantui-0.6.1 → quantui-0.7.0}/quantui/data/js/3Dmol-min.js.LICENSE.txt +0 -0
  38. {quantui-0.6.1 → quantui-0.7.0}/quantui/data/library/library.sqlite +0 -0
  39. {quantui-0.6.1 → quantui-0.7.0}/quantui/data/manifests/bulk_qm9.json +0 -0
  40. {quantui-0.6.1 → quantui-0.7.0}/quantui/data/manifests/curated.json +0 -0
  41. {quantui-0.6.1 → quantui-0.7.0}/quantui/data/manifests/presets.json +0 -0
  42. {quantui-0.6.1 → quantui-0.7.0}/quantui/descriptor_cards.py +0 -0
  43. {quantui-0.6.1 → quantui-0.7.0}/quantui/freq_calc.py +0 -0
  44. {quantui-0.6.1 → quantui-0.7.0}/quantui/freq_ir_workers.py +0 -0
  45. {quantui-0.6.1 → quantui-0.7.0}/quantui/gpu_offload.py +0 -0
  46. {quantui-0.6.1 → quantui-0.7.0}/quantui/ir_plot.py +0 -0
  47. {quantui-0.6.1 → quantui-0.7.0}/quantui/issue_tracker.py +0 -0
  48. {quantui-0.6.1 → quantui-0.7.0}/quantui/live_log.py +0 -0
  49. {quantui-0.6.1 → quantui-0.7.0}/quantui/log_utils.py +0 -0
  50. {quantui-0.6.1 → quantui-0.7.0}/quantui/molecule.py +0 -0
  51. {quantui-0.6.1 → quantui-0.7.0}/quantui/molecule_library.py +0 -0
  52. {quantui-0.6.1 → quantui-0.7.0}/quantui/nmr_calc.py +0 -0
  53. {quantui-0.6.1 → quantui-0.7.0}/quantui/orbital_visualization.py +0 -0
  54. {quantui-0.6.1 → quantui-0.7.0}/quantui/preopt.py +0 -0
  55. {quantui-0.6.1 → quantui-0.7.0}/quantui/progress.py +0 -0
  56. {quantui-0.6.1 → quantui-0.7.0}/quantui/pubchem.py +0 -0
  57. {quantui-0.6.1 → quantui-0.7.0}/quantui/reorganization_energy.py +0 -0
  58. {quantui-0.6.1 → quantui-0.7.0}/quantui/results_storage.py +0 -0
  59. {quantui-0.6.1 → quantui-0.7.0}/quantui/security.py +0 -0
  60. {quantui-0.6.1 → quantui-0.7.0}/quantui/structure_providers.py +0 -0
  61. {quantui-0.6.1 → quantui-0.7.0}/quantui/tddft_calc.py +0 -0
  62. {quantui-0.6.1 → quantui-0.7.0}/quantui/theme.py +0 -0
  63. {quantui-0.6.1 → quantui-0.7.0}/quantui/user_settings.py +0 -0
  64. {quantui-0.6.1 → quantui-0.7.0}/quantui/utils.py +0 -0
  65. {quantui-0.6.1 → quantui-0.7.0}/quantui/vib_cache.py +0 -0
  66. {quantui-0.6.1 → quantui-0.7.0}/quantui/visualization_py3dmol.py +0 -0
  67. {quantui-0.6.1 → quantui-0.7.0}/quantui/viz_assets.py +0 -0
  68. {quantui-0.6.1 → quantui-0.7.0}/quantui/viz_backend_router.py +0 -0
  69. {quantui-0.6.1 → quantui-0.7.0}/quantui.egg-info/dependency_links.txt +0 -0
  70. {quantui-0.6.1 → quantui-0.7.0}/quantui.egg-info/entry_points.txt +0 -0
  71. {quantui-0.6.1 → quantui-0.7.0}/quantui.egg-info/requires.txt +0 -0
  72. {quantui-0.6.1 → quantui-0.7.0}/quantui.egg-info/top_level.txt +0 -0
  73. {quantui-0.6.1 → quantui-0.7.0}/setup.cfg +0 -0
@@ -7,6 +7,68 @@ 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
+
10
72
  ## [0.6.1] - 2026-08-05
11
73
 
12
74
  ### Fixed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: quantui
3
- Version: 0.6.1
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.1"
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.1"
10
+ __version__ = "0.7.0"
11
11
 
12
12
  import logging
13
13
  from typing import Any
@@ -694,6 +694,9 @@ _RE_CYCLE = re.compile(
694
694
  )
695
695
  _RE_CONV = re.compile(r"converged SCF energy\s*=\s*([\-\d\.]+)")
696
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+)*")
697
700
 
698
701
  # ── Silent-phase heartbeat (M-PROGRESS Phase D) ──────────────────────────────
699
702
  #
@@ -757,6 +760,60 @@ class _LogCapture:
757
760
  self._hb_started_t = self._last_write_t
758
761
  self._hb_stop = threading.Event()
759
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
760
817
 
761
818
  # ── Silent-phase heartbeat ──────────────────────────────────────────────
762
819
 
@@ -830,8 +887,11 @@ class _LogCapture:
830
887
  while "\n" in self._line_buf:
831
888
  line, self._line_buf = self._line_buf.split("\n", 1)
832
889
  m = _RE_Q_STATUS.search(line)
833
- if m and self._status is not None:
834
- 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
835
895
  continue
836
896
  m = _RE_CYCLE.search(line)
837
897
  if m and self._status is not None:
@@ -860,6 +920,7 @@ class _LogCapture:
860
920
  step k…") during silent (``verbose=0``) phases, without cluttering the
861
921
  output log the way a ``[QuantUI_STATUS]`` stream line would.
862
922
  """
923
+ self._enter_stage(self._stage_key(message))
863
924
  if self._status is not None:
864
925
  try:
865
926
  self._status.value = message
@@ -980,6 +1041,14 @@ class QuantUIApp:
980
1041
  _reset_confirm_html: Any
981
1042
  _reset_confirm_no: Any
982
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
983
1052
  _status_html: Any
984
1053
  _status_tab_panel: Any
985
1054
  _theme_style: Any
@@ -1236,6 +1305,12 @@ class QuantUIApp:
1236
1305
  # The active run's _LogCapture, so the ticker can read the
1237
1306
  # completion fraction calc modules report onto it. None between runs.
1238
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()
1239
1314
  # Relaxed molecule from a pending pre-opt preview, awaiting Keep/Revert.
1240
1315
  self._preopt_relaxed_mol: Optional[Molecule] = None
1241
1316
  # Cache kernel io_loop once on the main thread so worker threads can
@@ -1354,9 +1429,14 @@ class QuantUIApp:
1354
1429
  if loop is not None:
1355
1430
  loop.add_callback(self._refresh_results_browser)
1356
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)
1357
1436
  else:
1358
1437
  self._refresh_results_browser()
1359
1438
  self._populate_compare_list()
1439
+ self._refresh_resume_list()
1360
1440
 
1361
1441
  def display(self) -> None:
1362
1442
  """Inject global CSS and render the application widget."""
@@ -1901,6 +1981,12 @@ class QuantUIApp:
1901
1981
  self.mult_si.observe(self._safe_cb(self._update_notes), names="value")
1902
1982
  self.method_dd.observe(self._safe_cb(self._update_estimate), names="value")
1903
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)
1904
1990
  # Help buttons
1905
1991
  self.method_help_btn.on_click(self._on_method_help)
1906
1992
  self.basis_help_btn.on_click(self._on_basis_help)
@@ -4418,6 +4504,7 @@ class QuantUIApp:
4418
4504
  n_basis=_nb_for_est,
4419
4505
  calc_type=_ct_for_est,
4420
4506
  gpu_used=_predicted_gpu_used,
4507
+ source="app",
4421
4508
  )
4422
4509
  if _est is not None:
4423
4510
  _predicted_run_s = float(_est["seconds"])
@@ -4491,6 +4578,28 @@ class QuantUIApp:
4491
4578
  # and keep writing into a finished log.
4492
4579
  log.start_heartbeat()
4493
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
+
4494
4603
  # The run header (structured banner) is written synchronously + atomically
4495
4604
  # on the main thread by ``on_run_clicked`` → ``_write_run_header`` BEFORE
4496
4605
  # this background thread starts. Writing it here (bg thread) instead was
@@ -4609,6 +4718,8 @@ class QuantUIApp:
4609
4718
  expected_steps=(
4610
4719
  int(round(_expected_steps)) if _expected_steps else None
4611
4720
  ),
4721
+ checkpoint=_ckpt,
4722
+ resume=_resume,
4612
4723
  )
4613
4724
  _sp_result = _run_required_final_single_point(
4614
4725
  result.molecule,
@@ -4881,6 +4992,8 @@ class QuantUIApp:
4881
4992
  stop=self._scan_stop.value,
4882
4993
  steps=self._scan_steps.value,
4883
4994
  progress_stream=log, # type: ignore[arg-type]
4995
+ checkpoint=_ckpt,
4996
+ resume=_resume,
4884
4997
  )
4885
4998
  result_html = self._format_pes_scan_result(result)
4886
4999
  save_spectra = {
@@ -4936,6 +5049,7 @@ class QuantUIApp:
4936
5049
  basis=self.basis_dd.value,
4937
5050
  progress_stream=log, # type: ignore[arg-type]
4938
5051
  solvent=_solvent,
5052
+ checkpoint=_ckpt,
4939
5053
  )
4940
5054
  result_html = self._format_result(result)
4941
5055
  save_spectra, save_type = {}, "single_point"
@@ -5158,6 +5272,8 @@ class QuantUIApp:
5158
5272
  _mark("perf_begin")
5159
5273
  try:
5160
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)
5161
5277
  _calc_log.log_calculation(
5162
5278
  formula=result.formula,
5163
5279
  n_atoms=len(calc_mol.atoms),
@@ -5175,6 +5291,9 @@ class QuantUIApp:
5175
5291
  gpu_used=getattr(result, "gpu_used", None),
5176
5292
  gpu_name=getattr(result, "gpu_name", None),
5177
5293
  n_steps=getattr(result, "n_steps", None),
5294
+ source="app",
5295
+ warm=_was_warm,
5296
+ stages=log.stage_timings(),
5178
5297
  )
5179
5298
  _calc_log.log_event(
5180
5299
  "calc_done",
@@ -5356,6 +5475,7 @@ class QuantUIApp:
5356
5475
  "Tips: try a smaller basis set (STO-3G), use a geometry-optimized "
5357
5476
  "structure first, or check for unusually long/short bonds in your "
5358
5477
  "XYZ input. Full error details are in the <b>Output</b> tab.</small>"
5478
+ f"{self._resume_hint_html(_ckpt)}"
5359
5479
  "</div>"
5360
5480
  )
5361
5481
  self.result_output.append_display_data(HTML(_err_html))
@@ -5378,6 +5498,7 @@ class QuantUIApp:
5378
5498
  log.stop_heartbeat()
5379
5499
  except Exception: # noqa: BLE001 — teardown must not mask a failure
5380
5500
  pass
5501
+ self._finish_run_checkpoint(_ckpt)
5381
5502
  self._activity_end(kind="compute")
5382
5503
 
5383
5504
  # ── Live elapsed ticker ────────────────────────────────────────────────
@@ -5468,6 +5589,163 @@ class QuantUIApp:
5468
5589
  def _update_notes(self, change=None) -> None:
5469
5590
  _run_update_notes(self, change)
5470
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
+
5471
5749
  def _update_estimate(self, change=None) -> None:
5472
5750
  _run_update_estimate(self, calc_log_mod=_calc_log, change=change)
5473
5751
 
@@ -760,6 +760,21 @@ def build_shared_widgets(
760
760
  )
761
761
  app.perf_estimate_html = widgets.HTML()
762
762
 
763
+ # Resume-from-checkpoint offer (M-CHECKPOINT CHK.5). Both widgets stay
764
+ # hidden until the configured calculation actually has stored progress —
765
+ # a permanently-visible "Resume" control that is almost never applicable
766
+ # is worse than none, because it invites the user to wonder what it would
767
+ # have done.
768
+ app._resume_notice_html = widgets.HTML(
769
+ value="", layout=layout_fn(display="none", margin="2px 0 0 0")
770
+ )
771
+ app._resume_cb = widgets.Checkbox(
772
+ value=True,
773
+ description="Resume from checkpoint",
774
+ indent=False,
775
+ layout=layout_fn(display="none", width="auto", margin="0 0 6px 0"),
776
+ )
777
+
763
778
  app.step_progress = step_progress_cls(
764
779
  ["Choose molecule", "Set method", "Run", "Results"]
765
780
  )
@@ -1522,6 +1537,8 @@ def build_run_section(app: Any, *, layout_fn: Any) -> None:
1522
1537
  "sets may take several minutes on a laptop.</p>"
1523
1538
  ),
1524
1539
  app.perf_estimate_html,
1540
+ app._resume_notice_html,
1541
+ app._resume_cb,
1525
1542
  widgets.HBox(
1526
1543
  [
1527
1544
  app.run_btn,
@@ -2513,8 +2530,54 @@ def build_output_tab(app: Any, *, layout_fn: Any) -> None:
2513
2530
  selected_index=None,
2514
2531
  )
2515
2532
  app._history_log_accordion.set_title(0, "PySCF output log")
2533
+
2534
+ # Unfinished work (M-CHECKPOINT CHK.6). History is where "things I did
2535
+ # earlier" already lives, and an interrupted run is exactly that — it
2536
+ # just never produced a result to save. The whole box hides when nothing
2537
+ # is resumable, so it costs an empty tab nothing.
2538
+ app._resume_list_html = widgets.HTML(value="")
2539
+ app._resume_list_dd = widgets.Dropdown(
2540
+ options=[("(none)", "")],
2541
+ value="",
2542
+ description="Unfinished:",
2543
+ style={"description_width": "80px"},
2544
+ layout=layout_fn(width="460px"),
2545
+ )
2546
+ app._resume_restore_btn = widgets.Button(
2547
+ description="Load these settings",
2548
+ icon="rotate-left",
2549
+ tooltip=(
2550
+ "Put the molecule and settings from this interrupted run back into "
2551
+ "the Calculate tab so it can be resumed"
2552
+ ),
2553
+ layout=layout_fn(width="200px"),
2554
+ )
2555
+ app._resume_discard_btn = widgets.Button(
2556
+ description="Discard",
2557
+ tooltip="Delete this checkpoint. The interrupted run can no longer be resumed.",
2558
+ layout=layout_fn(width="110px"),
2559
+ )
2560
+ app._resume_list_box = widgets.VBox(
2561
+ [
2562
+ widgets.HTML(
2563
+ '<h4 style="margin:12px 0 4px">Unfinished calculations</h4>'
2564
+ '<p style="color:#555;font-size:13px;margin:0 0 6px">Runs that '
2565
+ "stopped before finishing. Load one to put its settings back on "
2566
+ "the Calculate tab, then press Run to continue it.</p>"
2567
+ ),
2568
+ app._resume_list_dd,
2569
+ app._resume_list_html,
2570
+ widgets.HBox(
2571
+ [app._resume_restore_btn, app._resume_discard_btn],
2572
+ layout=layout_fn(gap="6px"),
2573
+ ),
2574
+ ],
2575
+ layout=layout_fn(display="none"),
2576
+ )
2577
+
2516
2578
  app.history_panel.children = (
2517
2579
  *app.history_panel.children,
2580
+ app._resume_list_box,
2518
2581
  app._history_log_accordion,
2519
2582
  )
2520
2583