diff --git a/quantui/app.py b/quantui/app.py index 9ea1672..882dbe9 100644 --- a/quantui/app.py +++ b/quantui/app.py @@ -738,6 +738,20 @@ def write(self, text: str) -> None: except Exception: pass + def set_status(self, message: str) -> None: + """Update the live status label WITHOUT appending to the log. + + M-PROGRESS A2: calc modules (optimizer / pes / reorg) call this via + ``log_utils.emit_status`` to surface a stage label ("Optimizing — + step k…") during silent (``verbose=0``) phases, without cluttering the + output log the way a ``[QuantUI_STATUS]`` stream line would. + """ + if self._status is not None: + try: + self._status.value = message + except Exception: + pass + def flush(self) -> None: pass @@ -888,6 +902,7 @@ class QuantUIApp: run_output: Any run_panel: Any run_status: Any + _run_elapsed_lbl: Any solvent_cb: Any solvent_dd: Any step_progress: Any @@ -1070,6 +1085,12 @@ def __init__(self) -> None: # Clear button from wiping output mid-run. self._cancel_event = threading.Event() self._calc_running: bool = False + # M-PROGRESS A1: stop signal for the live elapsed-time ticker thread. + self._elapsed_stop_event: Optional[threading.Event] = None + # M-PROGRESS B1: total run estimate the ticker turns into "time + # remaining"; set by _do_run once estimate_time() has run. + self._run_estimate_s: Optional[float] = None + self._run_estimate_conf: str = "unknown" # Relaxed molecule from a pending pre-opt preview, awaiting Keep/Revert. self._preopt_relaxed_mol: Optional[Molecule] = None # Cache kernel io_loop once on the main thread so worker threads can @@ -1145,6 +1166,15 @@ def _start_deferred_startup_tasks(self) -> None: """ def _detect_gpu() -> None: + # Warm the run-header's system-info cache (lru_cache; may shell out + # to nvidia-smi) off the main thread so the synchronous header write + # in on_run_clicked stays instant on the first calc (UXP.6). + try: + from quantui.log_utils import get_system_info + + get_system_info() + except Exception: # noqa: BLE001 — warm-up is best-effort + pass try: from quantui.gpu_offload import is_gpu_available @@ -4032,6 +4062,8 @@ def _do_run(self) -> None: ) _run_wall_t = time.perf_counter() _run_cpu_t = time.process_time() + # M-PROGRESS A1: start the live elapsed-time ticker. + self._start_elapsed_ticker(_run_wall_t) _scf_converged_t: Optional[float] = None _tail_marks: dict[str, float] = {} @@ -4088,6 +4120,10 @@ def _do_run(self) -> None: if _est is not None: _predicted_run_s = float(_est["seconds"]) _predicted_run_confidence = str(_est.get("confidence", "unknown")) + # B1: hand the estimate to the live ticker so it can show a + # "time remaining" readout alongside elapsed. + self._run_estimate_s = _predicted_run_s + self._run_estimate_conf = _predicted_run_confidence except Exception as _est_exc: # Estimator failure here is non-fatal — we just won't have a # predicted_s to compare against. Log to event_log so the @@ -4146,28 +4182,13 @@ def _run_required_final_single_point(target_mol, reason: str): cancel_check=self._cancel_event.is_set, ) - # Write structured log header immediately so it appears at the top of output - try: - from quantui.log_utils import format_log_header as _fmt_log_hdr - - _hdr_calc_type = { - "Geometry Opt": "geometry_opt", - "Frequency": "frequency", - "UV-Vis (TD-DFT)": "tddft", - "NMR Shielding": "nmr", - "PES Scan": "pes_scan", - "Reorganization Energy": "reorganization_energy", - }.get(self.calc_type_dd.value, "single_point") - log.write( - _fmt_log_hdr( - formula=mol.get_formula(), - method=self.method_dd.value, - basis=self.basis_dd.value, - calc_type=_hdr_calc_type, - ) - ) - except Exception: - pass + # The run header (structured banner) is written synchronously + atomically + # on the main thread by ``on_run_clicked`` → ``_write_run_header`` BEFORE + # this background thread starts. Writing it here (bg thread) instead was + # the pre-step-1 "blank window" bug (2026-07-18): for a large molecule the + # long gap before the first optimizer/SCF line exposed a lost-early-output + # race in the bg-thread ``append_stdout`` path. All later PySCF / optimizer + # output appends onto that header via ``log`` as usual. try: # Classical pre-optimization is now an explicit Preview → Keep tool @@ -5016,8 +5037,77 @@ def _run_required_final_single_point(target_mol, reason: str): self.cancel_btn.disabled = True self.cancel_btn.description = "Cancel" self.log_clear_btn.disabled = False + self._stop_elapsed_ticker() self._activity_end(kind="compute") + # ── Live elapsed ticker (M-PROGRESS A1) ─────────────────────────────── + + def _start_elapsed_ticker(self, start_t: float) -> None: + """Spin up a daemon thread that updates the elapsed chip every ~1 s. + + Writes only to ``_run_elapsed_lbl`` (never ``run_status``), so it never + fights the stage labels set by ``_LogCapture`` / the calc modules. + ``Label``/``HTML`` ``.value`` writes are thread-safe (reflection 02 + Rule 1), so no io_loop marshaling is needed. + """ + self._stop_elapsed_ticker() # ensure no prior ticker is still running + # B1: reset the runtime estimate; _do_run fills it in once computed. + self._run_estimate_s = None + self._run_estimate_conf = "unknown" + stop_event = threading.Event() + self._elapsed_stop_event = stop_event + + def _tick() -> None: + while not stop_event.wait(1.0): + try: + self._run_elapsed_lbl.value = self._format_elapsed_chip( + time.perf_counter() - start_t + ) + except Exception: + break + + threading.Thread( + target=_tick, daemon=True, name="quantui-elapsed-ticker" + ).start() + + def _format_elapsed_chip(self, elapsed: float) -> str: + """Compose the live chip: ``⏱ `` + ``· ~ left``. + + B1 (M-PROGRESS): folds the pre-run total estimate (``_run_estimate_s``, + set by ``_do_run``) into a remaining-time readout. Degrades to + elapsed-only when there's no estimate (cold history) and switches to + "longer than estimated" once elapsed passes the estimate — never shows a + negative or false-precise number. Low-confidence estimates are marked + "(rough)" so the readout stays honest (the estimator is Phase C's job). + """ + from quantui.log_utils import format_elapsed + + base = f"⏱ {format_elapsed(elapsed)}" + est = getattr(self, "_run_estimate_s", None) + if est and est > 0: + remaining = est - elapsed + if remaining > 0: + rough = ( + " (rough)" + if getattr(self, "_run_estimate_conf", "") == "low" + else "" + ) + base = f"{base} · ~{format_elapsed(remaining)} left{rough}" + else: + base = f"{base} · longer than estimated" + return f'{base}' + + def _stop_elapsed_ticker(self) -> None: + """Stop the elapsed ticker and clear the chip.""" + ev = getattr(self, "_elapsed_stop_event", None) + if ev is not None: + ev.set() + self._elapsed_stop_event = None + try: + self._run_elapsed_lbl.value = "" + except Exception: + pass + def _update_notes(self, change=None) -> None: _run_update_notes(self, change) diff --git a/quantui/app_builders.py b/quantui/app_builders.py index 7f414ec..3fdc1f4 100644 --- a/quantui/app_builders.py +++ b/quantui/app_builders.py @@ -921,6 +921,10 @@ def build_shared_widgets( layout=layout_fn(width="200px", height="36px"), ) app.run_status = widgets.Label() + # M-PROGRESS A1: live elapsed-time chip next to the status text — a bg-thread + # ticker updates it every ~1 s while a calc runs, so even a fully silent + # native phase (first gradient / Hessian) visibly advances. + app._run_elapsed_lbl = widgets.HTML(value="") # Gracefully stops a running calculation at the next SCF cycle / opt step. # Disabled unless a calc is in flight (toggled by _do_run). @@ -1351,7 +1355,16 @@ def build_run_section(app: Any, *, layout_fn: Any) -> None: ), app.perf_estimate_html, widgets.HBox( - [app.run_btn, app.cancel_btn, app.run_status], + [ + app.run_btn, + app.cancel_btn, + # Status + elapsed/remaining chip stacked vertically so the + # timer never crowds/truncates the (longer) status line. + widgets.VBox( + [app.run_status, app._run_elapsed_lbl], + layout=layout_fn(gap="0px"), + ), + ], layout=layout_fn(align_items="center", gap="8px"), ), widgets.HBox( diff --git a/quantui/app_runflow.py b/quantui/app_runflow.py index a0e5886..2829f31 100644 --- a/quantui/app_runflow.py +++ b/quantui/app_runflow.py @@ -22,48 +22,96 @@ def _calc_type_badge(calc_type: str) -> str: }.get(calc_type, calc_type or "Unknown") -_RUN_HEADER_CALC_LABELS = { - "Single Point": "Single Point Energy", - "Geometry Opt": "Geometry Optimization", - "Frequency": "Frequency Analysis", - "UV-Vis (TD-DFT)": "TD-DFT (UV-Vis)", - "NMR Shielding": "NMR Shielding", - "PES Scan": "PES Scan", +# Calc-type dropdown label → canonical schema key (used for the header banner). +_CALC_TYPE_CANON = { + "Geometry Opt": "geometry_opt", + "Frequency": "frequency", + "UV-Vis (TD-DFT)": "tddft", + "NMR Shielding": "nmr", + "PES Scan": "pes_scan", + "Reorganization Energy": "reorganization_energy", } -def _write_provisional_run_header(app: Any) -> None: - """Print an immediate one-line header the instant Run is clicked (UXP.6). +def _write_run_header(app: Any) -> None: + """Write the full run header to the live log — synchronously, atomically. - The full structured banner (``format_log_header``) is written from the - background ``_do_run`` thread and can lag behind the first observable - output because it calls ``get_system_info()`` (which may shell out to - ``nvidia-smi``) and only runs once the thread has spun up. This provisional - line runs synchronously on the main thread so the user gets instant - feedback that the click registered; the full banner follows and augments it. + UXP.6 + bug fix (2026-07-18): the header used to be written two ways, both + unreliable in Voilà — a provisional line via ``clear_output()`` + + ``append_stdout()`` (the non-atomic combo ``_set_html_output`` exists to + avoid) and the structured banner via ``append_stdout`` from the *background* + ``_do_run`` thread (a bg-thread ``.outputs`` mutation, which this app + marshals through the io_loop everywhere else). For a large molecule the long + gap before the first optimizer/SCF step exposed the race: the pre-step-1 + stream (both headers) was dropped and the log jumped straight to ``BFGS: 0``. + + Fix: build the whole banner and assign it in a single atomic + ``run_output.outputs = (…)`` on the main thread (the click handler). No + ``clear_output``, no bg-thread write, no intermediate empty state. + ``get_system_info()`` is ``lru_cache``d (warmed at startup) so this stays + instant. Later PySCF / optimizer output appends onto this header as before. """ mol = getattr(app, "_molecule", None) if mol is None: + # Nothing will run; just clear the previous log atomically. + _set_run_output(app, ()) return try: - formula = mol.get_formula() + from quantui.log_utils import format_log_header + + calc_type = _CALC_TYPE_CANON.get(app.calc_type_dd.value, "single_point") + try: + _n_atoms = len(mol.atoms) + except Exception: + _n_atoms = None + _solvent = app.solvent_dd.value if app.solvent_cb.value else None + try: + _out_dir = str(app._get_results_dir()) + except Exception: + _out_dir = None + banner = format_log_header( + formula=mol.get_formula(), + method=app.method_dd.value, + basis=app.basis_dd.value, + calc_type=calc_type, + n_atoms=_n_atoms, + multiplicity=int(app.mult_si.value), + solvent=_solvent, + output_dir=_out_dir, + ) except Exception: - formula = "?" - ct_label = _RUN_HEADER_CALC_LABELS.get( - app.calc_type_dd.value, app.calc_type_dd.value + # Fallback: a minimal one-liner still beats a blank window. + try: + banner = ( + f"▶ Starting {app.calc_type_dd.value} — {mol.get_formula()} · " + f"{app.method_dd.value}/{app.basis_dd.value} …\n" + ) + except Exception: + banner = "▶ Starting calculation …\n" + _set_run_output( + app, + ({"output_type": "stream", "name": "stdout", "text": banner},), ) + + +def _set_run_output(app: Any, outputs: tuple) -> None: + """Atomically set ``run_output.outputs`` (fallback to clear_output).""" try: - app.run_output.append_stdout( - f"▶ Starting {ct_label} — {formula} · " - f"{app.method_dd.value}/{app.basis_dd.value} …\n" - ) + app.run_output.outputs = outputs except Exception: - pass + try: + app.run_output.clear_output() + for o in outputs: + app.run_output.append_stdout(o.get("text", "")) + except Exception: + pass def on_run_clicked(app: Any, btn: Any) -> None: """Reset result panes and start the background run thread.""" - app.run_output.clear_output() + # Write the header FIRST (atomic, main thread) — this also clears the + # previous run's log via the single ``outputs`` assignment. + _write_run_header(app) app.result_output.clear_output() app.result_viz_output.clear_output() app._analysis_mol_output.clear_output() @@ -79,7 +127,6 @@ def on_run_clicked(app: Any, btn: Any) -> None: app._completion_banner.layout.display = "none" app._to_analysis_btn.layout.display = "none" app._analysis_empty_html.layout.display = "none" - _write_provisional_run_header(app) threading.Thread(target=app._do_run, daemon=True).start() diff --git a/quantui/cancellation.py b/quantui/cancellation.py index 0ddc3e6..a4b3371 100644 --- a/quantui/cancellation.py +++ b/quantui/cancellation.py @@ -45,20 +45,41 @@ def cancel_check_from_stream(stream: Any) -> Optional[CancelCheck]: return cc if callable(cc) else None -def attach_scf_cancel_callback(mf: Any, cancel_check: Optional[CancelCheck]) -> None: - """Attach a cancel check to a PySCF SCF object's per-cycle ``callback``. - - PySCF invokes ``mf.callback(envs)`` once per SCF macro-iteration. Raising - :class:`CalcCancelled` there stops the run between cycles regardless of - whether output is being streamed. No-op when *cancel_check* is ``None`` or - the object doesn't accept a callback (best-effort — cooperative - output-line cancellation still applies). +def attach_scf_cancel_callback( + mf: Any, + cancel_check: Optional[CancelCheck], + *, + progress_cb: Optional[Callable[[Any], None]] = None, +) -> None: + """Attach cancel + progress hooks to a PySCF SCF object's ``callback``. + + PySCF invokes ``mf.callback(envs)`` once per SCF macro-iteration (``envs`` + is the kernel's locals dict — cycle index, ``e_tot``, etc.). We use that + single hook for two things: + + - **Cancel** (UXP.5): raise :class:`CalcCancelled` between cycles so a + Cancel click stops the run regardless of whether output is streamed. + - **Progress** (M-PROGRESS A3): call *progress_cb(envs)* each cycle so the + caller can surface a live "SCF cycle N" status even when the SCF runs at + ``verbose=0`` (the optimizer/PES per-step SCF, which streams nothing). + + No-op when both hooks are ``None`` or the object doesn't accept a callback + (best-effort — cooperative output-line cancellation still applies). """ - if cancel_check is None or mf is None: + if mf is None or (cancel_check is None and progress_cb is None): return - def _cb(_envs: Any, _cc: CancelCheck = cancel_check) -> None: - if _cc(): + def _cb( + envs: Any, + _cc: Optional[CancelCheck] = cancel_check, + _pc: Optional[Callable[[Any], None]] = progress_cb, + ) -> None: + if _pc is not None: + try: + _pc(envs) + except Exception: # noqa: BLE001 — progress is best-effort + pass + if _cc is not None and _cc(): raise CalcCancelled() try: diff --git a/quantui/log_utils.py b/quantui/log_utils.py index 200e384..a74e35b 100644 --- a/quantui/log_utils.py +++ b/quantui/log_utils.py @@ -10,13 +10,36 @@ import os import platform +import socket import subprocess -from datetime import datetime +import uuid +from datetime import datetime, timezone from functools import lru_cache from typing import Any, Dict, Optional _WIDTH = 80 # total width of === border lines _SEP = "=" * _WIDTH +_SUB = "-" * _WIDTH # lighter divider between header sections + +# Static list of formats the app can export a result to (EXPORT milestone). +_EXPORT_FORMATS = "XYZ, MOL/SDF, PDB, Molden, .traj, cube, HTML spectra, .zip" + +# SCF/correlation methods that are NOT DFT functionals — shown verbatim in the +# "Method" field with no separate functional. Everything else is treated as a +# DFT functional and reported as RKS/UKS + the functional name. +_NON_DFT_METHODS = frozenset({"RHF", "UHF", "MP2", "CCSD", "CCSD(T)"}) + +# ASCII-art wordmark shown at the top of every run banner. Rendered in the +# monospace stdout stream of the live log, so the alignment holds. Raw string: +# the art contains backslashes. Trailing whitespace is stripped per line so it +# doesn't trip the linter (the art is the left edge; right padding is cosmetic). +_ASCII_LOGO_LINES = [ + r" ___ _ _ _ ___", + r" / _ \ _ _ __ _ _ __ | |_| | | |_ _|", + r" | | | | | | |/ _` | '_ \| __| | | || |", + r" | |_| | |_| | (_| | | | | |_| |_| || |", + r" \__\_\\__,_|\__,_|_| |_|\__|\___/|___|", +] # ============================================================================ @@ -101,8 +124,22 @@ def _detect_gpu() -> Optional[Dict[str, str]]: return None +def _pyscf_version() -> str: + """Return the installed PySCF version, or a clear 'not installed' marker. + + PySCF is Linux/macOS/WSL only, so this is expected to be absent on the + Windows UI-dev path (see constraint #1). + """ + try: + import pyscf + + return str(getattr(pyscf, "__version__", "unknown")) + except Exception: + return "not installed" + + def collect_system_info() -> Dict[str, Any]: - """Gather CPU, RAM, GPU, and thread count. Safe on all platforms.""" + """Gather CPU, RAM, GPU, thread count, host, and versions. Safe anywhere.""" cpu_model = ( _read_proc_cpu() or platform.processor() or platform.machine() or "Unknown CPU" ) @@ -115,12 +152,20 @@ def collect_system_info() -> Dict[str, Any]: omp = os.environ.get("OMP_NUM_THREADS", None) + try: + hostname = socket.gethostname() or "unknown" + except Exception: + hostname = "unknown" + return { "cpu_model": cpu_model, "cpu_count": cpu_count, "ram_str": ram_str, "gpu": gpu, "omp_threads": omp, + "hostname": hostname, + "python": platform.python_version(), + "pyscf": _pyscf_version(), } @@ -130,6 +175,41 @@ def get_system_info() -> Dict[str, Any]: return collect_system_info() +# ============================================================================ +# Live run-status helpers (M-PROGRESS Phase A) +# ============================================================================ + + +def emit_status(stream: Any, message: str) -> None: + """Set the live run-status label via a progress stream, if it supports it. + + The run's ``_LogCapture`` exposes a ``set_status`` method; calc modules + call this to update the status line during otherwise-silent phases (e.g. an + optimizer step's SCF running at ``verbose=0``) WITHOUT appending a line to + the log. No-op for plain streams (``sys.stdout``) — duck-typed, so calc + modules stay decoupled from the widget layer. + """ + setter = getattr(stream, "set_status", None) + if setter is None: + return + try: + setter(message) + except Exception: # noqa: BLE001 — status update is best-effort + pass + + +def format_elapsed(seconds: float) -> str: + """Compact elapsed-time label for the live ticker: ``0:42`` / ``1:03:22``.""" + if seconds < 0: + seconds = 0.0 + s = int(seconds) + h, rem = divmod(s, 3600) + m, sec = divmod(rem, 60) + if h: + return f"{h}:{m:02d}:{sec:02d}" + return f"{m}:{sec:02d}" + + # ============================================================================ # Duration formatter # ============================================================================ @@ -160,6 +240,25 @@ def _fmt_duration(seconds: float) -> str: } +def _split_method_functional( + method: str, multiplicity: int +) -> tuple[str, Optional[str]]: + """Split the user's method selection into (wf-method, functional). + + HF / post-HF selections are shown verbatim with no functional. A DFT + functional is reported as its Kohn-Sham class (RKS closed-shell / UKS + open-shell) plus the functional name, matching PySCF's auto-dispatch. + """ + if method.upper() in _NON_DFT_METHODS: + return method, None + return ("RKS" if multiplicity <= 1 else "UKS"), method + + +def _row(label: str, value: Any) -> str: + """One aligned `` Label : value`` header row.""" + return f" {label:<16}: {value}" + + def format_log_header( *, formula: str, @@ -167,43 +266,85 @@ def format_log_header( basis: str, calc_type: str, timestamp: Optional[str] = None, + n_atoms: Optional[int] = None, + multiplicity: int = 1, + solvent: Optional[str] = None, + output_dir: Optional[str] = None, + calc_id: Optional[str] = None, + starting_energy: Optional[float] = None, ) -> str: - """Return a formatted header string to prepend to calculation log output.""" + """Return a formatted header string to prepend to calculation log output. + + Core args (formula/method/basis/calc_type) are required; the rest add + run-provenance rows (host, versions, device, solvation, output dir, …) and + default to sensible placeholders so callers can pass only what they know at + header-write time. + """ sysinfo = get_system_info() if timestamp is None: - timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC") + if calc_id is None: + calc_id = uuid.uuid4().hex[:8] ct_label = _CALC_TYPE_LABELS.get(calc_type, calc_type.replace("_", " ").title()) + wf_method, functional = _split_method_functional(method, multiplicity) gpu = sysinfo["gpu"] if gpu: mem = f" | {gpu['mem_mb']} MB" if gpu.get("mem_mb") else "" drv = f" | Driver {gpu['driver']}" if gpu.get("driver") else "" - gpu_line = f" GPU: {gpu['name']}{mem}{drv}" + device = f"GPU — {gpu['name']}{mem}{drv}" else: - gpu_line = " GPU: (none detected)" + device = "CPU" omp = sysinfo["omp_threads"] omp_str = ( - f"OMP_NUM_THREADS={omp}" - if omp - else f"OMP_NUM_THREADS not set (cores: {sysinfo['cpu_count']})" + f"OMP_NUM_THREADS={omp}" if omp else f"not set (cores: {sysinfo['cpu_count']})" + ) + + structure = formula if n_atoms is None else f"{formula} ({n_atoms} atoms)" + start_e = ( + f"{starting_energy:.8f} Ha" + if starting_energy is not None + else "— (available after the first SCF)" ) lines = [ "", _SEP, - " QuantUI — Quantum Chemistry Interface", - _SEP, - f" Machine: {sysinfo['cpu_model']} | {sysinfo['cpu_count']} cores | RAM: {sysinfo['ram_str']}", - gpu_line, - f" Threads: {omp_str}", "", - f" Molecule: {formula}", - f" Method/Basis: {method} / {basis}", - f" Calc type: {ct_label}", - f" Started: {timestamp}", + *_ASCII_LOGO_LINES, + "", + " Quantum Chemistry Interface", + _SEP, + _row("Calculation ID", calc_id), + _row("Timestamp", timestamp), + _row("Host", sysinfo.get("hostname", "unknown")), + _row( + "Python", + f"{sysinfo.get('python', '?')} | " + f"PySCF: {sysinfo.get('pyscf', '?')}", + ), + _row("Device", device), + _row( + "Machine", + f"{sysinfo['cpu_model']} | {sysinfo['cpu_count']} cores " + f"| RAM: {sysinfo['ram_str']}", + ), + _row("Threads", omp_str), + _SUB, + _row("Method", wf_method), + _row("Functional", functional or "—"), + _row("Basis Set", basis), + _row("Solvation", f"PCM ({solvent})" if solvent else "None"), + _row("Job Type", ct_label), + _SUB, + _row("Input Structure", structure), + _row("Starting Energy", start_e), + _SUB, + _row("Output directory", output_dir or "—"), + _row("Export formats", _EXPORT_FORMATS), _SEP, "", ] diff --git a/quantui/nmr_calc.py b/quantui/nmr_calc.py index 68ae7c1..fc35ac2 100644 --- a/quantui/nmr_calc.py +++ b/quantui/nmr_calc.py @@ -362,9 +362,11 @@ def _run_nmr_calc_body( # UXP.5: cooperative cancel between SCF cycles. from .cancellation import attach_scf_cancel_callback, cancel_check_from_stream + from .log_utils import emit_status attach_scf_cancel_callback(mf, cancel_check_from_stream(stream)) + emit_status(stream, "Running SCF…") # M-PROGRESS A4 try: mf.kernel() except Exception as exc: @@ -388,6 +390,7 @@ def _run_nmr_calc_body( _ensure_nmr_compat_patches_applied() + emit_status(stream, "Computing NMR shielding tensors (GIAO)…") # M-PROGRESS A4 try: if method_upper == "RHF": nmr_obj = _pyscf_nmr.RHF(mf) diff --git a/quantui/optimizer.py b/quantui/optimizer.py index 897c1fe..47582cc 100644 --- a/quantui/optimizer.py +++ b/quantui/optimizer.py @@ -94,6 +94,8 @@ def __init__( charge: int = 0, spin: int = 0, cancel_check=None, + progress_stream=None, + status_label: str = "Optimizing geometry", **kwargs, ) -> None: super().__init__(**kwargs) @@ -105,6 +107,12 @@ def __init__( # the per-step SCF callback (the SCF runs silent here, so the # stream-based cancel can't see it). self.cancel_check = cancel_check + # M-PROGRESS A2: progress stream + label for per-step status + # heartbeats (the SCF runs at verbose=0, so nothing else surfaces + # progress during a step). ``_eval_count`` counts force evaluations. + self.progress_stream = progress_stream + self.status_label = status_label + self._eval_count = 0 def calculate( self, @@ -123,6 +131,16 @@ def calculate( raise_if_cancelled(self.cancel_check) + # M-PROGRESS A2: heartbeat so the status line advances during the + # (silent) per-step SCF + gradient. + self._eval_count += 1 + from .log_utils import emit_status + + emit_status( + self.progress_stream, + f"{self.status_label} — SCF + gradient (step {self._eval_count})…", + ) + import numpy as np from pyscf import dft, gto, scf @@ -166,7 +184,21 @@ def calculate( mf.verbose = 0 mf.stdout = _sink - attach_scf_cancel_callback(mf, self.cancel_check) + + # M-PROGRESS A3: per-SCF-cycle heartbeat during the (silent) step, + # so the status advances mid-SCF, not just per optimizer step. + _k = self._eval_count + + def _scf_progress(envs, _k=_k) -> None: + cyc = envs.get("cycle") if hasattr(envs, "get") else None + if cyc is None: + return + emit_status( + self.progress_stream, + f"{self.status_label} — step {_k}, SCF cycle {cyc + 1}…", + ) + + attach_scf_cancel_callback(mf, self.cancel_check, progress_cb=_scf_progress) mf.kernel() # Save final SCF state for orbital visualization @@ -308,6 +340,7 @@ def optimize_geometry( fmax: float = DEFAULT_FMAX, steps: int = DEFAULT_OPT_STEPS, progress_stream: Optional[IO[str]] = None, + status_label: str = "Optimizing geometry", ) -> OptimizationResult: """ Optimize a molecular geometry at the QM level using ASE-BFGS + PySCF. @@ -410,6 +443,8 @@ def optimize_geometry( charge=molecule.charge, spin=molecule.multiplicity - 1, cancel_check=_cancel_check, + progress_stream=_stream, + status_label=status_label, ) # M-STDERR / STDERR.1: PySCF gradients (called by ASE-BFGS at every diff --git a/quantui/pes_scan.py b/quantui/pes_scan.py index 92feeba..7e54fce 100644 --- a/quantui/pes_scan.py +++ b/quantui/pes_scan.py @@ -318,6 +318,13 @@ def run_pes_scan( for step_num, val in enumerate(scan_values, start=1): raise_if_cancelled(_cancel_check) + # M-PROGRESS A2: live per-point status (the per-point SCF is silent). + from .log_utils import emit_status + + emit_status( + _stream, + f"Scan point {step_num}/{steps} — relaxing (SCF + gradient)…", + ) _stream.write( f"\nScan point {step_num}/{steps}: " f"{scan_type} = {val:.4f} {('Å' if scan_type == 'bond' else '°')}\n" diff --git a/quantui/reorganization_energy.py b/quantui/reorganization_energy.py index dbf1287..06d8e30 100644 --- a/quantui/reorganization_energy.py +++ b/quantui/reorganization_energy.py @@ -319,6 +319,7 @@ def _single_point(mol: Molecule, mth: str, tag: str) -> float: fmax=fmax, steps=steps, progress_stream=stream, # type: ignore[arg-type] + status_label="Reorg: optimizing neutral geometry", ) neutral_mol = neutral_opt.molecule n_total_steps = neutral_opt.n_steps @@ -361,6 +362,7 @@ def _single_point(mol: Molecule, mth: str, tag: str) -> float: fmax=fmax, steps=steps, progress_stream=stream, # type: ignore[arg-type] + status_label=f"Reorg: optimizing {kind} ion geometry", ) ion_mol = ion_opt.molecule n_total_steps += ion_opt.n_steps diff --git a/quantui/session_calc.py b/quantui/session_calc.py index 3e91e3e..b7dc438 100644 --- a/quantui/session_calc.py +++ b/quantui/session_calc.py @@ -398,11 +398,13 @@ def _run_session_calc_body( # the SCF callback so a Cancel click stops between SCF cycles even when the # calc is running with sparse/no streamed output. from .cancellation import attach_scf_cancel_callback, cancel_check_from_stream + from .log_utils import emit_status _cancel_check = cancel_check_from_stream(stream) attach_scf_cancel_callback(mf, _cancel_check) # --- Run SCF --- + emit_status(stream, "Running SCF…") # M-PROGRESS A4 try: energy_hartree = float(mf.kernel()) except Exception as exc: @@ -417,6 +419,7 @@ def _run_session_calc_body( try: from pyscf import mp as _mp + emit_status(stream, "Running MP2 correlation…") # M-PROGRESS A4 _mp2 = _mp.MP2(mf) _e_corr, _ = _mp2.kernel() mp2_correlation_hartree = float(_e_corr) @@ -437,6 +440,7 @@ def _run_session_calc_body( try: from pyscf import cc as _cc + emit_status(stream, "Running CCSD correlation…") # M-PROGRESS A4 _ccsd_obj = _cc.CCSD(mf) _e_corr_ccsd, _t1, _t2 = _ccsd_obj.kernel() ccsd_correlation_hartree = float(_e_corr_ccsd) @@ -447,6 +451,7 @@ def _run_session_calc_body( ) from exc if method_upper == "CCSD(T)": try: + emit_status(stream, "Computing CCSD(T) triples…") # M-PROGRESS A4 _e_t = _ccsd_obj.ccsd_t() ccsd_t_correction_hartree = float(_e_t) energy_hartree += float(_e_t) diff --git a/quantui/tddft_calc.py b/quantui/tddft_calc.py index f0f1eae..633fa5b 100644 --- a/quantui/tddft_calc.py +++ b/quantui/tddft_calc.py @@ -229,9 +229,11 @@ def _run_tddft_calc_body( # UXP.5: cooperative cancel between SCF cycles. from .cancellation import attach_scf_cancel_callback, cancel_check_from_stream + from .log_utils import emit_status attach_scf_cancel_callback(mf, cancel_check_from_stream(stream)) + emit_status(stream, "Running SCF (ground state)…") # M-PROGRESS A4 try: energy_hartree = float(mf.kernel()) except Exception as exc: @@ -268,6 +270,11 @@ def _run_tddft_calc_body( oscillator_strengths: List[float] = [] try: + emit_status( # M-PROGRESS A4 + stream, + f"Solving {'TDHF (CIS)' if using_hf else 'TD-DFT'} " + f"excited states ({nstates})…", + ) td = mf.TDHF() if using_hf else mf.TDDFT() td.nstates = nstates td.verbose = 3 diff --git a/tests/test_app.py b/tests/test_app.py index a88c50e..a6fc0db 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -706,6 +706,131 @@ def test_script_filename_sanitizes_basis_with_asterisk(self, tmp_path, monkeypat assert "Error" not in app.export_status.value +class TestRunHeader: + """The run header is written atomically on the main thread (2026-07-18 fix). + + Regression: the header used clear_output()+append and a background-thread + append, so for large molecules the pre-step-1 stream was dropped and the + log jumped straight to the first optimizer step. The fix writes the whole + banner in one atomic ``run_output.outputs = (...)`` on click. + """ + + def test_header_written_atomically_with_molecule(self): + from quantui.app_runflow import _write_run_header + + app = QuantUIApp() + app._set_molecule(_water()) + app.method_dd.value = "B3LYP" + app.basis_dd.value = "6-31G*" + _write_run_header(app) + outs = app.run_output.outputs + # A single atomic stream output — not a clear + multiple appends. + assert len(outs) == 1 + assert outs[0]["output_type"] == "stream" + text = outs[0]["text"] + assert "B3LYP" in text and "6-31G*" in text + + def test_header_clears_stale_log_when_no_molecule(self): + from quantui.app_runflow import _write_run_header + + app = QuantUIApp() + app.run_output.outputs = ( + {"output_type": "stream", "name": "stdout", "text": "old run\n"}, + ) + app._molecule = None + _write_run_header(app) + assert app.run_output.outputs == () + + +class TestProgressTicker: + """M-PROGRESS A1: the live elapsed-time ticker.""" + + def test_format_elapsed(self): + from quantui.log_utils import format_elapsed + + assert format_elapsed(0) == "0:00" + assert format_elapsed(65) == "1:05" + assert format_elapsed(3725) == "1:02:05" + assert format_elapsed(-3) == "0:00" + + def test_start_sets_stop_event_and_stop_clears(self): + app = QuantUIApp() + import time as _t + + app._start_elapsed_ticker(_t.perf_counter()) + assert app._elapsed_stop_event is not None + app._stop_elapsed_ticker() + assert app._elapsed_stop_event is None + assert app._run_elapsed_lbl.value == "" + + def test_ticker_updates_the_chip(self): + app = QuantUIApp() + import time as _t + + app._start_elapsed_ticker(_t.perf_counter()) + try: + _t.sleep(1.2) + assert "⏱" in app._run_elapsed_lbl.value + finally: + app._stop_elapsed_ticker() + + +class TestRemainingTimeChip: + """M-PROGRESS B1: fold the total estimate into a 'time remaining' readout.""" + + def test_elapsed_only_when_no_estimate(self): + app = QuantUIApp() + app._run_estimate_s = None + chip = app._format_elapsed_chip(30) + assert "⏱" in chip + assert "left" not in chip and "estimated" not in chip + + def test_shows_remaining_when_estimate_present(self): + app = QuantUIApp() + app._run_estimate_s = 120.0 + app._run_estimate_conf = "high" + chip = app._format_elapsed_chip(30) + assert "~1:30 left" in chip + assert "(rough)" not in chip # high confidence → no rough marker + + def test_low_confidence_marked_rough(self): + app = QuantUIApp() + app._run_estimate_s = 120.0 + app._run_estimate_conf = "low" + assert "(rough)" in app._format_elapsed_chip(30) + + def test_overdue_estimate_switches_message(self): + app = QuantUIApp() + app._run_estimate_s = 60.0 + chip = app._format_elapsed_chip(200) + assert "longer than estimated" in chip + assert "left" not in chip + + +class TestStatusHeartbeat: + """M-PROGRESS A2: emit_status drives run_status without touching the log.""" + + def test_emit_status_sets_label_via_logcapture(self): + from quantui.app import _LogCapture + from quantui.log_utils import emit_status + + out = widgets.Output() + status = widgets.Label() + cap = _LogCapture(out, status) + emit_status(cap, "Optimizing geometry — SCF + gradient (step 3)…") + assert "step 3" in status.value + # set_status must NOT append anything to the output log. + assert out.outputs == () + + def test_emit_status_noop_on_plain_stream(self): + import io + + from quantui.log_utils import emit_status + + # A plain stream has no set_status — must be a safe no-op. + emit_status(io.StringIO(), "ignored") + + class TestCalcTypeHelp: """A '?' help button next to Calc. Type opens the calc_type help topic.""" diff --git a/tests/test_cancel_and_clear_guard.py b/tests/test_cancel_and_clear_guard.py index 4f503a3..f75dbb2 100644 --- a/tests/test_cancel_and_clear_guard.py +++ b/tests/test_cancel_and_clear_guard.py @@ -82,6 +82,35 @@ class _FakeMF: attach_scf_cancel_callback(mf, None) assert mf.callback is None + def test_progress_cb_called_each_cycle(self): + # M-PROGRESS A3: the same callback drives a per-cycle progress hook. + class _FakeMF: + callback = None + + mf = _FakeMF() + seen = [] + attach_scf_cancel_callback( + mf, None, progress_cb=lambda envs: seen.append(envs.get("cycle")) + ) + assert callable(mf.callback) + mf.callback({"cycle": 0}) + mf.callback({"cycle": 1}) + assert seen == [0, 1] + + def test_progress_and_cancel_compose(self): + # progress_cb runs, THEN cancel raises — both on the one callback. + class _FakeMF: + callback = None + + mf = _FakeMF() + seen = [] + attach_scf_cancel_callback( + mf, lambda: True, progress_cb=lambda envs: seen.append(1) + ) + with pytest.raises(CalcCancelled): + mf.callback({"cycle": 0}) + assert seen == [1] # progress fired before the cancel raise + # ── _LogCapture cancellation mechanism ──────────────────────────────────────