diff --git a/quantui/app.py b/quantui/app.py index b47ccef..ab3e15a 100644 --- a/quantui/app.py +++ b/quantui/app.py @@ -392,6 +392,7 @@ from quantui.app_visualization import ( wire_uv_controls as _viz_wire_uv_controls, ) +from quantui.cancellation import CalcCancelled as _CalcCancelled # Import directly from submodules to avoid circular-import issues. # quantui/__init__.py imports this module (app.py), so using @@ -606,6 +607,35 @@ def _load_last_calibration_label() -> str: .jp-OutputArea-stderr, .output_stderr { background: transparent !important; } + +/* 3D molecule-viewer frames (UXP.2) — a subtle bounding box so the viewer + extent reads clearly. The light border inverts to dark automatically under + the global dark-mode invert filter. */ +.quantui-viewer-frame { + border: 1px solid #c0ccd8 !important; + border-radius: 6px !important; + overflow: hidden !important; +} +/* Collapse the frame to nothing when the viewer output is empty (e.g. before + a calc runs) so no hollow box shows. Degrades to an always-on border where + :has() is unsupported. */ +.quantui-viewer-frame:not(:has(.jp-OutputArea-child)) { + border-color: transparent !important; +} + +/* Inline "calculating" spinner (UXP.3) — shown next to slow on-demand + controls (e.g. orbital-isosurface generation) while work is in flight. */ +@keyframes quantui-spin { to { transform: rotate(360deg); } } +.quantui-spinner { + display: inline-block; + width: 14px; + height: 14px; + border: 2px solid #c0ccd8; + border-top-color: #2563eb; + border-radius: 50%; + animation: quantui-spin 0.7s linear infinite; + vertical-align: middle; +} """ _LAYOUT_TRAITS: frozenset[str] = frozenset(widgets.Layout.class_trait_names()) @@ -638,19 +668,12 @@ def _layout(**kwargs: Any) -> widgets.Layout: # ══ LOG CAPTURE ══════════════════════════════════════════════════════════════ -class _CalcCancelled(BaseException): - """Raised to abort a running calculation when the user clicks Cancel. - - The calc streams output line-by-line through ``_LogCapture.write`` (SCF - cycles, optimizer steps), so checking a cancel flag there lets us bail at - the next output — a cooperative, graceful stop that needs no thread-kill. - - Inherits ``BaseException`` (like ``KeyboardInterrupt``) on purpose: PySCF / - session_calc / the optimizer wrap their kernels in ``except Exception``, so - a plain ``Exception`` cancel would be swallowed and re-reported as a - "Calculation failed" error. As a ``BaseException`` it sails through those - handlers and reaches ``_do_run``'s ``except _CalcCancelled`` cleanly. - """ +# ``_CalcCancelled`` is defined in quantui.cancellation (imported at the top of +# this module) so the calc modules (session_calc / optimizer / freq / tddft / +# nmr / pes) can raise the SAME class from their SCF callbacks + optimizer +# observers (UXP.5) without importing the app layer. ``_do_run``'s +# ``except _CalcCancelled`` catches it whether it was raised by +# ``_LogCapture.write`` or by one of those hooks. class _LogCapture: @@ -670,6 +693,9 @@ def __init__( self._on_scf_converged = on_scf_converged self._scf_converged_seen = False self._cancel_check = cancel_check + # Public alias so calc modules can duck-type the predicate off the + # progress stream (see quantui.cancellation.cancel_check_from_stream). + self.cancel_check = cancel_check def write(self, text: str) -> None: if not text: @@ -961,7 +987,9 @@ class QuantUIApp: mol_info_html: Any mol_summary_compact: Any mult_si: Any - notes_output: Any + _method_card_html: Any + _basis_card_html: Any + _descriptor_cards_box: Any nstates_si: Any perf_estimate_html: Any post_calc_panel: Any @@ -1757,6 +1785,10 @@ def _wire_callbacks(self) -> None: ) # Orbital isosurface generate button self._iso_generate_btn.on_click(self._on_iso_generate) + # UXP.4: reveal the free-entry MO-index input only in "By index" mode. + self._orb_toggle.observe( + self._safe_cb(self._on_orb_toggle_changed), names="value" + ) # M-EXPORT / EXPORT.5: cube + bundle exports self._iso_export_cube_btn.on_click(self._on_iso_export_cube) self._export_bundle_btn.on_click(self._on_export_bundle) @@ -1770,6 +1802,13 @@ def _files_allowed_roots(self) -> list[Path]: _last_dir = getattr(self, "_last_result_dir", None) if isinstance(_last_dir, Path): candidates.append(_last_dir) + # UXP.1: expose the app's own log dir (~/.quantui/logs) so the event + # log is reachable in-app. Resolves inside the runtime process, so it + # correctly points at the WSL home when the app runs under WSL. + try: + candidates.append(_calc_log._log_dir()) + except Exception: + pass for candidate in candidates: if candidate is None: @@ -1829,6 +1868,10 @@ def _format_files_root_label(self, root: Path) -> str: labels.append(("Current Result", _last_dir.resolve())) except OSError: pass + try: + labels.append(("Logs", _calc_log._log_dir().resolve())) + except OSError: + pass for prefix, known_root in labels: if root == known_root: @@ -1990,6 +2033,7 @@ def _preview_file_path(self, path: Path) -> None: ".txt", ".log", ".json", + ".jsonl", ".md", ".py", ".csv", @@ -2080,6 +2124,50 @@ def _preview_file_path(self, path: Path) -> None: except Exception: # noqa: BLE001 — fall through to text preview pass + if suffix == ".jsonl": + # UXP.1: JSONL logs (event_log.jsonl) grow append-only, so the + # newest — and most useful — records are at the END. The generic + # text dispatch keeps the FIRST 200 KB (oldest events), so tail + # the file here instead: show the last N lines, newest last. + try: + _MAX_TAIL_LINES = 300 + raw = path.read_bytes() + total_bytes = len(raw) + # Cap the decode window so a multi-MB log stays responsive; + # the tail is all we render anyway. + tail_raw = raw[-400_000:] + text = tail_raw.decode("utf-8", errors="replace") + lines = text.splitlines() + # A leading partial line can appear after byte-slicing — drop it. + if len(tail_raw) < total_bytes and lines: + lines = lines[1:] + total_lines_shown = min(len(lines), _MAX_TAIL_LINES) + shown = lines[-_MAX_TAIL_LINES:] + rendered = "\n".join(shown) + note = ( + f"Showing the last {total_lines_shown} record(s)" + + ( + " (file tail — older records not shown)" + if len(lines) > _MAX_TAIL_LINES or len(tail_raw) < total_bytes + else "" + ) + + "." + ) + with self._files_preview_output: + display( + HTML( + "

" + f"{_html.escape(note)}

" + "
"
+                            f"{_html.escape(rendered)}
" + ) + ) + self._set_files_status(f"Log preview (tail): {path.name}") + return + except Exception: # noqa: BLE001 — fall through to text preview + pass + if suffix == ".csv": try: import csv as _csv @@ -2944,7 +3032,17 @@ def _on_cancel(self, btn=None) -> None: return self._cancel_event.set() self.cancel_btn.disabled = True - self.run_status.value = "Cancelling — stopping at the next step…" + self.cancel_btn.description = "Cancelling…" + self.run_status.value = "Cancelling — stopping at the next cycle/step…" + # UXP.5: write an immediate marker to the live log so the click reads as + # acknowledged even if the next cooperative checkpoint is a moment away. + try: + self.run_output.append_stdout( + "\n⏹ Cancel requested — stopping at the next SCF cycle / " + "optimizer step…\n" + ) + except Exception: + pass def _on_preopt_preview(self, btn=None) -> None: _run_on_preopt_preview(self, btn) @@ -3824,6 +3922,12 @@ def _show_orbital_diagram(self, result) -> bool: def _on_iso_generate(self, btn) -> None: _viz_on_iso_generate(self, btn) + def _on_orb_toggle_changed(self, change) -> None: + """Show/hide the free-entry MO-index input for the 'By index' mode.""" + self._orb_index_input.layout.display = ( + "" if change["new"] == "By index" else "none" + ) + def _on_orb_range_changed(self, _change=None) -> None: _viz_on_orb_range_changed(self, _change) @@ -4864,6 +4968,7 @@ def _run_required_final_single_point(target_mol, reason: str): self._calc_running = False self._cancel_event.clear() self.cancel_btn.disabled = True + self.cancel_btn.description = "Cancel" self.log_clear_btn.disabled = False self._activity_end(kind="compute") diff --git a/quantui/app_builders.py b/quantui/app_builders.py index 9e61409..b249408 100644 --- a/quantui/app_builders.py +++ b/quantui/app_builders.py @@ -509,6 +509,7 @@ def build_shared_widgets( # needs no scrollbar — clipping a few px of margin avoids an internal # scrollbar that resets to the top on every backend/palette swap. app.viz_output = widgets.Output(layout=layout_fn(height="510px", overflow="hidden")) + app.viz_output.add_class("quantui-viewer-frame") app.run_output = widgets.Output( layout=layout_fn( border="1px solid #c0ccd8", @@ -528,6 +529,7 @@ def build_shared_widgets( ) app.result_output = widgets.Output() app.result_viz_output = widgets.Output() + app.result_viz_output.add_class("quantui-viewer-frame") app.comparison_output = widgets.Output() app._last_result_dir = None @@ -568,7 +570,18 @@ def build_shared_widgets( [app.viz_style_dd, app.viz_lighting_dd], layout=layout_fn(gap="8px", margin="2px 0 0 0", align_items="center"), ) - app.notes_output = widgets.Output() + # UXP.7: method / basis descriptor cards replace the old inline + # ``notes_output`` educational-notes block. Two compact HTML cards + # (icon + one-line) sit to the right of the dropdowns; populated here with + # the defaults and refreshed by ``update_notes`` on every dropdown change. + from quantui.descriptor_cards import basis_card_html, method_card_html + + app._method_card_html = widgets.HTML(value=method_card_html(default_method)) + app._basis_card_html = widgets.HTML(value=basis_card_html(default_basis)) + app._descriptor_cards_box = widgets.VBox( + [app._method_card_html, app._basis_card_html], + layout=layout_fn(margin="0 0 0 4px"), + ) app.perf_estimate_html = widgets.HTML() app.step_progress = step_progress_cls( @@ -875,7 +888,7 @@ def build_shared_widgets( icon="stop", disabled=True, layout=layout_fn(width="110px", height="36px"), - tooltip="Stop the running calculation (at the next step)", + tooltip=("Stop the running calculation at the next SCF cycle / optimizer step"), ) app.log_clear_btn = widgets.Button( @@ -1257,7 +1270,12 @@ def build_calc_setup(app: Any, *, layout_fn: Any) -> None: ), widgets.HTML("  "), widgets.VBox([app.charge_si, app.mult_si]), - ] + widgets.HTML(" "), + # UXP.7 descriptor cards — to the right of the inputs so + # they don't displace charge/multiplicity. + app._descriptor_cards_box, + ], + layout=layout_fn(flex_wrap="wrap", align_items="flex-start"), ), app.calc_type_dd, app.calc_extra_opts, @@ -1271,7 +1289,6 @@ def build_calc_setup(app: Any, *, layout_fn: Any) -> None: [app.solvent_cb, app.solvent_dd], layout=layout_fn(align_items="center", gap="4px"), ), - app.notes_output, ] ) @@ -1561,12 +1578,24 @@ def _plot_export_row(prefix: str) -> widgets.HBox: orb_diagram_content, layout=layout_fn(width="100%"), ) + # UXP.4: widen the preset buttons and add a "By index" mode that reveals a + # free-entry 0-based MO index input, so any orbital (not just the HOMO/LUMO + # neighbourhood) can be rendered as an isosurface. app._orb_toggle = widgets.ToggleButtons( - options=["HOMO-1", "HOMO", "LUMO", "LUMO+1"], + options=["HOMO-1", "HOMO", "LUMO", "LUMO+1", "By index"], value="HOMO", - style={"button_width": "70px"}, + style={"button_width": "92px"}, layout=layout_fn(margin="8px 0 4px 0"), ) + app._orb_index_input = widgets.BoundedIntText( + value=0, + min=0, + max=100000, + description="MO #", + tooltip="0-based molecular-orbital index (0 = lowest-energy MO)", + style={"description_width": "40px"}, + layout=layout_fn(display="none", width="150px", margin="0 0 4px 0"), + ) app._orb_iso_output = widgets.Output() app._orb_iso_controls = widgets.VBox( [ @@ -1575,6 +1604,7 @@ def _plot_export_row(prefix: str) -> widgets.HBox: "Orbital isosurface:" ), app._orb_toggle, + app._orb_index_input, app._orb_iso_output, ], layout=layout_fn(display="none", margin="8px 0 0 0"), @@ -1619,6 +1649,12 @@ def _plot_export_row(prefix: str) -> widgets.HBox: app._iso_export_status = widgets.HTML( value="", layout=layout_fn(margin="0 0 0 8px") ) + # UXP.3: inline "calculating" spinner shown while a cube is being computed. + # Hidden until on_iso_generate reveals it; hidden again on completion. + app._iso_spinner = widgets.HTML( + value='', + layout=layout_fn(display="none", margin="0 0 0 4px"), + ) iso_body = widgets.VBox( [ widgets.HTML( @@ -1631,6 +1667,7 @@ def _plot_export_row(prefix: str) -> widgets.HBox: widgets.HBox( [ app._iso_generate_btn, + app._iso_spinner, app._iso_export_cube_btn, app._iso_export_status, ], @@ -1772,6 +1809,7 @@ def _plot_export_row(prefix: str) -> widgets.HBox: app.results_panel = app.results_tab_panel app._analysis_mol_output = widgets.Output() + app._analysis_mol_output.add_class("quantui-viewer-frame") # Analysis-tab backend toggle — mirrors the Calculate-tab `viz_backend_toggle`. # Created only when both backends are available (matches Calculate-tab diff --git a/quantui/app_runflow.py b/quantui/app_runflow.py index afaef8d..56d967a 100644 --- a/quantui/app_runflow.py +++ b/quantui/app_runflow.py @@ -21,6 +21,45 @@ 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", +} + + +def _write_provisional_run_header(app: Any) -> None: + """Print an immediate one-line header the instant Run is clicked (UXP.6). + + 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. + """ + mol = getattr(app, "_molecule", None) + if mol is None: + return + try: + formula = mol.get_formula() + except Exception: + formula = "?" + ct_label = _RUN_HEADER_CALC_LABELS.get( + app.calc_type_dd.value, app.calc_type_dd.value + ) + try: + app.run_output.append_stdout( + f"▶ Starting {ct_label} — {formula} · " + f"{app.method_dd.value}/{app.basis_dd.value} …\n" + ) + 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() @@ -39,6 +78,7 @@ 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() @@ -1058,40 +1098,17 @@ def _progress( def update_notes(app: Any, change: Any = None) -> None: - """Refresh educational method notes for the active molecule/method.""" - app.notes_output.clear_output(wait=True) - if app._molecule is None: - return + """Refresh the method / basis descriptor cards (UXP.7). + + Replaces the old inline educational-notes text block. The cards describe + the *method* and *basis* themselves, so — unlike the old notes — they + refresh independently of whether a molecule is loaded. + """ try: - from quantui import PySCFCalculation + from quantui.descriptor_cards import basis_card_html, method_card_html - calc = PySCFCalculation( - app._molecule, - method=app.method_dd.value, - basis=app.basis_dd.value, - ) - notes = calc.get_educational_notes() - if notes: - # M12 audit fix (2026-07-14): .replace("**", ..., 1) only - # converts the FIRST **bold** pair in the whole string — every - # note after the first (get_educational_notes() typically - # returns 2-3 separate "**Label**: description" paragraphs - # joined by "\n\n") kept its literal "**" markers instead of - # being rendered bold. A regex replaces every **...** pair. - import re as _re - - safe = _re.sub(r"\*\*(.+?)\*\*", r"\1", notes).replace( - "\n\n", "

" - ) - with app.notes_output: - display( - HTML( - '
' - + safe - + "
" - ) - ) + app._method_card_html.value = method_card_html(app.method_dd.value) + app._basis_card_html.value = basis_card_html(app.basis_dd.value) except Exception: pass diff --git a/quantui/app_visualization.py b/quantui/app_visualization.py index 83e36ee..f8cd497 100644 --- a/quantui/app_visualization.py +++ b/quantui/app_visualization.py @@ -1040,10 +1040,23 @@ def show_orbital_diagram(app: Any, result: Any) -> bool: def on_iso_generate(app: Any, btn: Any) -> None: """Generate orbital isosurface for currently selected orbital.""" orbital_label = app._orb_toggle.value + # UXP.4: "By index" mode renders an arbitrary 0-based MO index. Encode it + # into the label as "MO "; render_orbital_isosurface parses it back. + if orbital_label == "By index": + orbital_label = f"MO {int(app._orb_index_input.value)}" app._iso_render_token = int(getattr(app, "_iso_render_token", 0)) + 1 render_token = app._iso_render_token btn.disabled = True btn.description = "Generating…" + # UXP.3: reveal the inline spinner + light the toolbar activity indicator + # so the (slow) cube generation reads as busy, not hung. + _spinner = getattr(app, "_iso_spinner", None) + if _spinner is not None: + _spinner.layout.display = "" + try: + app._activity_begin("Generating orbital isosurface…", kind="compute") + except Exception: + pass try: from quantui import calc_log as _clog @@ -1061,8 +1074,27 @@ def on_iso_generate(app: Any, btn: Any) -> None: ) done = threading.Event() + # UXP.3: balance the single _activity_begin above exactly once, across + # both the normal-completion and timeout paths (idempotent). + _finished = threading.Event() + + def _finish_activity() -> None: + if _finished.is_set(): + return + _finished.set() + try: + app._activity_end(kind="compute") + except Exception: + pass + # Only hide the spinner if no newer generation superseded this one + # (a newer render still wants the spinner visible). + if render_token == int(getattr(app, "_iso_render_token", 0)): + _sp = getattr(app, "_iso_spinner", None) + if _sp is not None: + _sp.layout.display = "none" def _reset_button() -> None: + _finish_activity() if render_token != int(getattr(app, "_iso_render_token", 0)): return btn.disabled = False @@ -1080,6 +1112,7 @@ def _watchdog() -> None: return def _show_timeout() -> None: + _finish_activity() if render_token != int(getattr(app, "_iso_render_token", 0)): return try: @@ -1166,7 +1199,29 @@ def _is_stale() -> bool: "LUMO+1": n_occ + 1, } orb_idx = idx_map.get(orbital_label) + # UXP.4: "MO " labels carry an explicit 0-based index from By-index mode. + if orb_idx is None: + _m = _re.match(r"MO\s+(\d+)$", orbital_label) + if _m: + orb_idx = int(_m.group(1)) if orb_idx is None or orb_idx < 0 or orb_idx >= n_total: + # Out-of-range (now user-reachable via free index entry) — surface it + # instead of silently leaving the "Generating…" placeholder in place. + def _show_range_err() -> None: + if _is_stale(): + return + app._orb_iso_output.clear_output() + with app._orb_iso_output: + display( + HTML( + '

' + f"⚠ Orbital index out of range. This calculation has " + f"{n_total} molecular orbitals (valid indices 0–" + f"{n_total - 1}; HOMO = {n_occ - 1}).

" + ) + ) + + app._queue_main_thread_callback(_show_range_err) return mo_coeff = getattr(app, "_last_orb_mo_coeff", None) diff --git a/quantui/cancellation.py b/quantui/cancellation.py new file mode 100644 index 0000000..0ddc3e6 --- /dev/null +++ b/quantui/cancellation.py @@ -0,0 +1,67 @@ +"""Shared cancellation primitive for in-flight calculations (UXP.5). + +The Cancel button sets a ``threading.Event`` on the app; the run's +``_LogCapture`` checks it on every output line and raises +:class:`CalcCancelled` at the next line — a cooperative stop that needs no +(unsafe) thread-kill. UXP.5 tightens that latency by also attaching the same +check to PySCF SCF callbacks and ASE optimizer observers, so cancellation +fires between SCF cycles / optimizer steps even when the calc is running +silently (verbose=0) and no output line is triggering the stream check. + +The cancellation exception inherits :class:`BaseException`, **not** +``Exception`` — PySCF / ASE / ``session_calc`` wrap their kernels in +``except Exception``, so a plain-``Exception`` cancel would be swallowed and +re-reported as "Calculation failed". A ``BaseException`` (like +``KeyboardInterrupt``) sails through and reaches ``_do_run``'s +``except _CalcCancelled`` cleanly. See reflection 02 Rule 8. +""" + +from __future__ import annotations + +from typing import Any, Callable, Optional + +CancelCheck = Callable[[], bool] + + +class CalcCancelled(BaseException): + """Raised to abort an in-flight calculation at a cooperative checkpoint.""" + + +def raise_if_cancelled(cancel_check: Optional[CancelCheck]) -> None: + """Raise :class:`CalcCancelled` if *cancel_check* is set and returns True.""" + if cancel_check is not None and cancel_check(): + raise CalcCancelled() + + +def cancel_check_from_stream(stream: Any) -> Optional[CancelCheck]: + """Extract the cancel-check predicate carried on a progress stream. + + The run's ``_LogCapture`` exposes its cancel predicate as a public + ``cancel_check`` attribute. Any calc module that receives the stream can + duck-type it out with this helper — no import of the app layer needed. + Returns ``None`` when the stream carries no (callable) predicate. + """ + cc = getattr(stream, "cancel_check", None) + 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). + """ + if cancel_check is None or mf is None: + return + + def _cb(_envs: Any, _cc: CancelCheck = cancel_check) -> None: + if _cc(): + raise CalcCancelled() + + try: + mf.callback = _cb + except Exception: # noqa: BLE001 — best-effort; not all backends allow it + pass diff --git a/quantui/descriptor_cards.py b/quantui/descriptor_cards.py new file mode 100644 index 0000000..f18d19b --- /dev/null +++ b/quantui/descriptor_cards.py @@ -0,0 +1,177 @@ +"""Method / basis descriptor cards (UXP.7 — FR-DESCRIPTOR-CARDS). + +Replaces the inline multi-paragraph educational-notes block next to the +method / basis dropdowns with two compact "descriptor cards" — an icon, a +one-line title, and a single distilled sentence — styled like the History-tab +result cards (light background + coloured left border). + +Pure string builders (no widgets / no PySCF import) so the card content is +unit-testable in isolation. Icons are inline SVG (offline-safe — never a CDN +asset) drawn with ``stroke="currentColor"`` so they take the family accent +colour from the wrapping span and invert with the global dark-mode filter like +the rest of the UI (inline SVG is not one of the ``canvas/img/iframe/video`` +tags the filter excludes). + +Method family comes from ``config.METHOD_INFO[method]["type"]`` +(``hf`` / ``dft`` / ``wavefunction``); the one-line body reuses that entry's +``use_for`` so the card can never drift from the help text. Basis family is +derived from the basis-set name. +""" + +from __future__ import annotations + +from . import config + +# ── Icons (24×24 inline SVG, currentColor) ─────────────────────────────────── + +_SVG_OPEN = ( + '' +) + +# Hartree-Fock — paired spins (↑↓). +_ICON_HF = ( + _SVG_OPEN + '' + '' + '' + '' +) + +# DFT — electron-density cloud. +_ICON_DFT = ( + _SVG_OPEN + '' +) + +# Post-HF wavefunction — correlated wave. +_ICON_WAVE = _SVG_OPEN + '' + +# Minimal basis — a single tight function. +_ICON_BASIS_MINIMAL = ( + _SVG_OPEN + '' +) + +# Split-valence (Pople) — a core function plus a split valence shell. +_ICON_BASIS_POPLE = ( + _SVG_OPEN + '' + '' +) + +# Correlation-consistent — systematically nested shells. +_ICON_BASIS_CC = ( + _SVG_OPEN + '' + '' + '' +) + +# def2 (Karlsruhe) — a function with polarisation lobes. +_ICON_BASIS_DEF2 = ( + _SVG_OPEN + '' + '' + '' + '' + '' +) + +# ── Family → (accent fg, light bg, icon) ───────────────────────────────────── +# Palette mirrors the calc-type badge colours in app_formatters so the whole +# app reads as one system. + +_METHOD_FAMILY_STYLE = { + "hf": ("#2563eb", "#eff6ff", _ICON_HF), + "dft": ("#7c3aed", "#f5f3ff", _ICON_DFT), + "wavefunction": ("#b45309", "#fffbeb", _ICON_WAVE), +} +_METHOD_FALLBACK_STYLE = ("#475569", "#f8fafc", _ICON_HF) + +_BASIS_FAMILY_STYLE = { + "minimal": ("#64748b", "#f8fafc", _ICON_BASIS_MINIMAL), + "pople": ("#0d9488", "#f0fdfa", _ICON_BASIS_POPLE), + "cc": ("#15803d", "#f0fdf4", _ICON_BASIS_CC), + "def2": ("#c2410c", "#fff7ed", _ICON_BASIS_DEF2), +} + +# ── Basis family classification + one-line copy ────────────────────────────── + +_BASIS_COPY = { + "minimal": ( + "Minimal", + "Fastest, lowest accuracy — great for learning, not research.", + ), + "pople": ( + "Split-valence (Pople)", + "Balanced speed/accuracy; * and ** add polarisation for bonds " + "and lone pairs.", + ), + "cc": ( + "Correlation-consistent", + "Systematic convergence; best paired with correlated methods " "(MP2 / CCSD).", + ), + "def2": ( + "Karlsruhe (def2)", + "Optimised for DFT; def2-SVP a solid default, def2-TZVP near " + "complete-basis accuracy.", + ), +} + + +def basis_family(basis: str) -> str: + """Classify a basis-set name into an icon/copy family key.""" + if basis == "STO-3G": + return "minimal" + if "cc-pV" in basis: + return "cc" + if "def2" in basis: + return "def2" + # 3-21G and the whole 6-31G family are Pople split-valence sets. + if basis.startswith("6-31") or basis == "3-21G" or basis.startswith("6-311"): + return "pople" + return "pople" + + +# ── Card HTML ──────────────────────────────────────────────────────────────── + + +def _card_html(*, fg: str, bg: str, icon: str, title: str, body: str) -> str: + """Assemble one descriptor card (icon + title + one-line body).""" + return ( + f'
' + f'' + f"{icon}" + f'
' + f'
{title}
' + f'
{body}
' + f"
" + ) + + +def method_card_html(method: str) -> str: + """Return the descriptor-card HTML for *method*.""" + info = config.METHOD_INFO.get(method) + if not info: + fg, bg, icon = _METHOD_FALLBACK_STYLE + return _card_html(fg=fg, bg=bg, icon=icon, title=method, body="") + fg, bg, icon = _METHOD_FAMILY_STYLE.get( + info.get("type", ""), _METHOD_FALLBACK_STYLE + ) + # The label is "NAME — Descriptor…"; the part after the em dash is a tidy + # family descriptor ("DFT Hybrid Functional", "Restricted Hartree-Fock"). + label = info.get("label", method) + suffix = label.split("—", 1)[1].strip() if "—" in label else "" + title = f"{method} · {suffix}" if suffix else method + body = info.get("use_for", info.get("description", "")) + return _card_html(fg=fg, bg=bg, icon=icon, title=title, body=body) + + +def basis_card_html(basis: str) -> str: + """Return the descriptor-card HTML for *basis*.""" + fam = basis_family(basis) + fg, bg, icon = _BASIS_FAMILY_STYLE[fam] + fam_label, body = _BASIS_COPY[fam] + title = f"{basis} · {fam_label}" + return _card_html(fg=fg, bg=bg, icon=icon, title=title, body=body) diff --git a/quantui/freq_calc.py b/quantui/freq_calc.py index 02c1936..8c16f87 100644 --- a/quantui/freq_calc.py +++ b/quantui/freq_calc.py @@ -253,6 +253,12 @@ def _status(msg: str) -> None: mf.xc = resolve_xc(method) mf = maybe_apply_d3(mf, method, progress_stream=stream) + # UXP.5: cooperative cancel between SCF cycles (the Hessian block that + # follows is a single long native call the callback can't interrupt). + from .cancellation import attach_scf_cancel_callback, cancel_check_from_stream + + attach_scf_cancel_callback(mf, cancel_check_from_stream(stream)) + try: energy_hartree = float(mf.kernel()) except Exception as exc: diff --git a/quantui/nmr_calc.py b/quantui/nmr_calc.py index a9ace09..68ae7c1 100644 --- a/quantui/nmr_calc.py +++ b/quantui/nmr_calc.py @@ -360,6 +360,11 @@ def _run_nmr_calc_body( mf.xc = resolve_xc(method) mf = maybe_apply_d3(mf, method, progress_stream=stream) + # UXP.5: cooperative cancel between SCF cycles. + from .cancellation import attach_scf_cancel_callback, cancel_check_from_stream + + attach_scf_cancel_callback(mf, cancel_check_from_stream(stream)) + try: mf.kernel() except Exception as exc: diff --git a/quantui/optimizer.py b/quantui/optimizer.py index 3e048fe..897c1fe 100644 --- a/quantui/optimizer.py +++ b/quantui/optimizer.py @@ -93,6 +93,7 @@ def __init__( basis: str = "STO-3G", charge: int = 0, spin: int = 0, + cancel_check=None, **kwargs, ) -> None: super().__init__(**kwargs) @@ -100,6 +101,10 @@ def __init__( self.basis = basis self.charge = charge self.spin = spin + # UXP.5: cooperative-cancel predicate; checked per step + wired into + # the per-step SCF callback (the SCF runs silent here, so the + # stream-based cancel can't see it). + self.cancel_check = cancel_check def calculate( self, @@ -109,6 +114,15 @@ def calculate( ) -> None: super().calculate(atoms, properties, system_changes) + # UXP.5: bail before starting this step's SCF if cancel was clicked + # (fires between BFGS force evaluations, independent of ASE output). + from .cancellation import ( + attach_scf_cancel_callback, + raise_if_cancelled, + ) + + raise_if_cancelled(self.cancel_check) + import numpy as np from pyscf import dft, gto, scf @@ -152,6 +166,7 @@ def calculate( mf.verbose = 0 mf.stdout = _sink + attach_scf_cancel_callback(mf, self.cancel_check) mf.kernel() # Save final SCF state for orbital visualization @@ -381,18 +396,22 @@ def optimize_geometry( "Ensure ASE >= 3.22.0: pip install 'ase>=3.22.0'" ) from exc + _stream: IO[str] = progress_stream if progress_stream is not None else sys.stdout + _null = io.StringIO() + # --- Set up ASE Atoms + PySCF calculator --- + from .cancellation import cancel_check_from_stream, raise_if_cancelled + + _cancel_check = cancel_check_from_stream(_stream) atoms = molecule_to_atoms(molecule) atoms.calc = _QuantUIPySCFCalc( method=method, basis=basis, charge=molecule.charge, spin=molecule.multiplicity - 1, + cancel_check=_cancel_check, ) - _stream: IO[str] = progress_stream if progress_stream is not None else sys.stdout - _null = io.StringIO() - # M-STDERR / STDERR.1: PySCF gradients (called by ASE-BFGS at every # step) emit fd-2 stderr from libcint / BLAS. Wrap the full BFGS run # in capture_c_stderr so those bytes go to _stream instead of the red- @@ -410,6 +429,10 @@ def optimize_geometry( trajectory=str(traj_path), logfile=_stream, # BFGS step table → progress_stream ) + # UXP.5: check cancel after every BFGS step (belt-and-suspenders + # with the per-step calculator check above). + if _cancel_check is not None: + dyn.attach(lambda: raise_if_cancelled(_cancel_check), interval=1) with capture_c_stderr(_stream), contextlib.redirect_stdout(_null): converged = bool(dyn.run(fmax=fmax, steps=steps)) diff --git a/quantui/pes_scan.py b/quantui/pes_scan.py index d6db109..92feeba 100644 --- a/quantui/pes_scan.py +++ b/quantui/pes_scan.py @@ -290,6 +290,13 @@ def run_pes_scan( _stream: IO[str] = progress_stream if progress_stream is not None else sys.stdout _null = io.StringIO() + # UXP.5: cooperative cancel — checked between scan points, per BFGS step, + # and inside each point's SCF (via the shared calculator). + from .cancellation import cancel_check_from_stream, raise_if_cancelled + + _cancel_check = cancel_check_from_stream(_stream) + atoms.calc.cancel_check = _cancel_check + import numpy as np scan_values = np.linspace(start, stop, steps).tolist() @@ -310,6 +317,7 @@ def run_pes_scan( i4 = atom_indices[3] if len(atom_indices) >= 4 else 0 for step_num, val in enumerate(scan_values, start=1): + raise_if_cancelled(_cancel_check) _stream.write( f"\nScan point {step_num}/{steps}: " f"{scan_type} = {val:.4f} {('Å' if scan_type == 'bond' else '°')}\n" @@ -351,6 +359,8 @@ def run_pes_scan( atoms.set_constraint(constraint) dyn = BFGS(atoms, logfile=_stream) + if _cancel_check is not None: + dyn.attach(lambda: raise_if_cancelled(_cancel_check), interval=1) # M-STDERR / STDERR.1: capture fd-2 stderr from PySCF C # extensions for the duration of this scan-point optimisation. from quantui.c_stderr import capture_c_stderr diff --git a/quantui/session_calc.py b/quantui/session_calc.py index d3fb065..3e91e3e 100644 --- a/quantui/session_calc.py +++ b/quantui/session_calc.py @@ -393,6 +393,15 @@ def _run_session_calc_body( except Exception: # noqa: BLE001 — cleanup (progress stream may be closed) pass + # --- Cooperative cancellation (UXP.5) --- + # Attach the run's cancel predicate (carried on the progress stream) to + # 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 + + _cancel_check = cancel_check_from_stream(stream) + attach_scf_cancel_callback(mf, _cancel_check) + # --- Run SCF --- try: energy_hartree = float(mf.kernel()) diff --git a/quantui/tddft_calc.py b/quantui/tddft_calc.py index c344604..f0f1eae 100644 --- a/quantui/tddft_calc.py +++ b/quantui/tddft_calc.py @@ -227,6 +227,11 @@ def _run_tddft_calc_body( except Exception: # noqa: BLE001 — cleanup (stream may be closed) pass + # UXP.5: cooperative cancel between SCF cycles. + from .cancellation import attach_scf_cancel_callback, cancel_check_from_stream + + attach_scf_cancel_callback(mf, cancel_check_from_stream(stream)) + try: energy_hartree = float(mf.kernel()) except Exception as exc: diff --git a/tests/test_app.py b/tests/test_app.py index c17e403..489d31e 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -706,48 +706,36 @@ def test_script_filename_sanitizes_basis_with_asterisk(self, tmp_path, monkeypat assert "Error" not in app.export_status.value -class TestUpdateNotesBoldRendering: - """_update_notes converts every **bold** span, not just the first. - - Regression (M12 audit fix, 2026-07-14): the old implementation was - ``notes.replace("**", "", 1).replace("**", "", 1)`` — string - .replace(..., 1) only touches the FIRST occurrence in the whole - string, so only the first "**bold**" pair converted; every later one - (get_educational_notes() typically returns 2-3 separate - "**Label**: description" paragraphs) kept its literal "**" markers - and leaked into the rendered panel instead of rendering bold. - """ - - def test_multiple_bold_spans_all_converted(self, monkeypatch): - # UHF + 6-31G* + multiplicity 2 -> 3 separate "**Label**" spans - # in get_educational_notes()'s output. - # - # `with app.notes_output: display(HTML(...))` only populates the - # widget's `.outputs` under a live IPython display hook, which - # isn't present in a plain pytest process — so this test captures - # what's passed to `display()` directly instead of inspecting - # `notes_output.outputs` (the pattern used by tests of the - # newer `_set_html_output` atomic-swap helper, which manipulates - # `.outputs` directly and doesn't have this limitation). - import quantui.app_runflow as app_runflow +class TestDescriptorCards: + """UXP.7: method / basis descriptor cards replace the inline notes block. - captured: list = [] - monkeypatch.setattr(app_runflow, "display", lambda obj: captured.append(obj)) + ``_update_notes`` now sets the ``_method_card_html`` / ``_basis_card_html`` + widget values (icon + one-line) instead of rendering a markdown block into + an Output — and does so independently of whether a molecule is loaded. + """ + def test_cards_populated_at_construction(self): app = QuantUIApp() - mol = Molecule(["O", "H"], [[0.0, 0.0, 0.0], [0.96, 0.0, 0.0]], multiplicity=2) - app._set_molecule(mol) - app.method_dd.value = "UHF" - app.basis_dd.value = "6-31G*" + assert "") >= 2 - assert html.count("") == html.count("") + def test_cards_reflect_dropdown_change(self): + app = QuantUIApp() + app.method_dd.value = "MP2" + assert "MP2" in app._method_card_html.value + app.basis_dd.value = "def2-SVP" + assert "def2-SVP" in app._basis_card_html.value class TestExportMoleculeAndLabel: @@ -2444,14 +2432,30 @@ def test_orb_export_controls_exist(self): assert isinstance(app._orb_export_fmt_dd, widgets.Dropdown) assert app._orb_export_fmt_dd.value == "html" - def test_orb_toggle_has_four_options(self): + def test_orb_toggle_has_preset_options(self): + # UXP.4: the four HOMO/LUMO presets plus a free-entry "By index" mode. app = QuantUIApp() - assert set(app._orb_toggle.options) == {"HOMO-1", "HOMO", "LUMO", "LUMO+1"} + assert set(app._orb_toggle.options) == { + "HOMO-1", + "HOMO", + "LUMO", + "LUMO+1", + "By index", + } def test_orb_toggle_default_homo(self): app = QuantUIApp() assert app._orb_toggle.value == "HOMO" + def test_orb_index_input_hidden_until_by_index(self): + # UXP.4: the arbitrary MO-index input is revealed only in "By index". + app = QuantUIApp() + assert app._orb_index_input.layout.display == "none" + app._orb_toggle.value = "By index" + assert app._orb_index_input.layout.display == "" + app._orb_toggle.value = "HOMO" + assert app._orb_index_input.layout.display == "none" + def test_orb_iso_controls_hidden_initially(self): app = QuantUIApp() assert app._orb_iso_controls.layout.display == "none" diff --git a/tests/test_cancel_and_clear_guard.py b/tests/test_cancel_and_clear_guard.py index 6bb7ce3..4f503a3 100644 --- a/tests/test_cancel_and_clear_guard.py +++ b/tests/test_cancel_and_clear_guard.py @@ -17,6 +17,71 @@ from quantui.app import _CalcCancelled, _LogCapture from quantui.app_runflow import on_clear_log +from quantui.cancellation import ( + CalcCancelled, + attach_scf_cancel_callback, + cancel_check_from_stream, + raise_if_cancelled, +) + +# ── UXP.5 cancel hooks (SCF callback + optimizer observer plumbing) ────────── + + +class TestCancellationHooks: + def test_shared_class_is_app_class(self): + # app._CalcCancelled must be the SAME class the calc modules raise, or + # _do_run's ``except _CalcCancelled`` won't catch a hook-raised cancel. + assert _CalcCancelled is CalcCancelled + assert issubclass(CalcCancelled, BaseException) + assert not issubclass(CalcCancelled, Exception) + + def test_raise_if_cancelled_raises_when_true(self): + with pytest.raises(CalcCancelled): + raise_if_cancelled(lambda: True) + + def test_raise_if_cancelled_noop_when_false_or_none(self): + raise_if_cancelled(lambda: False) # no raise + raise_if_cancelled(None) # no raise + + def test_cancel_check_extracted_from_logcapture(self): + out = widgets.Output() + cap = _LogCapture(out, cancel_check=lambda: True) + cc = cancel_check_from_stream(cap) + assert callable(cc) and cc() is True + + def test_cancel_check_none_when_stream_has_no_predicate(self): + import io + + assert cancel_check_from_stream(io.StringIO()) is None + + def test_attach_scf_callback_raises_on_cancel(self): + # Fake PySCF SCF object: attach sets a .callback PySCF would invoke + # once per cycle. With the flag set, invoking it raises CalcCancelled. + class _FakeMF: + callback = None + + mf = _FakeMF() + attach_scf_cancel_callback(mf, lambda: True) + assert callable(mf.callback) + with pytest.raises(CalcCancelled): + mf.callback({"cycle": 1}) + + def test_attach_scf_callback_noop_when_not_cancelled(self): + class _FakeMF: + callback = None + + mf = _FakeMF() + attach_scf_cancel_callback(mf, lambda: False) + mf.callback({"cycle": 1}) # must not raise + + def test_attach_scf_callback_noop_when_check_none(self): + class _FakeMF: + callback = None + + mf = _FakeMF() + attach_scf_cancel_callback(mf, None) + assert mf.callback is None + # ── _LogCapture cancellation mechanism ────────────────────────────────────── diff --git a/tests/test_descriptor_cards.py b/tests/test_descriptor_cards.py new file mode 100644 index 0000000..d4a3a02 --- /dev/null +++ b/tests/test_descriptor_cards.py @@ -0,0 +1,83 @@ +"""Tests for the UXP.7 method / basis descriptor-card builders. + +Pure string builders — no widgets, no PySCF — so they exercise the family +classification, the icon embedding, and the one-line copy directly. +""" + +from __future__ import annotations + +import pytest + +from quantui import config +from quantui.descriptor_cards import ( + basis_card_html, + basis_family, + method_card_html, +) + + +class TestBasisFamily: + @pytest.mark.parametrize( + "basis,expected", + [ + ("STO-3G", "minimal"), + ("3-21G", "pople"), + ("6-31G", "pople"), + ("6-31G*", "pople"), + ("6-31G**", "pople"), + ("cc-pVDZ", "cc"), + ("cc-pVTZ", "cc"), + ("def2-SVP", "def2"), + ("def2-TZVP", "def2"), + ], + ) + def test_family_classification(self, basis, expected): + assert basis_family(basis) == expected + + def test_every_supported_basis_has_a_family_and_card(self): + # No supported basis should fall through to a KeyError in the copy map. + for basis in config.SUPPORTED_BASIS_SETS: + html = basis_card_html(basis) + assert basis in html + assert " / CDN link. + for html in (method_card_html("PBE"), basis_card_html("cc-pVTZ")): + assert "http://" not in html + assert "https://" not in html diff --git a/tests/test_polish_file_preview.py b/tests/test_polish_file_preview.py index bf2e969..954e9c8 100644 --- a/tests/test_polish_file_preview.py +++ b/tests/test_polish_file_preview.py @@ -75,6 +75,17 @@ def test_cube_preview_shows_metadata_only(self, app, tmp_path): app._preview_file_path(p) assert "Cube file metadata" in app._files_status_html.value + def test_jsonl_preview_tails_log(self, app, tmp_path): + # UXP.1: JSONL logs are previewed by tailing the file (newest last), + # not by the generic first-200KB text dump. + p = tmp_path / "event_log.jsonl" + lines = [json.dumps({"event": "e", "n": i}) for i in range(1000)] + p.write_text("\n".join(lines) + "\n", encoding="utf-8") + app._preview_file_path(p) + status = app._files_status_html.value + assert "Log preview" in status + assert "tail" in status.lower() + def test_text_fallback_for_unknown_extension(self, app, tmp_path): p = tmp_path / "notes.txt" p.write_text("line one\nline two\n", encoding="utf-8") @@ -133,6 +144,26 @@ def test_selecting_folder_does_not_preview(self, app, tmp_path): assert "CSV preview" not in status +class TestLogDirRoot: + """UXP.1: the app's log dir is an allowed Files-tab root.""" + + def test_log_dir_in_allowed_roots(self, tmp_path, monkeypatch): + logs = tmp_path / "logs" + logs.mkdir() + monkeypatch.setenv("QUANTUI_LOG_DIR", str(logs)) + monkeypatch.setenv("QUANTUI_RESULTS_DIR", str(tmp_path / "results")) + a = QuantUIApp() + roots = a._files_allowed_roots() + assert logs.resolve() in roots + + def test_log_dir_root_label_is_logs(self, tmp_path, monkeypatch): + logs = tmp_path / "logs" + logs.mkdir() + monkeypatch.setenv("QUANTUI_LOG_DIR", str(logs)) + a = QuantUIApp() + assert a._format_files_root_label(logs.resolve()).startswith("Logs") + + class TestFilePreviewSafety: def test_path_outside_allowed_roots_rejected(self, app, tmp_path, monkeypatch): # Tighten allowed roots to a subdirectory; a sibling file must