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.
- {quantui-0.6.1 → quantui-0.7.0}/CHANGELOG.md +62 -0
- {quantui-0.6.1/quantui.egg-info → quantui-0.7.0}/PKG-INFO +1 -1
- {quantui-0.6.1 → quantui-0.7.0}/pyproject.toml +1 -1
- {quantui-0.6.1 → quantui-0.7.0}/quantui/__init__.py +1 -1
- {quantui-0.6.1 → quantui-0.7.0}/quantui/app.py +280 -2
- {quantui-0.6.1 → quantui-0.7.0}/quantui/app_builders.py +63 -0
- {quantui-0.6.1 → quantui-0.7.0}/quantui/app_runflow.py +335 -8
- {quantui-0.6.1 → quantui-0.7.0}/quantui/benchmarks.py +20 -2
- {quantui-0.6.1 → quantui-0.7.0}/quantui/calc_log.py +132 -8
- quantui-0.7.0/quantui/checkpoint.py +650 -0
- quantui-0.7.0/quantui/estimator_eval.py +272 -0
- {quantui-0.6.1 → quantui-0.7.0}/quantui/help_content.py +70 -0
- {quantui-0.6.1 → quantui-0.7.0}/quantui/optimizer.py +130 -3
- {quantui-0.6.1 → quantui-0.7.0}/quantui/pes_scan.py +117 -1
- {quantui-0.6.1 → quantui-0.7.0}/quantui/session_calc.py +107 -1
- {quantui-0.6.1 → quantui-0.7.0/quantui.egg-info}/PKG-INFO +1 -1
- {quantui-0.6.1 → quantui-0.7.0}/quantui.egg-info/SOURCES.txt +2 -0
- {quantui-0.6.1 → quantui-0.7.0}/LICENSE +0 -0
- {quantui-0.6.1 → quantui-0.7.0}/MANIFEST.in +0 -0
- {quantui-0.6.1 → quantui-0.7.0}/README.md +0 -0
- {quantui-0.6.1 → quantui-0.7.0}/SECURITY.md +0 -0
- {quantui-0.6.1 → quantui-0.7.0}/quantui/analytics.py +0 -0
- {quantui-0.6.1 → quantui-0.7.0}/quantui/app_analysis.py +0 -0
- {quantui-0.6.1 → quantui-0.7.0}/quantui/app_exports.py +0 -0
- {quantui-0.6.1 → quantui-0.7.0}/quantui/app_formatters.py +0 -0
- {quantui-0.6.1 → quantui-0.7.0}/quantui/app_history.py +0 -0
- {quantui-0.6.1 → quantui-0.7.0}/quantui/app_visualization.py +0 -0
- {quantui-0.6.1 → quantui-0.7.0}/quantui/ase_bridge.py +0 -0
- {quantui-0.6.1 → quantui-0.7.0}/quantui/c_stderr.py +0 -0
- {quantui-0.6.1 → quantui-0.7.0}/quantui/cactus.py +0 -0
- {quantui-0.6.1 → quantui-0.7.0}/quantui/calculator.py +0 -0
- {quantui-0.6.1 → quantui-0.7.0}/quantui/cancellation.py +0 -0
- {quantui-0.6.1 → quantui-0.7.0}/quantui/cli.py +0 -0
- {quantui-0.6.1 → quantui-0.7.0}/quantui/comparison.py +0 -0
- {quantui-0.6.1 → quantui-0.7.0}/quantui/config.py +0 -0
- {quantui-0.6.1 → quantui-0.7.0}/quantui/data/js/3Dmol-min.js +0 -0
- {quantui-0.6.1 → quantui-0.7.0}/quantui/data/js/3Dmol-min.js.LICENSE.txt +0 -0
- {quantui-0.6.1 → quantui-0.7.0}/quantui/data/library/library.sqlite +0 -0
- {quantui-0.6.1 → quantui-0.7.0}/quantui/data/manifests/bulk_qm9.json +0 -0
- {quantui-0.6.1 → quantui-0.7.0}/quantui/data/manifests/curated.json +0 -0
- {quantui-0.6.1 → quantui-0.7.0}/quantui/data/manifests/presets.json +0 -0
- {quantui-0.6.1 → quantui-0.7.0}/quantui/descriptor_cards.py +0 -0
- {quantui-0.6.1 → quantui-0.7.0}/quantui/freq_calc.py +0 -0
- {quantui-0.6.1 → quantui-0.7.0}/quantui/freq_ir_workers.py +0 -0
- {quantui-0.6.1 → quantui-0.7.0}/quantui/gpu_offload.py +0 -0
- {quantui-0.6.1 → quantui-0.7.0}/quantui/ir_plot.py +0 -0
- {quantui-0.6.1 → quantui-0.7.0}/quantui/issue_tracker.py +0 -0
- {quantui-0.6.1 → quantui-0.7.0}/quantui/live_log.py +0 -0
- {quantui-0.6.1 → quantui-0.7.0}/quantui/log_utils.py +0 -0
- {quantui-0.6.1 → quantui-0.7.0}/quantui/molecule.py +0 -0
- {quantui-0.6.1 → quantui-0.7.0}/quantui/molecule_library.py +0 -0
- {quantui-0.6.1 → quantui-0.7.0}/quantui/nmr_calc.py +0 -0
- {quantui-0.6.1 → quantui-0.7.0}/quantui/orbital_visualization.py +0 -0
- {quantui-0.6.1 → quantui-0.7.0}/quantui/preopt.py +0 -0
- {quantui-0.6.1 → quantui-0.7.0}/quantui/progress.py +0 -0
- {quantui-0.6.1 → quantui-0.7.0}/quantui/pubchem.py +0 -0
- {quantui-0.6.1 → quantui-0.7.0}/quantui/reorganization_energy.py +0 -0
- {quantui-0.6.1 → quantui-0.7.0}/quantui/results_storage.py +0 -0
- {quantui-0.6.1 → quantui-0.7.0}/quantui/security.py +0 -0
- {quantui-0.6.1 → quantui-0.7.0}/quantui/structure_providers.py +0 -0
- {quantui-0.6.1 → quantui-0.7.0}/quantui/tddft_calc.py +0 -0
- {quantui-0.6.1 → quantui-0.7.0}/quantui/theme.py +0 -0
- {quantui-0.6.1 → quantui-0.7.0}/quantui/user_settings.py +0 -0
- {quantui-0.6.1 → quantui-0.7.0}/quantui/utils.py +0 -0
- {quantui-0.6.1 → quantui-0.7.0}/quantui/vib_cache.py +0 -0
- {quantui-0.6.1 → quantui-0.7.0}/quantui/visualization_py3dmol.py +0 -0
- {quantui-0.6.1 → quantui-0.7.0}/quantui/viz_assets.py +0 -0
- {quantui-0.6.1 → quantui-0.7.0}/quantui/viz_backend_router.py +0 -0
- {quantui-0.6.1 → quantui-0.7.0}/quantui.egg-info/dependency_links.txt +0 -0
- {quantui-0.6.1 → quantui-0.7.0}/quantui.egg-info/entry_points.txt +0 -0
- {quantui-0.6.1 → quantui-0.7.0}/quantui.egg-info/requires.txt +0 -0
- {quantui-0.6.1 → quantui-0.7.0}/quantui.egg-info/top_level.txt +0 -0
- {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
|
|
@@ -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.
|
|
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
|
|
834
|
-
|
|
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"♻ <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
|
|