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
47 changes: 47 additions & 0 deletions launchers/_banner.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
#!/usr/bin/env bash
# Shared QuantUI startup banner for the shell launchers.
#
# Sourced, not executed:
# source "$SCRIPT_DIR/_banner.sh"
# quantui_banner "NATIVE MODE — local conda env, no container" \
# "quantui/*.py edits are live; no rebuild needed"
#
# The wordmark is the same figlet art as the in-app run header
# (quantui/log_utils.py:_ASCII_LOGO_LINES). It is duplicated here rather than
# printed by Python on purpose: the banner runs *before* the conda env is
# activated, so there is no interpreter to ask, and a cosmetic banner must never
# be the thing that fails a launch. If the art ever changes, update both.
#
# Windows .bat launchers are deliberately NOT covered — batch escaping of the
# backslashes and pipes in the art is error-prone and cannot be verified from
# this dev environment. They keep their plain-text headers.

quantui_banner() {
local mode_line="${1:-}"
local note_line="${2:-}"
local c_logo="" c_dim="" c_bold="" c_off=""

# Colour only when stdout is a TTY, so piping or redirecting a launcher's
# output stays free of escape codes.
if [ -t 1 ]; then
c_logo=$'\033[36m'
c_dim=$'\033[2m'
c_bold=$'\033[1m'
c_off=$'\033[0m'
fi

printf '%s\n' "${c_logo}"
cat <<'LOGO'
___ _ _ _ ___
/ _ \ _ _ __ _ _ __ | |_| | | |_ _|
| | | | | | |/ _` | '_ \| __| | | || |
| |_| | |_| | (_| | | | | |_| |_| || |
\__\_\\__,_|\__,_|_| |_|\__|\___/|___|
LOGO
printf '%s' "${c_off}"
printf ' %sQuantum Chemistry Interface%s\n' "${c_bold}" "${c_off}"
[ -n "${mode_line}" ] && printf ' %s%s%s\n' "${c_dim}" "${mode_line}" "${c_off}"
[ -n "${note_line}" ] && printf ' %s%s%s\n' "${c_dim}" "${note_line}" "${c_off}"
printf '\n'
return 0
}
10 changes: 10 additions & 0 deletions launchers/launch-native-jupyter.sh
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,16 @@ mkdir -p "$(dirname "$LOG_FILE")"

exec > >(tee -a "$LOG_FILE") 2>&1

# Startup banner (shared; see _banner.sh). Placed after the tee redirect so it
# lands in the log too. Non-fatal under `set -euo pipefail`.
if [ -f "$SCRIPT_DIR/_banner.sh" ]; then
# shellcheck source=/dev/null
. "$SCRIPT_DIR/_banner.sh" || true
quantui_banner \
"NATIVE JUPYTER MODE — JupyterLab, not Voilà" \
"output is tee'd to logs/native-jupyter.log" || true
fi

# Locate conda.sh. Detect the install rather than assuming one location:
# miniforge (recommended for WSL), miniconda, and anaconda are all supported.
# Falls back to an already-configured conda via $CONDA_EXE.
Expand Down
15 changes: 12 additions & 3 deletions launchers/launch-native.command
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,18 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# Repo root is one level up from this launchers/ folder.
cd "$SCRIPT_DIR/.."

echo "QuantUI NATIVE MODE — Local conda env on macOS, no container"
echo "Use this when you have edited quantui/*.py and want to test immediately."
echo
# Startup banner (shared; see _banner.sh). Non-fatal by design under
# `set -eu` — decoration must never abort a launch.
if [ -f "$SCRIPT_DIR/_banner.sh" ]; then
# shellcheck source=/dev/null
. "$SCRIPT_DIR/_banner.sh" || true
quantui_banner \
"NATIVE MODE — local conda env on macOS, no container" \
"quantui/*.py edits are live; no rebuild needed" || true
else
echo "QuantUI NATIVE MODE — Local conda env on macOS, no container"
echo
fi

# Locate conda.sh. Detect the install rather than assuming one location:
# miniforge, miniconda, and anaconda are all supported, in home, /opt, and
Expand Down
16 changes: 13 additions & 3 deletions launchers/launch-native.sh
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,19 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# Repo root is one level up from this launchers/ folder.
cd "$SCRIPT_DIR/.."

echo "QuantUI NATIVE MODE — Local conda env on Linux/WSL, no container"
echo "Use this when you have edited quantui/*.py and want to test immediately."
echo
# Startup banner (shared with the other shell launchers; see _banner.sh).
# `|| true` so a missing/oddly-permissioned banner file can never abort a
# launch under `set -eu` — it is decoration, not function.
if [ -f "$SCRIPT_DIR/_banner.sh" ]; then
# shellcheck source=/dev/null
. "$SCRIPT_DIR/_banner.sh" || true
quantui_banner \
"NATIVE MODE — local conda env on Linux/WSL, no container" \
"quantui/*.py edits are live; no rebuild needed" || true
else
echo "QuantUI NATIVE MODE — Local conda env on Linux/WSL, no container"
echo
fi

# Locate conda.sh. Detect the install rather than assuming one location:
# miniforge (recommended for WSL), miniconda, and anaconda are all supported,
Expand Down
133 changes: 133 additions & 0 deletions quantui/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,9 @@
from quantui.app_runflow import (
on_freq_seed_changed as _run_on_freq_seed_changed,
)
from quantui.app_runflow import (
on_geo_seed_changed as _run_on_geo_seed_changed,
)
from quantui.app_runflow import (
on_help_toggle as _run_on_help_toggle,
)
Expand Down Expand Up @@ -308,6 +311,9 @@
from quantui.app_runflow import (
refresh_freq_seed_options as _run_refresh_freq_seed_options,
)
from quantui.app_runflow import (
refresh_geo_seed_options as _run_refresh_geo_seed_options,
)
from quantui.app_runflow import (
refresh_results_browser as _run_refresh_results_browser,
)
Expand Down Expand Up @@ -694,6 +700,25 @@ def _layout(**kwargs: Any) -> widgets.Layout:
_RE_CONV = re.compile(r"converged SCF energy\s*=\s*([\-\d\.]+)")
_RE_Q_STATUS = re.compile(r"\[QuantUI_STATUS\]\s*(.+)")

# ── Silent-phase heartbeat (M-PROGRESS Phase D) ──────────────────────────────
#
# Seconds of stream silence before the log says it is still alive.
#
# Sized from a real measurement, not a guess: a user timed an aspirin
# (21 atoms) B3LYP/6-31G* UV-Vis run and the log printed **nothing for 120 s**
# after "converged SCF energy" while the TD-DFT solve ran. The status label was
# advancing the whole time — Phase A covers that — but the log, which is what a
# user actually watches, looked frozen.
#
# 25 s yields ~4 lines across that gap: enough to prove liveness, few enough not
# to bloat the archived pyscf.log. Gaps grow steeply with system size, and
# aspirin is a *small* case, so err on the short side.
_HEARTBEAT_AFTER_S = 25.0

# How often the watchdog wakes to check. Well under _HEARTBEAT_AFTER_S so a beat
# lands close to its due time, but coarse enough to be free.
_HEARTBEAT_POLL_S = 2.0


# ══ LOG CAPTURE ══════════════════════════════════════════════════════════════

Expand Down Expand Up @@ -729,6 +754,70 @@ def __init__(
# Completion fraction (0..1) reported by calc modules via
# log_utils.emit_progress; read by the elapsed ticker. None = unknown.
self._fraction: Optional[float] = None
# Silent-phase heartbeat (M-PROGRESS Phase D). Long kernels — the TD-DFT
# excited-state solve most of all — print nothing for minutes, so the
# log looks hung even though the status label is advancing. A watchdog
# appends a "still working" line when the stream has gone quiet.
self._last_write_t = time.monotonic()
self._hb_started_t = self._last_write_t
self._hb_stop = threading.Event()
self._hb_thread: Optional[threading.Thread] = None

# ── Silent-phase heartbeat ──────────────────────────────────────────────

def start_heartbeat(self) -> None:
"""Begin watching for silent stretches. Idempotent."""
if self._hb_thread is not None:
return
self._last_write_t = time.monotonic()
self._hb_started_t = self._last_write_t
self._hb_thread = threading.Thread(
target=self._heartbeat_loop, daemon=True, name="quantui-log-heartbeat"
)
self._hb_thread.start()

def stop_heartbeat(self) -> None:
"""Stop the watchdog. Safe to call more than once, or if never started."""
self._hb_stop.set()
self._hb_thread = None

def _heartbeat_loop(self) -> None:
"""Append a liveness line whenever the stream has been quiet too long.

Deliberately writes **directly** to the widget and buffer rather than
going through :meth:`write`: that path checks cancellation (which would
raise ``_CalcCancelled`` on this thread, where nothing can catch it) and
would also reset the very timer being measured.
"""
while not self._hb_stop.wait(_HEARTBEAT_POLL_S):
quiet_for = time.monotonic() - self._last_write_t
if quiet_for < _HEARTBEAT_AFTER_S:
continue
stage = ""
if self._status is not None:
try:
stage = str(self._status.value).strip()
except Exception: # noqa: BLE001 — a label read must not kill it
stage = ""
from quantui.log_utils import format_elapsed

elapsed = time.monotonic() - self._hb_started_t
line = " … still working"
if stage:
line += f" — {stage}"
line += f" · {format_elapsed(elapsed)} elapsed\n"
try:
# Widget only — deliberately NOT self._buf. The buffer becomes
# the result directory's pyscf.log, which should stay a faithful
# record of what PySCF emitted. Heartbeats are UI chrome for the
# live view; padding the archived log with them would make a
# long silent run look chatty after the fact.
self._w.append_stdout(line)
except Exception: # noqa: BLE001 — never let the log kill a run
pass
# Reset so the next beat is measured from this line, giving evenly
# spaced heartbeats instead of one per poll once the gap is open.
self._last_write_t = time.monotonic()

def write(self, text: str) -> None:
if not text:
Expand All @@ -737,6 +826,9 @@ def write(self, text: str) -> None:
# opt step), so raising here stops it at the next line. See _CalcCancelled.
if self._cancel_check is not None and self._cancel_check():
raise _CalcCancelled()
# Any real output resets the silence timer, so a heartbeat only ever
# appears in a genuinely quiet stretch.
self._last_write_t = time.monotonic()
self._w.append_stdout(text)
self._buf.write(text)
self._line_buf += text
Expand Down Expand Up @@ -971,6 +1063,8 @@ class QuantUIApp:
xyz_btn: Any
xyz_msg: Any
_freq_preopt_cb: Any
_geo_seed_dd: Any
_geo_seed_note: Any
_freq_seed_dd: Any
_freq_seed_note: Any
_freq_seed_refresh_btn: Any
Expand Down Expand Up @@ -1760,12 +1854,18 @@ def _wire_callbacks(self) -> None:
self._freq_seed_dd.observe(
self._safe_cb(self._on_freq_seed_changed), names="value"
)
self._geo_seed_dd.observe(
self._safe_cb(self._on_geo_seed_changed), names="value"
)
self._tddft_seed_dd.observe(
self._safe_cb(self._on_tddft_seed_changed), names="value"
)
self._scan_type_dd.observe(
self._safe_cb(self._update_scan_widgets), names="value"
)
self._geo_seed_refresh_btn.on_click(
lambda _btn: self._refresh_geo_seed_options()
)
self._freq_seed_refresh_btn.on_click(
lambda _btn: self._refresh_freq_seed_options()
)
Expand Down Expand Up @@ -3142,6 +3242,12 @@ def _on_calc_type_changed(self, change) -> None:
def _update_scan_widgets(self, _change=None) -> None:
_run_update_scan_widgets(self, _change)

def _refresh_geo_seed_options(self) -> None:
_run_refresh_geo_seed_options(self)

def _on_geo_seed_changed(self, change) -> None:
_run_on_geo_seed_changed(self, change)

def _refresh_freq_seed_options(self) -> None:
_run_refresh_freq_seed_options(self)

Expand Down Expand Up @@ -4285,6 +4391,10 @@ def _run_required_final_single_point(target_mol, reason: str):
# Expose this run's log to the elapsed ticker so it can read the
# completion fraction calc modules report via emit_progress.
self._active_log = log
# Watch for silent stretches (M-PROGRESS Phase D). Stopped in the
# `finally` alongside the elapsed ticker, so it cannot outlive the run
# and keep writing into a finished log.
log.start_heartbeat()

# The run header (structured banner) is written synchronously + atomically
# on the main thread by ``on_run_clicked`` → ``_write_run_header`` BEFORE
Expand Down Expand Up @@ -4367,6 +4477,25 @@ def _run_required_final_single_point(target_mol, reason: str):
)

if ct == "Geometry Opt":
# Optional seed: start from a previously optimised geometry
# rather than the current molecule — the "optimise cheaply,
# then refine at a higher level of theory" workflow. Mirrors
# the Frequency / UV-Vis seed handling below.
_geo_seed_path = self._geo_seed_dd.value
if _geo_seed_path:
from quantui.results_storage import load_trajectory

self.run_status.value = "Loading seed geometry from history…"
_geo_seed_traj, _ = load_trajectory(Path(_geo_seed_path))
calc_mol = _geo_seed_traj[-1]
log.write(
f"\nSeed geometry loaded from: "
f"{Path(_geo_seed_path).name}\n"
f" Formula: {calc_mol.get_formula()} "
f"Atoms: {len(calc_mol.atoms)}\n"
" Optimization starts from this geometry.\n\n"
)

self.run_status.value = "Optimizing geometry..."
from quantui import optimize_geometry

Expand Down Expand Up @@ -5150,6 +5279,10 @@ def _run_required_final_single_point(target_mol, reason: str):
self.cancel_btn.description = "Cancel"
self.log_clear_btn.disabled = False
self._stop_elapsed_ticker()
try:
log.stop_heartbeat()
except Exception: # noqa: BLE001 — teardown must not mask a failure
pass
self._activity_end(kind="compute")

# ── Live elapsed ticker ────────────────────────────────────────────────
Expand Down
35 changes: 31 additions & 4 deletions quantui/app_builders.py
Original file line number Diff line number Diff line change
Expand Up @@ -815,15 +815,20 @@ def build_shared_widgets(
overflow="hidden",
)
)
# Keep/Revert live in their own box so the preview handler can hide them
# independently of the status line. When the relaxation moves the geometry
# negligibly there is nothing to keep or revert, and offering the choice is
# just confusing — see app_runflow._preopt_preview_done.
app._preopt_actions_box = widgets.HBox(
[app.preopt_accept_btn, app.preopt_reset_btn],
layout=layout_fn(gap="8px", margin="6px 0 0"),
)
# Whole preview block hidden until the user clicks Preview.
app.preopt_preview_box = widgets.VBox(
[
app.preopt_preview_status,
app.preopt_preview_output,
widgets.HBox(
[app.preopt_accept_btn, app.preopt_reset_btn],
layout=layout_fn(gap="8px", margin="6px 0 0"),
),
app._preopt_actions_box,
],
layout=layout_fn(display="none", margin="6px 0 4px", max_width="480px"),
)
Expand Down Expand Up @@ -888,6 +893,28 @@ def build_shared_widgets(
layout=layout_fn(width="180px"),
)

# Geometry Opt seed: start a run from a previously optimised geometry —
# the standard "optimise cheaply, then refine at a higher level of theory"
# workflow. Same filtered-dropdown pattern as the Frequency / UV-Vis seeds
# below; the shared `_refresh_seed_options` helper populates all three.
app._geo_seed_dd = widgets.Dropdown(
options=[("(use current molecule)", "")],
description="Seed geometry:",
style={"description_width": "110px"},
layout=layout_fn(width="auto", flex="1 1 auto", min_width="260px"),
tooltip=(
"Optionally start from the final geometry of a previous Geo Opt "
"result — e.g. optimise at a low level of theory, then refine here"
),
)
app._geo_seed_refresh_btn = widgets.Button(
description="",
icon="refresh",
layout=layout_fn(width="32px", height="32px"),
tooltip="Refresh the list of saved geometry optimisations",
)
app._geo_seed_note = widgets.HTML("")

app._freq_seed_dd = widgets.Dropdown(
options=[("(use current molecule)", "")],
description="Seed geometry:",
Expand Down
Loading