Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
134 changes: 112 additions & 22 deletions quantui/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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] = {}

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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: ``⏱ <elapsed>`` + ``· ~<remaining> 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'<span style="color:#64748b;font-size:13px">{base}</span>'

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)

Expand Down
15 changes: 14 additions & 1 deletion quantui/app_builders.py
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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(
Expand Down
99 changes: 73 additions & 26 deletions quantui/app_runflow.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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()


Expand Down
Loading
Loading