diff --git a/quantui/analytics.py b/quantui/analytics.py index 99318eb..9c18b8c 100644 --- a/quantui/analytics.py +++ b/quantui/analytics.py @@ -19,7 +19,7 @@ compute device (CPU grey, GPU green), so a user can spot regressions or speedups visually as they run more calcs. -Older perf-log records that pre-date the M-GPU follow-up don't have +Older perf-log records that pre-date GPU support don't have ``gpu_used`` set — those are treated as "device unknown" and counted in their own bucket rather than guessed CPU. @@ -46,7 +46,7 @@ def _classify_device(record: dict) -> str: """Return ``"GPU"``, ``"CPU"``, or ``"Unknown"`` for one perf record. - Records written before the M-GPU follow-up (2026-05-25) don't have + Records written before GPU support (2026-05-25) don't have ``gpu_used`` at all — we don't backfill those as CPU because they pre-date GPU support entirely, so calling them "CPU" would muddy any speedup comparison. ``"Unknown"`` is the honest bucket. @@ -353,7 +353,7 @@ def _timeline_html(records: list[dict], *, include_plotlyjs: bool) -> Optional[s # --------------------------------------------------------------------------- -# Prediction-accuracy section (M-EST / EST.6, 2026-05-25) +# Prediction-accuracy section (2026-05-25) # --------------------------------------------------------------------------- @@ -555,7 +555,7 @@ def build_dashboard(out_path: Optional[Path] = None) -> Optional[Path]: method_counts = _counts_by(records, "method") calc_type_counts = _counts_by(records, "calc_type") - # M-EST / EST.6: prediction-accuracy data lives in its own log file. + # Prediction-accuracy data lives in its own log file. # Best-effort read — older installs without the file produce an # empty list and the section degrades to an empty-state message. try: diff --git a/quantui/app.py b/quantui/app.py index 882dbe9..9427f92 100644 --- a/quantui/app.py +++ b/quantui/app.py @@ -511,7 +511,7 @@ # Provider key → short, accurate label for the loaded-molecule card. Replaces # the old hard-coded "PubChem: " which mislabeled offline/library/SMILES -# hits (manual finding #1b, 2026-06-15). +# hits (2026-06-15). _STRUCT_SOURCE_PREFIX = { "pubchem": "PubChem", "cactus": "NCI CACTUS", @@ -614,7 +614,7 @@ def _load_last_calibration_label() -> str: background: transparent !important; } -/* 3D molecule-viewer frames (UXP.2) — a subtle bounding box so the viewer +/* 3D molecule-viewer frames — 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 { @@ -629,7 +629,7 @@ def _load_last_calibration_label() -> str: border-color: transparent !important; } -/* Inline "calculating" spinner (UXP.3) — shown next to slow on-demand +/* Inline "calculating" spinner — 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 { @@ -677,7 +677,7 @@ def _layout(**kwargs: Any) -> widgets.Layout: # ``_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 +# observers 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. @@ -702,6 +702,9 @@ def __init__( # 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 + # 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 def write(self, text: str) -> None: if not text: @@ -741,7 +744,7 @@ def write(self, text: str) -> None: 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 + 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. @@ -752,6 +755,21 @@ def set_status(self, message: str) -> None: except Exception: pass + def set_progress_fraction(self, fraction: float) -> None: + """Record a completion fraction (0..1) for the live remaining-time chip. + + Calc modules call this via ``log_utils.emit_progress`` + when they know a real completion fraction (PES points, optimizer fmax + trend). The elapsed ticker reads it off the active log and prefers a + self-correcting ``elapsed·(1−f)/f`` estimate over the static total. + """ + try: + f = float(fraction) + except (TypeError, ValueError): + return + # Clamp below 1.0 so the chip never claims "0s left" while work remains. + self._fraction = max(0.0, min(f, 0.999)) + def flush(self) -> None: pass @@ -767,7 +785,7 @@ def close(self) -> None: ase==3.26.0 (the newest version pip resolves for Python 3.9) hits the stricter ``openfile()`` and raises ``TypeError: expected str, bytes or os.PathLike object`` — a real - Python-3.9-specific compatibility gap the L6 audit fix's CI matrix + Python-3.9-specific compatibility gap the CI matrix expansion caught. """ @@ -1066,7 +1084,7 @@ def __init__(self) -> None: self._last_orb_mo_occ: Any = None self._last_orb_mol_atom: Any = None self._last_orb_mol_basis: Any = None - # Last-generated cube file path + orbital label (M-EXPORT / EXPORT.5). + # Last-generated cube file path + orbital label. # Set by the isosurface render path; consumed by the Export cube # button. Initialized here so the button handler reads ``None`` # cleanly when no isosurface has been generated yet. @@ -1085,12 +1103,15 @@ 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. + # 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 + # 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" + # The active run's _LogCapture, so the ticker can read the + # completion fraction calc modules report onto it. None between runs. + self._active_log: Optional[_LogCapture] = None # 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 @@ -1107,7 +1128,7 @@ def __init__(self) -> None: # User settings (persisted in ~/.quantui/settings.json) + viz # backend availability snapshot. The router consumes these; render - # call sites will be migrated to the router in VIZBACK.4 ff. + # call sites will be migrated to the router. self._user_settings: UserSettings = UserSettings.load() self._viz_availability: BackendAvailability = ( BackendAvailability.from_environment() @@ -1136,7 +1157,7 @@ def __init__(self) -> None: self._assemble_tabs() # Log startup, but never let optional logging I/O break app startup. - # Include the loaded viz backend preference so a "it reset" report (#4a) + # Include the loaded viz backend preference so a "it reset" report # can be confirmed against what was actually persisted. try: _calc_log.log_event( @@ -1168,7 +1189,7 @@ 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). + # in on_run_clicked stays instant on the first calc. try: from quantui.log_utils import get_system_info @@ -1614,7 +1635,7 @@ def _assemble_tabs(self) -> None: _rtp.insert(_rtp.index(self._to_analysis_btn), self.advanced_accordion) self.results_tab_panel.children = tuple(_rtp) - # POLISH.8 (M-POLISH, 2026-05-25): Log moved to be an + # Log moved to be an # Accordion inside the History tab — see build_output_tab for # the wrap. Tab indices renumbered: Files 6→5, System Settings # 7→6. Update any caller that depended on tab-index 5 being @@ -1637,7 +1658,7 @@ def _assemble_tabs(self) -> None: self.root_tab.set_title(3, "History") self.root_tab.set_title(4, "Compare") self.root_tab.set_title(5, "Files") - # POLISH.4 (M-POLISH, 2026-05-25): "Status" was ambiguous — + # "Status" was ambiguous — # status of what? "System Settings" is what the tab actually # holds (env info + calibration + GPU status + UI prefs). self.root_tab.set_title(6, "System Settings") @@ -1744,7 +1765,7 @@ def _wire_callbacks(self) -> None: self._orb_export_btn.on_click(self._on_orb_export_plot) self._pes_export_btn.on_click(self._on_pes_export_plot) self._vib_export_btn.on_click(self._on_vib_export_animation) - # M-EXPORT / EXPORT.4: per-panel CSV-to-clipboard / file buttons. + # Per-panel CSV-to-clipboard / file buttons. self._ir_copy_data_btn.on_click(self._on_ir_copy_data) self._uv_copy_data_btn.on_click(self._on_uv_copy_data) self._orb_copy_data_btn.on_click(self._on_orb_copy_data) @@ -1826,11 +1847,11 @@ 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. + # 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 + # 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) @@ -1843,7 +1864,7 @@ 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 + # 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: @@ -2102,7 +2123,7 @@ def _preview_file_path(self, path: Path) -> None: self._set_files_status(f"Previewing SVG: {path.name}") return - # POLISH.5 (M-POLISH, 2026-05-25): specialized previews for + # Specialized previews for # extensions where the generic text dump is unhelpful. Each # handler caps file reads at 256 KB. On any exception inside a # handler, fall through to the generic text dispatch below so @@ -2166,7 +2187,7 @@ def _preview_file_path(self, path: Path) -> None: pass if suffix == ".jsonl": - # UXP.1: JSONL logs (event_log.jsonl) grow append-only, so the + # 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. @@ -2490,8 +2511,8 @@ def _set_html_output(self, out: widgets.Output, html: str) -> None: The assignment is a single ``out.outputs = (display_data,)`` rather than ``clear_output() + append_display_data()`` so the browser never observes an intermediate empty state. This eliminates the flicker - users were seeing on IR Stick/Broadened toggle and FWHM slider drag - (BUG.9) and matches the atomic-swap pattern already used by + users were seeing on IR Stick/Broadened toggle and FWHM slider drag, + and matches the atomic-swap pattern already used by ``_swap_frame_out`` (trajectory) and ``_swap_vib_output`` (vib). """ if threading.current_thread() is not threading.main_thread(): @@ -2511,20 +2532,20 @@ def _refresh_calc_mol_viewer(self, *, backend: Optional[str] = None) -> None: """Re-render the Calculate-tab molecule viewer via an atomic HTML swap. Replaces the ``with self.viz_output: display_molecule(...)`` pattern - that surfaced BUG B1/B2/B3 (2026-05-25 user report): + that surfaced three viewer issues (2026-05-25): - - **B1** "viewer doesn't update on PubChem load until I toggle the + - "viewer doesn't update on PubChem load until I toggle the backend" — the Output-context render path was racing the kernel's comms flush, so the initial display was sometimes never emitted. Atomic ``outputs = (display_data,)`` is a single synchronous assignment that the front-end always picks up. - - **B3** "red log lines around the viewer on the Calculate tab" — + - "red log lines around the viewer on the Calculate tab" — ``with self.viz_output:`` captured every ``logger.info`` / ``logger.error`` line that ``display_molecule`` emitted while it ran. ``render_molecule_html`` returns the HTML string OUTSIDE any Output context, so the only thing that lands in the widget is the viewer itself. - - **B2** "PlotlyMol valence error spills as red text" — the same + - "PlotlyMol valence error spills as red text" — the same helper wraps render failures into an inline error
, so plotlymol's RDKit-bond-perception failure on aromatic systems shows up as a friendly inline message instead of a logger.error @@ -2646,7 +2667,7 @@ def _set_viz_preference(self, new_pref: str, *, persist: bool) -> None: ``new_pref`` must be one of "auto" | "py3dmol" | "plotlymol". All three widgets (Settings dropdown + Calculate/Analysis toggles) edit the same single global preference, so every user-initiated change passes - ``persist=True`` (#4a — a backend choice must survive the session). + ``persist=True`` (a backend choice must survive the session). ``persist=False`` remains available for programmatic syncs that must not write settings. @@ -2755,7 +2776,7 @@ def _on_viz_backend_changed(self, change) -> None: Previously ``persist=False`` (session-only). That surprised users: the toggle visibly updated every view + the Settings dropdown, but the - choice silently reverted next session (manual finding #4a, 2026-06-15). + choice silently reverted next session (2026-06-15). All three widgets edit the one global preference, so any user-initiated change now persists. """ @@ -2764,7 +2785,7 @@ def _on_viz_backend_changed(self, change) -> None: self._set_viz_preference(change["new"], persist=True) def _on_viz_backend_changed_ana(self, change) -> None: - """Analysis-tab toggle observer — persists the chosen backend (see #4a).""" + """Analysis-tab toggle observer — persists the chosen backend.""" if self._viz_sync_in_progress: return self._set_viz_preference(change["new"], persist=True) @@ -2873,14 +2894,14 @@ def _apply_pubchem_search_result( source: Optional[str] = None, ) -> None: # Runs on the main loop. Terminal point of a search → end the activity - # indicator started in the search handler (#2). Best-effort + idempotent + # indicator started in the search handler. Best-effort + idempotent # via the counter, so an unbalanced begin can't pin the light "busy". try: self._activity_end(kind="ui") except Exception: pass if error is None and mol is not None: - # Label by where the structure ACTUALLY came from (#1b) — not always + # Label by where the structure ACTUALLY came from — not always # "PubChem". Offline FALLBACK means the network was tried + failed, # so surface a no-network note; a plain library hit is not an error. prefix = _STRUCT_SOURCE_PREFIX.get(source or "", "Structure") @@ -2952,7 +2973,7 @@ def _show_pubchem_candidates(self, query: str, candidates: list) -> None: ) self.pubchem_btn.disabled = False # Terminal point of the search phase (awaiting the user's pick) → end - # the activity indicator started in _on_search_pubchem (#2). + # the activity indicator started in _on_search_pubchem. try: self._activity_end(kind="ui") except Exception: @@ -2988,7 +3009,7 @@ def _on_search_pubchem(self, btn) -> None: self.pubchem_btn.disabled = True # Light the toolbar activity indicator so the resolver chain # (PubChem → CACTUS → library, up to ~8 s on a CACTUS timeout) doesn't - # look like a hang (#2). Ended at every terminal point below. + # look like a hang. Ended at every terminal point below. try: self._activity_begin(f'Searching structures for "{query}"…', kind="ui") except Exception: @@ -3078,7 +3099,7 @@ def _on_cancel(self, btn=None) -> None: self.cancel_btn.disabled = True 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 + # 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( @@ -3323,7 +3344,6 @@ def _fig_to_csv(fig: Any, *, title: str = "") -> str: see (e.g.) Stick + Broadened spectra in one file. Returns the empty string if the figure has no extractable data — caller treats that as "nothing to copy" rather than writing an empty file. - (M-EXPORT / EXPORT.4) """ if fig is None: return "" @@ -3363,7 +3383,6 @@ def _copy_plot_data( a secure context + user-gesture in some browsers; failures are invisible by design). Status widget surfaces the saved path so the user can find the file even when clipboard is unavailable. - (M-EXPORT / EXPORT.4) """ if fig is None: status_widget.value = ( @@ -3522,7 +3541,7 @@ def _history_load_results( self, data: dict, result_dir: Path, *, source_btns: tuple = () ) -> None: # Activity indicator + button-disable feedback are handled inside the - # inner ``history_load_results`` helper now (HIST.1). The wrapper just + # inner ``history_load_results`` helper now. The wrapper just # forwards source_btns and refreshes the Files tab after the load. try: _hist_history_load_results(self, data, result_dir, source_btns=source_btns) @@ -3709,10 +3728,10 @@ def _set_molecule(self, mol: Molecule, label: str = "") -> None: if mol.multiplicity > 1 and self.method_dd.value == "RHF": self.method_dd.value = "UHF" - # BUG B1/B2/B3 (2026-05-25): route through ``_refresh_calc_mol_viewer`` + # Route through ``_refresh_calc_mol_viewer`` (2026-05-25) # so the viewer renders via an atomic outputs swap rather than the - # ``with self.viz_output: display(...)`` pattern that the BUG.7 fix - # already replaced for the Analysis tab. The molecule attribute on + # ``with self.viz_output: display(...)`` pattern already replaced + # for the Analysis tab. The molecule attribute on # the app was set just above; the helper reads it. self._refresh_calc_mol_viewer() @@ -4062,12 +4081,12 @@ 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. + # Start the live elapsed-time ticker. self._start_elapsed_ticker(_run_wall_t) _scf_converged_t: Optional[float] = None _tail_marks: dict[str, float] = {} - # M-EST / EST.6 (2026-05-25): capture the estimator's pre-run + # Capture the estimator's pre-run (2026-05-25) # prediction so we can write a (predicted, actual) record to # ``prediction_log.jsonl`` after the calc completes. The # estimator may return None (insufficient history); we record @@ -4120,7 +4139,7 @@ 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 + # 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 @@ -4181,6 +4200,9 @@ def _run_required_final_single_point(target_mol, reason: str): on_scf_converged=_on_scf_converged, cancel_check=self._cancel_event.is_set, ) + # 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 # The run header (structured banner) is written synchronously + atomically # on the main thread by ``on_run_clicked`` → ``_write_run_header`` BEFORE @@ -4216,7 +4238,7 @@ def _run_required_final_single_point(target_mol, reason: str): ): from quantui import optimize_geometry - # POLISH.9 (M-POLISH, 2026-05-25): rename user-facing + # Rename user-facing # "Pre-optimisation" → "Geometry optimization". The # wrapped operation is the full DFT geom-opt at the # user's selected method/basis — same code path as the @@ -4227,10 +4249,9 @@ def _run_required_final_single_point(target_mol, reason: str): f"\n── Geometry optimization (before {ct}) " f"────────────────────────────\n" ) - # BUG C (2026-05-25): catch numerical failures (e.g. - # singular matrix in cho_solve on tight rings) and fall - # back to the user's input geometry rather than killing - # the whole calc. + # Catch numerical failures (e.g. singular matrix in + # cho_solve on tight rings) and fall back to the user's + # input geometry rather than killing the whole calc. try: _pre_opt = optimize_geometry( molecule=calc_mol, @@ -4267,6 +4288,11 @@ def _run_required_final_single_point(target_mol, reason: str): self.run_status.value = "Optimizing geometry..." from quantui import optimize_geometry + # History-based expected step count → "step k/~N" + a floor + # for the live progress fraction. None on cold history. + _expected_steps = _calc_log.estimate_opt_steps( + self.method_dd.value, self.basis_dd.value + ) result = optimize_geometry( molecule=calc_mol, method=self.method_dd.value, @@ -4274,6 +4300,9 @@ def _run_required_final_single_point(target_mol, reason: str): fmax=self.fmax_fi.value, steps=self.max_steps_si.value, progress_stream=log, # type: ignore[arg-type] + expected_steps=( + int(round(_expected_steps)) if _expected_steps else None + ), ) _sp_result = _run_required_final_single_point( result.molecule, @@ -4327,13 +4356,13 @@ def _run_required_final_single_point(target_mol, reason: str): # ── Step 2: optional geometry optimization ──────────────────── # - # POLISH.9 (M-POLISH, 2026-05-25): renamed from + # Renamed from # "pre-optimisation" — the wrapped operation is a full # DFT geometry optimization at the user's selected # method/basis. The LJ-classical pre-opt is in # quantui/preopt.py and keeps its "pre-opt" name. # - # BUG C (2026-05-25): geom-opt can hit a singular matrix + # Geom-opt can hit a singular matrix # in PySCF's ``cho_solve`` on tight rings (e.g. aromatic # benzene with B3LYP/6-31G). That raises out of the # optimizer and used to kill the whole calc. Wrap it: on @@ -4435,7 +4464,7 @@ def _run_required_final_single_point(target_mol, reason: str): ) # ── Step 2: optional geometry optimization ──────────────────── - # POLISH.9 (M-POLISH, 2026-05-25): renamed from + # Renamed from # "pre-optimisation" — DFT geom-opt is just geom-opt. if self._freq_preopt_cb.value: from quantui import optimize_geometry @@ -4447,9 +4476,9 @@ def _run_required_final_single_point(target_mol, reason: str): "\n── Geometry optimization (before UV-Vis (TD-DFT)) " "─────────────\n" ) - # BUG C (2026-05-25): catch numerical failures and - # fall back to the user's seed geometry rather than - # killing the whole TD-DFT calc. + # Catch numerical failures and fall back to the + # user's seed geometry rather than killing the whole + # TD-DFT calc. try: _pre_opt = optimize_geometry( molecule=calc_mol, @@ -4698,7 +4727,7 @@ def _run_required_final_single_point(target_mol, reason: str): spectra=save_spectra, ) self._last_result_dir = _saved_dir - # M-EXPORT / EXPORT.5: result folder is now on disk — + # Result folder is now on disk — # the "Export bundle (.zip)" button has something to zip. try: self._export_bundle_btn.disabled = False @@ -4718,7 +4747,7 @@ def _run_required_final_single_point(target_mol, reason: str): _e_list = getattr(result, "energies_hartree", []) if _traj: save_trajectory(_saved_dir, _traj, _e_list or []) - # M-EXPORT / EXPORT.3 + EXPORT.7: also write + # Also write # external-tool-friendly trajectory formats. # Multi-frame XYZ (any viewer) and ASE .traj # (ASE-GUI + ASE Python post-processing). Both @@ -4744,7 +4773,7 @@ def _run_required_final_single_point(target_mol, reason: str): ) except Exception: pass - # Persist pre-opt geometry trajectory for Frequency runs (DEC-007). + # Persist pre-opt geometry trajectory for Frequency runs. if ct == "Frequency" and _pre_opt is not None: _pre_traj = getattr(_pre_opt, "trajectory", None) _pre_e = list(getattr(_pre_opt, "energies_hartree", [])) @@ -4758,7 +4787,7 @@ def _run_required_final_single_point(target_mol, reason: str): # Persist MO data for orbital diagram + isosurface replay. if ct in ("Single Point", "Geometry Opt", "Frequency"): save_orbitals(_saved_dir, result) - # M-EXPORT / EXPORT.1+2: write a Molden-format companion + # Write a Molden-format companion # file so users can open results in Avogadro / IQmol / # Jmol. Best-effort — failures are swallowed by the # outer try block above and the calc still completes. @@ -4839,6 +4868,7 @@ def _run_required_final_single_point(target_mol, reason: str): calc_type=save_type, gpu_used=getattr(result, "gpu_used", None), gpu_name=getattr(result, "gpu_name", None), + n_steps=getattr(result, "n_steps", None), ) _calc_log.log_event( "calc_done", @@ -4848,7 +4878,7 @@ def _run_required_final_single_point(target_mol, reason: str): gpu_used=bool(getattr(result, "gpu_used", False)), gpu_name=getattr(result, "gpu_name", None), ) - # M-EST / EST.6: persist the (predicted, actual) pair to + # Persist the (predicted, actual) pair to # ``prediction_log.jsonl``. ``_predicted_run_s`` was # captured at the top of _do_run via the same # estimate_time(...) call that drives the UI estimate; @@ -5040,20 +5070,23 @@ def _run_required_final_single_point(target_mol, reason: str): self._stop_elapsed_ticker() self._activity_end(kind="compute") - # ── Live elapsed ticker (M-PROGRESS A1) ─────────────────────────────── + # ── Live elapsed ticker ──────────────────────────────────────────────── 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. + ``Label``/``HTML`` ``.value`` writes are thread-safe, 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. + # Reset the runtime estimate; _do_run fills it in once computed. self._run_estimate_s = None self._run_estimate_conf = "unknown" + # Drop any prior run's log so a stale fraction can't leak into the + # new run's chip before this run's _LogCapture is created. + self._active_log = None stop_event = threading.Event() self._elapsed_stop_event = stop_event @@ -5073,16 +5106,30 @@ def _tick() -> None: 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``, + 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). + "(rough)" so the readout stays honest. """ from quantui.log_utils import format_elapsed base = f"⏱ {format_elapsed(elapsed)}" + + # Prefer a self-correcting fraction-based estimate when a calc module + # reports real progress (PES points, optimizer fmax trend). Only trust it + # once past a small floor so early-run noise doesn't spike the estimate. + log = getattr(self, "_active_log", None) + frac = getattr(log, "_fraction", None) if log is not None else None + if frac is not None and frac >= 0.03 and elapsed > 0: + remaining = elapsed * (1.0 - frac) / frac + return ( + '' + f"{base} · ~{format_elapsed(remaining)} left" + ) + + # Fallback: static total estimate minus elapsed. est = getattr(self, "_run_estimate_s", None) if est and est > 0: remaining = est - elapsed @@ -5160,7 +5207,7 @@ def _wrapper(change): return _wrapper def _goto_output_tab(self) -> None: - # POLISH.8 (M-POLISH, 2026-05-25): the standalone Log tab is + # The standalone Log tab is # gone; the PySCF output log now lives in an Accordion inside # the History tab (index 3). Switch tabs + expand the log # accordion so the user lands directly on the log content. diff --git a/quantui/app_analysis.py b/quantui/app_analysis.py index ce7016c..e1033b6 100644 --- a/quantui/app_analysis.py +++ b/quantui/app_analysis.py @@ -328,7 +328,7 @@ def pop_preopt_trajectory(app: Any, ctx: Any) -> bool: """Populate Trajectory panel for the frequency-time DFT geometry optimization trajectory. - POLISH.9 (2026-05-25): the wrapped operation is a full DFT geom-opt + The wrapped operation is a full DFT geom-opt at the user's method/basis, not the classical LJ pre-opt that lives in ``quantui/preopt.py``. The function name + ``preopt_trajectory.json`` filename stay (renaming the saved file would break history replay of diff --git a/quantui/app_builders.py b/quantui/app_builders.py index 3fdc1f4..28ac254 100644 --- a/quantui/app_builders.py +++ b/quantui/app_builders.py @@ -96,7 +96,7 @@ def _ok(flag: bool, extra: str = "") -> str: "Timing calibration: not yet run — use the Calibrate panel in History
" ) - # GPU offload indicator (M-GPU / GPU.2). Detecting GPUs imports gpu4pyscf + + # GPU offload indicator. Detecting GPUs imports gpu4pyscf + # cupy and queries CUDA, which costs ~7 s — far too slow to block startup. # So the status HTML is rendered via this closure with the GPU row as a # "checking…" placeholder; the app re-renders it (via app._render_status_html) @@ -259,7 +259,7 @@ def build_history_section( tooltip="Open the full PySCF output log in the Output tab", ) - # M-EST / EST.4: 4-tier calibration selector. ToggleButtons works for + # 4-tier calibration selector. ToggleButtons works for # 4 options; switch to a Dropdown if a 5th tier is ever added. Tier 3 # / tier 4 require PySCF (the geom-opt + freq dispatch); tier 1 / 2 # are SP-only and gated separately by the run button. @@ -295,10 +295,9 @@ def build_history_section( tooltip="Abandon the rest of the calibration (current step is also killed).", layout=layout_fn(width="90px", display="none"), ) - # session 55 user request: replaced the hard 1800 s per-step timeout - # with a Skip button so the user can abandon ONE step that's running - # too long without losing the whole run. Distinct from Stop (which - # abandons everything remaining). + # Replaced the hard 1800 s per-step timeout with a Skip button so the + # user can abandon ONE step that's running too long without losing the + # whole run. Distinct from Stop (which abandons everything remaining). app._cal_skip_btn = widgets.Button( description="Skip step", button_style="info", @@ -391,7 +390,7 @@ def build_history_section( if cal_last else "" ) - # M-EST / EST.4: import tier sizes lazily so we can refer to all four + # Import tier sizes lazily so we can refer to all four # in the panel blurb. ``benchmark_suite`` / ``benchmark_suite_long`` # are kept as positional args for back-compat but new code prefers # the four named tiers. @@ -431,10 +430,9 @@ def build_history_section( app._cal_accordion = widgets.Accordion(children=[cal_panel], selected_index=None) app._cal_accordion.set_title(0, "Calibrate time estimates") - # POLISH.3 (M-POLISH, 2026-05-25): the History tab is now purely - # the result-browser. Performance stats + Calibrate accordions - # moved to the System Settings tab — see below — so the user finds - # benchmarking + system state in one logical place. + # The History tab is now purely the result-browser. Performance stats + # + Calibrate accordions moved to the System Settings tab — see below — + # so the user finds benchmarking + system state in one logical place. app.history_panel = widgets.VBox( [ widgets.HTML( @@ -455,7 +453,7 @@ def build_history_section( ] ) - # POLISH.3: now that the calibration + performance accordions exist + # Now that the calibration + performance accordions exist # (created above in this function), append them to the System # Settings tab. ``_status_tab_panel`` was built earlier in # ``build_status_panel`` without these — extend its children tuple. @@ -503,7 +501,7 @@ def build_shared_widgets( app.mol_summary_compact = widgets.HTML(value="") # Fixed heights reserve space so swapping content (backend/palette toggle) # or streaming output never resizes the container — which would reflow the - # page and jump the scrollbar (BUG-SCROLL). The molecule viewer renders at + # page and jump the scrollbar. The molecule viewer renders at # render_molecule_html's default 500px; the run log scrolls internally. # overflow hidden (not auto): the 3D viewer is a fixed-size canvas, so it # needs no scrollbar — clipping a few px of margin avoids an internal @@ -570,7 +568,7 @@ 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"), ) - # UXP.7: method / basis descriptor cards replace the old inline + # 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. @@ -644,7 +642,7 @@ def build_shared_widgets( ' — fast MMFF/UFF ' "cleanup of a rough structure" ) - # Interactive pre-opt (M-PREOPT PREOPT.2/.3): run the bonded-FF pre-opt on + # Interactive pre-opt: run the bonded-FF pre-opt on # demand, watch it relax in-place, then keep or revert. app.preopt_preview_btn = widgets.Button( description="Preview", @@ -696,7 +694,7 @@ def build_shared_widgets( from quantui.config import SOLVENT_OPTIONS as _SOLVENT_OPTS - # POLISH.10: drop the default Checkbox description gutter + explicit width + # Drop the default Checkbox description gutter + explicit width # (style description_width "initial" + indent=False) that produced the # indent + horizontal scrollbar. app.solvent_cb = widgets.Checkbox( @@ -921,7 +919,7 @@ 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 + # 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="") @@ -990,7 +988,7 @@ def build_shared_widgets( layout=layout_fn(width="130px"), ) app.struct_export_status = widgets.Label() - # M-EXPORT / EXPORT.5: zip the entire result folder for emailing / + # Zip the entire result folder for emailing / # attaching to a writeup. Disabled until ``_last_result_dir`` is set. app._export_bundle_btn = widgets.Button( description="Export bundle (.zip)", @@ -1031,7 +1029,7 @@ def build_theme_selector(app: Any, *, layout_fn: Any) -> None: def build_welcome_header(app: Any, *, layout_fn: Any = None) -> None: """Build the QuantUI welcome banner. - POLISH.1 third iteration (M-POLISH, 2026-05-25): the + Third iteration (2026-05-25): the ```` approach failed too — Voilà's HTML sanitizer (stricter than JupyterLab's) strips ``data:`` URIs from ```` attributes. The third iteration @@ -1317,7 +1315,7 @@ 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 + # Descriptor cards — to the right of the inputs so # they don't displace charge/multiplicity. app._descriptor_cards_box, ], @@ -1404,7 +1402,7 @@ def _plot_export_row(prefix: str) -> widgets.HBox: layout=layout_fn(width="130px"), tooltip="Export the current plot as HTML or PNG", ) - # M-EXPORT / EXPORT.4: per-panel "Copy data" button that exports + # Per-panel "Copy data" button that exports # the underlying numerical data to CSV (saved to result_dir) and # also attempts to copy to the system clipboard via the browser # API. Status widget below is shared with the Save Plot path — @@ -1447,7 +1445,6 @@ def _plot_export_row(prefix: str) -> widgets.HBox: # Using `widgets.Output` here previously caused widget references inside # `with output: display(widget)` to be deferred/asynchronous, leaving the # accordion visibly empty even after _show_opt_trajectory logged success. - # See BUG-FRESH-TRAJ root-cause analysis in session 48. app.traj_output = widgets.VBox(layout=layout_fn(margin="0")) app.traj_accordion = widgets.Accordion( children=[app.traj_output], @@ -1551,7 +1548,7 @@ def _plot_export_row(prefix: str) -> widgets.HBox: style={"description_width": "120px"}, layout=layout_fn(width="300px", display="none"), # continuous_update=False so dragging the slider only fires on - # release, not 30-60 times per second during the drag (BUG.9 fix). + # release, not 30-60 times per second during the drag. # Combined with the atomic outputs swap in _set_html_output this # eliminates the IR re-render storm that caused visible flicker. continuous_update=False, @@ -1640,7 +1637,7 @@ 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 + # 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( @@ -1694,7 +1691,7 @@ def _plot_export_row(prefix: str) -> widgets.HBox: ), layout=layout_fn(width="200px", margin="8px 0 4px 0"), ) - # M-EXPORT / EXPORT.5: copy the last-generated cube to the top-level + # Copy the last-generated cube to the top-level # result dir under a friendly name (HOMO.cube / LUMO.cube / etc.). # Disabled until the first isosurface generation populates # ``app._last_cube_path``. @@ -1711,7 +1708,7 @@ 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. + # 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='', @@ -2083,8 +2080,7 @@ def build_output_tab(app: Any, *, layout_fn: Any) -> None: button_style="danger", layout=layout_fn(width="140px", display="none"), ) - # POLISH.8 (M-POLISH, 2026-05-25): the Log tab moved to be an - # Accordion inside the History tab — rationale in the roadmap. The + # The Log tab moved to be an Accordion inside the History tab. The # explanatory text no longer needs to say "Use View log in the # History tab" since the user IS in the History tab now. app.log_tab_panel = widgets.VBox( @@ -2117,7 +2113,7 @@ def build_output_tab(app: Any, *, layout_fn: Any) -> None: layout=layout_fn(padding="8px 0"), ) - # POLISH.8: wrap the log panel in an Accordion + append to the + # Wrap the log panel in an Accordion + append to the # History tab. ``history_panel`` was built in # ``build_history_section`` earlier in the app-init sequence # (see app.py: _build_history_section runs BEFORE _build_output_tab). @@ -2222,8 +2218,8 @@ def build_help_section(app: Any, *, layout_fn: Any) -> None: app.help_content_html = widgets.HTML() app._render_help_topic() - # POLISH.2 (M-POLISH, 2026-05-25): the single-character "?" was - # visually noisy and hard to recognise as the global help toggle. + # The single-character "?" was visually noisy and hard to recognise + # as the global help toggle. # Field-level "?" buttons (method_help_btn / basis_help_btn earlier # in this file) keep the symbol — for inline-with-input help it's # universally understood. diff --git a/quantui/app_exports.py b/quantui/app_exports.py index 13b81ad..4c5b945 100644 --- a/quantui/app_exports.py +++ b/quantui/app_exports.py @@ -127,7 +127,7 @@ def export_molecule_and_label(app: Any) -> tuple[Any, str, str]: def on_iso_export_cube(app: Any, btn: Any) -> None: - """Copy the last-generated cube file to the result folder (EXPORT.5). + """Copy the last-generated cube file to the result folder. Reads ``app._last_cube_path`` (set by the isosurface render path in ``app_visualization.py``) and copies it to @@ -160,7 +160,7 @@ def on_iso_export_cube(app: Any, btn: Any) -> None: def on_export_bundle(app: Any, btn: Any) -> None: - """Zip the entire result folder for sharing (EXPORT.5).""" + """Zip the entire result folder for sharing.""" from quantui.results_storage import export_result_bundle result_dir = getattr(app, "_last_result_dir", None) diff --git a/quantui/app_formatters.py b/quantui/app_formatters.py index 24224ab..459da17 100644 --- a/quantui/app_formatters.py +++ b/quantui/app_formatters.py @@ -12,7 +12,7 @@ def _result_extra_rows(get: Any) -> str: ``get(key, default=None)`` reads a field from either a result object (``getattr``) or a saved ``result.json`` dict (``dict.get``). Used by BOTH :func:`format_result` (live) and :func:`format_past_result` (history) so the - two cards can never drift again — the M-CLEAN regression where the compute + two cards can never drift again — a past regression had the compute device / dipole / Mulliken rows existed only on the live card. Rows: post-HF correlation breakdown (MP2 / CCSD / (T)), solvent, compute device (always shown), dipole moment, Mulliken charges. @@ -45,7 +45,7 @@ def _num(label: str, value: str) -> str: if _solvent is not None: rows += _num("Solvent (PCM)", str(_solvent)) - # Compute device (M-GPU / GPU.2) — always shown; old saved results lack the + # Compute device — always shown; old saved results lack the # field and safely read "CPU". if bool(get("gpu_used", False)): _name = get("gpu_name") @@ -466,7 +466,7 @@ def format_past_result(data: dict[str, Any], result_dir: Optional[Path] = None) ts = data.get("timestamp", "") # Shared 'extra' rows (correlation breakdown / solvent / device / dipole / - # Mulliken) — same builder as the live card so the two never drift (M-CLEAN). + # Mulliken) — same builder as the live card so the two never drift. _extra = _result_extra_rows(lambda k, d=None: data.get(k, d)) # Embed thumbnail if saved diff --git a/quantui/app_history.py b/quantui/app_history.py index 426a6f9..a2f510b 100644 --- a/quantui/app_history.py +++ b/quantui/app_history.py @@ -13,14 +13,14 @@ class _LoadTimer: - """Per-stage timing collector for a history-load operation (HIST.2). + """Per-stage timing collector for a history-load operation. Used as: open one ``_LoadTimer`` at the top of each loader, wrap each interesting sub-stage in ``with timer.stage("name"):``, then call ``timer.emit(status=...)`` exactly once (from the loader's ``finally`` block). One ``history_load_timing`` event is appended to ``event_log.jsonl`` per load with the total elapsed time and a per-stage - breakdown. The data drives the HIST.2 latency-optimization pass — until + breakdown. The data drives the latency-optimization pass — until we know which stage dominates, we don't know which to optimize. Failures inside ``calc_log.log_event`` are swallowed: telemetry must @@ -223,7 +223,7 @@ def mol_from_result_dir(result_dir: Path, data: dict[str, Any]) -> Any: def _begin_history_load(app: Any, message: str, source_btns: tuple) -> None: - """Show immediate feedback when a history-load action starts (HIST.1). + """Show immediate feedback when a history-load action starts. Lights the toolbar activity indicator and disables the source buttons so a second click can't fire a parallel load. Both actions are best-effort — @@ -242,7 +242,7 @@ def _begin_history_load(app: Any, message: str, source_btns: tuple) -> None: def _end_history_load(app: Any, source_btns: tuple) -> None: - """Restore UI state after a history-load action finishes (HIST.1). + """Restore UI state after a history-load action finishes. Mirrors :func:`_begin_history_load`. Called from the loader's ``finally`` block so the activity indicator + buttons are always restored, even when @@ -269,11 +269,11 @@ def history_load_results( """Display a history result card in the Results tab and navigate there. ``source_btns`` is an optional tuple of button widgets to disable while - the load is in flight (HIST.1 immediate-loading-feedback contract). Tests + the load is in flight (immediate-loading-feedback contract). Tests and callers that don't have a button reference can omit it. Stage timings are emitted as a single ``history_load_timing`` event on - completion (HIST.2 — drives latency-optimization decisions). + completion (drives latency-optimization decisions). """ _begin_history_load(app, "Loading history result…", source_btns) timer = _LoadTimer("history_load_results", result_dir) @@ -330,11 +330,11 @@ def history_load_analysis( """Load analysis panels for a history result and navigate to Analysis tab. ``source_btns`` is an optional tuple of button widgets to disable while - the load is in flight (HIST.1 immediate-loading-feedback contract). Tests + the load is in flight (immediate-loading-feedback contract). Tests and callers that don't have a button reference can omit it. Stage timings are emitted as a single ``history_load_timing`` event on - completion (HIST.2 — drives latency-optimization decisions). Stages cover + completion (drives latency-optimization decisions). Stages cover the four expected hotspots: pyscf.log read, context build, molecule reconstruction, 3D viewer render, and the analysis-context registry walk. """ diff --git a/quantui/app_runflow.py b/quantui/app_runflow.py index 2829f31..7dd4333 100644 --- a/quantui/app_runflow.py +++ b/quantui/app_runflow.py @@ -36,7 +36,7 @@ def _calc_type_badge(calc_type: str) -> str: def _write_run_header(app: Any) -> None: """Write the full run header to the live log — synchronously, atomically. - UXP.6 + bug fix (2026-07-18): the header used to be written two ways, both + 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* @@ -136,8 +136,8 @@ def on_calc_type_changed(app: Any, change: Any, *, layout_fn: Any) -> None: # The "geometry optimization before this calc" checkbox is meaningful # for all workflows except Geometry Opt itself (which IS the geom-opt - # workflow). POLISH.9: this was called "pre-optimisation" pre-2026-05-25; - # the underlying operation is a full DFT geom-opt — distinct from the + # workflow). This was previously called "pre-optimisation"; the + # underlying operation is a full DFT geom-opt — distinct from the # LJ classical pre-opt in quantui/preopt.py. # Reorganization Energy runs its own neutral + ion optimizations, so the # standalone "geometry optimization before this calc" checkbox is @@ -237,7 +237,7 @@ def update_scan_widgets(app: Any, _change: Any = None) -> None: app._scan_unit_lbl.value = '°' -# Default RMSD tolerance for the seed-geometry "same molecule" check (HIST.6). +# Default RMSD tolerance for the seed-geometry "same molecule" check. # 0.1 Å is generous enough to admit slight conformational differences (e.g. # re-importing the same SMILES, which can produce ~0.05 Å float-precision # drift in RDKit's embedding) but tight enough to reject distinct isomers, @@ -302,7 +302,7 @@ def _geometries_match( *, rmsd_tol: float = _SEED_GEOMETRY_RMSD_TOLERANCE, ) -> bool: - """Strict atom-order + RMSD-based geometry comparison (HIST.6). + """Strict atom-order + RMSD-based geometry comparison. Returns ``True`` iff the atom symbol lists are equal in order AND the structures' RMSD (no rigid alignment) is at or below ``rmsd_tol`` Å. @@ -340,7 +340,7 @@ def _refresh_seed_options(app: Any, dropdown: Any) -> None: """Populate a geo-opt seed dropdown filtered by strict atom+coord match. Shared helper used by both Frequency and UV-Vis (TD-DFT) seed dropdowns. - Filter cascade (HIST.6): + Filter cascade: 1. No active molecule → list every geo-opt result (no filter; lets the user browse history before loading anything). @@ -483,10 +483,10 @@ def _preopt_small(text: str, color: str = "#555") -> str: def on_preopt_preview(app: Any, btn: Any = None) -> None: """Run the classical pre-opt on demand and animate the relaxation in-place. - M-PREOPT PREOPT.2: instead of the pre-opt being a silent step inside the - run, the user previews it here — watches the bonded-FF relaxation animate - in the Calculate tab — then Keeps or Reverts it (PREOPT.3). The pre-opt runs - on a background thread; UI updates are marshalled back to the main thread. + Instead of the pre-opt being a silent step inside the run, the user + previews it here — watches the bonded-FF relaxation animate in the + Calculate tab — then Keeps or Reverts it. The pre-opt runs on a + background thread; UI updates are marshalled back to the main thread. """ mol = getattr(app, "_molecule", None) if mol is None: @@ -567,7 +567,7 @@ def _preopt_preview_failed(app: Any, msg: str) -> None: def on_preopt_accept(app: Any, btn: Any = None) -> None: - """Make the previewed relaxed geometry the active molecule (PREOPT.3).""" + """Make the previewed relaxed geometry the active molecule.""" relaxed = getattr(app, "_preopt_relaxed_mol", None) if relaxed is None: return @@ -844,9 +844,9 @@ def on_exit_clicked(app: Any, _unused: Any = None) -> None: app._exit_btn.description = "Exiting…" app._exit_btn.disabled = True - # POLISH.1 retry-2 (2026-05-25): the welcome logo now lives in its - # own ``widgets.Image`` next to the text. At shutdown hide the logo - # so the centered "QuantUI has shut down" message isn't off-center. + # The welcome logo now lives in its own ``widgets.Image`` next to the + # text. At shutdown hide the logo so the centered "QuantUI has shut + # down" message isn't off-center. if hasattr(app, "_welcome_logo"): try: app._welcome_logo.layout.display = "none" @@ -883,8 +883,8 @@ def on_cal_run( """Start async calibration run and initialize calibration UI state.""" _ = btn mode = app._cal_mode_toggle.value - # session 55 hotfix: the old ``"short" else "long"`` two-tier dispatch - # silently routed tier 3 / tier 4 (and tier 1!) to the tier-2 suite, + # The old ``"short" else "long"`` two-tier dispatch silently routed + # tier 3 / tier 4 (and tier 1!) to the tier-2 suite, # which set ``progress_bar.max = 20`` while tier 1 only ran 8 steps # — the bar froze at 40% on completion. Use the 4-tier lookup so # ``max`` matches the actual step count. @@ -892,8 +892,8 @@ def on_cal_run( suite = _MODE_TO_SUITE.get(mode, benchmark_suite) app._cal_stop_event = threading.Event() - # session 55 user request: skip-current-step event, separate from - # the whole-run stop event. Replaces the hard per-step timeout. + # Skip-current-step event, separate from the whole-run stop event. + # Replaces the hard per-step timeout. app._cal_skip_event = threading.Event() app._cal_run_btn.disabled = True app._cal_mode_toggle.disabled = True @@ -906,7 +906,7 @@ def on_cal_run( app._cal_step_label.value = ( 'Starting…' # Reserve a second invisible line so the live-message ticker - # doesn't jump the accordion height (session 55 user report). + # doesn't jump the accordion height. '
.' ) app._cal_results_html.value = "" @@ -924,9 +924,9 @@ def on_cal_stop(app: Any, btn: Any) -> None: def on_cal_skip(app: Any, btn: Any) -> None: """Signal the active calibration to skip the CURRENT step + continue. - Replaces the per-step timeout (session 55 user request after a - near-finishing benzene B3LYP/6-31G* freq calc got cut off at the - 1800 s tier-4 cap). The worker is killed, the step is marked + Replaces the per-step timeout (added after a near-finishing benzene + B3LYP/6-31G* freq calc got cut off at the 1800 s tier-4 cap). The + worker is killed, the step is marked ``skipped``, the event is cleared inside ``run_calibration``, and the loop moves on to the next step. """ @@ -952,7 +952,7 @@ def _cal_table_html(steps_so_far, total: int, *, in_flight_step=None) -> str: Called incrementally — after every completed step — so the user sees rows accumulate in real time instead of waiting for the whole tier - to finish (session 55 user request). ``steps_so_far`` is the list of + to finish. ``steps_so_far`` is the list of ``BenchmarkStep`` objects completed; ``in_flight_step`` (optional) is a dict ``{label, n_electrons, n_basis, status, elapsed_s}`` that appends a "running" row at the bottom while a step is mid-execution. @@ -960,8 +960,8 @@ def _cal_table_html(steps_so_far, total: int, *, in_flight_step=None) -> str: For failed steps (error / timeout / skipped) we render an inline italic line below the status cell with a truncated ``error_msg``, so the user can see WHY a step failed without having to open - ``calibration.json`` (session 55 user request after MP2/CCSD on - H₂O/cc-pVDZ silently 'errored' with no on-screen explanation). + ``calibration.json`` (added after MP2/CCSD on H₂O/cc-pVDZ silently + 'errored' with no on-screen explanation). """ import html as _html_mod @@ -1029,7 +1029,7 @@ def _err_detail(s) -> str: def do_calibration(app: Any, *, pyscf_available: bool) -> None: """Run calibration suite and render calibration summary table. - Fixes shipped 2026-05-25 (session 55 user reports): + Fixes shipped 2026-05-25: - Wraps the whole run in ``_activity_begin/_end`` so the toolbar activity badge stops reading "Idle" while calibration is busy. @@ -1049,15 +1049,15 @@ def do_calibration(app: Any, *, pyscf_available: bool) -> None: # callback; no need to compute it locally. (The earlier draft pulled # it from ``_MODE_TO_SUITE`` but never used it — ruff F841.) - # session 55 user request (after a near-finishing benzene - # B3LYP/6-31G* freq got cut off at the old 1800 s tier-4 cap): - # no automatic timeout — the user controls long-running steps via - # the Skip button. If they walk away from a runaway calc, the - # Stop button is still available. Headless callers that genuinely - # want a wall-clock cap can pass timeout_per_step explicitly. + # No automatic timeout — the user controls long-running steps via + # the Skip button (added after a near-finishing benzene B3LYP/6-31G* + # freq got cut off at the old 1800 s tier-4 cap). If they walk away + # from a runaway calc, the Stop button is still available. Headless + # callers that genuinely want a wall-clock cap can pass + # timeout_per_step explicitly. timeout_per_step: Optional[float] = None - # M-EST follow-up: keep the toolbar activity badge red for the + # Keep the toolbar activity badge red for the # duration of the calibration so the user knows the kernel is busy. app._activity_begin(f"Calibrating ({mode})…", kind="compute") @@ -1162,7 +1162,7 @@ def _progress( def update_notes(app: Any, change: Any = None) -> None: """Refresh the method / basis descriptor cards + open-shell hint. - Replaces the old inline educational-notes text block (UXP.7). The cards + 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. The open-shell hint (restored from the old notes) appears only when multiplicity > 1. @@ -1224,7 +1224,7 @@ def update_estimate(app: Any, *, calc_log_mod: Any, change: Any = None) -> None: n_basis = calc_log_mod.count_basis_functions( app._molecule.atoms, app.basis_dd.value ) - # M-EST / EST.1: predict the device the upcoming run will use so + # Predict the device the upcoming run will use so # the estimator can partition history by GPU vs CPU. The method # also matters — gpu4pyscf doesn't support CCSD(T), so even on a # GPU machine that calc will run CPU-side. @@ -1262,7 +1262,7 @@ def update_estimate(app: Any, *, calc_log_mod: Any, change: Any = None) -> None: def refresh_results_browser(app: Any) -> None: """Refresh the History dropdown with saved result directories. - POLISH.6 (M-POLISH, 2026-05-25): prepends a + Prepends a ``"(select a calculation to view)"`` placeholder so the dropdown opens in an explicit "no calc loaded yet" state. Without the placeholder, ipywidgets auto-selected the most-recent entry as the @@ -1297,10 +1297,10 @@ def refresh_results_browser(app: Any) -> None: data = load_result(d) ts = data.get("timestamp", d.name) calc_badge = _calc_type_badge(data.get("calc_type", "")) - # M-EST follow-up (2026-05-25): calibration-produced results - # get a 🔧 marker so the user can tell them apart from - # user-initiated calcs. The marker comes from result.json's - # ``calibration_run_id`` extras field written by the worker. + # Calibration-produced results get a 🔧 marker so the user + # can tell them apart from user-initiated calcs. The marker + # comes from result.json's ``calibration_run_id`` extras field + # written by the worker. calib_marker = "🔧 " if data.get("calibration_run_id") else "" label = ( f"{ts} · [{calc_badge}] " diff --git a/quantui/app_visualization.py b/quantui/app_visualization.py index f8cd497..2f0b3a4 100644 --- a/quantui/app_visualization.py +++ b/quantui/app_visualization.py @@ -79,8 +79,8 @@ def show_result_3d( ``visualization_py3dmol.render_molecule_html``); the HTML is routed through ``app._set_html_output`` so the viewer is replaced as a single atomic ``Output.outputs`` swap. This avoids the nested-Output + ``display(viz)`` - pattern that caused BUG.6 (trajectory regression) and BUG.7 (Analysis-tab - top viewer rendering blank with 🙁 on history replay). + pattern that caused a trajectory regression and an Analysis-tab top + viewer rendering blank with 🙁 on history replay. """ if render_html_fn is None or molecule is None: return @@ -595,7 +595,7 @@ def show_vib_animation(app: Any, freq_result: Any, molecule: Any) -> bool: panel populates regardless of plotlymol3d availability. The plotlymol3d `VibrationalData` wrapper is built optionally — required only when the plotlymol render path is selected; the py3Dmol render path reads - displacements directly from ``freq_result`` (VIZBACK.8). + displacements directly from ``freq_result``. """ freqs = freq_result.frequencies_cm1 if not freqs: @@ -1040,7 +1040,7 @@ 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 + # "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)}" @@ -1048,7 +1048,7 @@ def on_iso_generate(app: Any, btn: Any) -> None: render_token = app._iso_render_token btn.disabled = True btn.description = "Generating…" - # UXP.3: reveal the inline spinner + light the toolbar activity indicator + # 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: @@ -1074,7 +1074,7 @@ def on_iso_generate(app: Any, btn: Any) -> None: ) done = threading.Event() - # UXP.3: balance the single _activity_begin above exactly once, across + # Balance the single _activity_begin above exactly once, across # both the normal-completion and timeout paths (idempotent). _finished = threading.Event() @@ -1199,7 +1199,7 @@ 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. + # "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: @@ -1264,7 +1264,7 @@ def _show_range_err() -> None: # Charge/spin aren't carried on the app's orbital-state attributes — # infer them from the MO occupations so charged/open-shell molecules - # (H3O+, OH-, radicals, ...) don't fail to build in PySCF (H1 fix). + # (H3O+, OH-, radicals, ...) don't fail to build in PySCF. _charge, _spin = infer_charge_and_spin(mol_atom, mo_occ_for_charge) generate_cube_from_arrays( mol_atom, @@ -1340,7 +1340,7 @@ def _show_err(msg: str = err_msg) -> None: return if _is_stale(): return - # M-EXPORT / EXPORT.5: track the last-generated cube + its orbital + # Track the last-generated cube + its orbital # label so the "Export cube" button can copy it to the top-level # result dir with a friendly name without re-deriving the path. app._last_cube_path = cube_path @@ -1365,7 +1365,7 @@ def _show_err(msg: str = err_msg) -> None: html_str, ) - # M-EXPORT / EXPORT.5: now that ``_last_cube_path`` is populated, the + # Now that ``_last_cube_path`` is populated, the # "Export cube" button has something to copy. Enable it on the main # thread alongside the iso render swap. def _enable_cube_btn() -> None: @@ -1571,7 +1571,7 @@ def _render_vib_mode_py3dmol( ) -> None: """Render vibrational animation via py3Dmol multi-frame XYZ. - Per VIZBACK.8 spec: pure-numpy frame generation (no plotlymol3d + Pure-numpy frame generation (no plotlymol3d dependency); 24 sinusoidal-phase frames over one full oscillation; py3Dmol view with ``addModelsAsFrames`` + ``animate``; serialized to HTML and atomically swapped into ``app.vib_output``. @@ -1609,7 +1609,7 @@ def _render_vib_mode_py3dmol( _vib_err(app, "No frequency result cached for vibrational animation.") return - # Cache hit short-circuit (VIZBACK.9). The cache key now includes ``fps`` + # Cache hit short-circuit. The cache key now includes ``fps`` # so a user who changes the framerate will rebuild rather than play back # a mismatched-interval HTML blob. result_dir = getattr(app, "_last_result_dir", None) @@ -1714,7 +1714,7 @@ def _render_vib_mode_py3dmol( _swap_vib_output(app, html_str) # Persist to disk cache so future visits and history replay can hit - # this mode instantly (VIZBACK.9). Non-fatal on failure — render still + # this mode instantly. Non-fatal on failure — render still # succeeded, cache is purely an optimization. if result_dir is not None: try: @@ -2084,7 +2084,7 @@ def build_preopt_preview_html( ``viewer.setFrame`` so the user can compare geometries without re-rendering. A single-frame trajectory (no relaxation / FF fallback) renders as a static structure with no controls. Used by the interactive "Preview - pre-optimization" flow (M-PREOPT PREOPT.2). + pre-optimization" flow. """ import re diff --git a/quantui/benchmarks.py b/quantui/benchmarks.py index b1dd05f..9f19ec0 100644 --- a/quantui/benchmarks.py +++ b/quantui/benchmarks.py @@ -7,8 +7,8 @@ :func:`~quantui.calc_log.estimate_time` immediately becomes useful on a fresh install. -Four tiers (M-EST / EST.4, 2026-05-25) --------------------------------------- +Four tiers (2026-05-25) +----------------------- The calibration suite is now a **four-tier cascade** rather than the original short/long pair. Users pick the depth that matches their setup- @@ -320,7 +320,7 @@ "RHF", "STO-3G", ), - # ── M-EST / EST.4 expansion (2026-05-25) ────────────────────────────── + # ── Expansion (2026-05-25) ──────────────────────────────────────────── # Additional SP entries that broaden the method × basis grid coverage, # extending tier 2's expected wall-clock to the 3-5 min target. ( @@ -641,7 +641,7 @@ def _normalize_entry(entry: tuple) -> dict: # --------------------------------------------------------------------------- -# Cross-device probe (M-EST / EST.5, 2026-05-25) +# Cross-device probe (2026-05-25) # --------------------------------------------------------------------------- # # When GPU offload is available, tier 3 and tier 4 calibrations should run @@ -727,10 +727,10 @@ class BenchmarkStep: elapsed_s: float = 0.0 error_msg: str = "" n_basis: Optional[int] = None - # M-EST / EST.4: track which calc-type this step ran so tier 3+4 + # Track which calc-type this step ran so tier 3+4 # entries can be distinguished in summaries. calc_type: str = "single_point" - # M-EST follow-up (2026-05-25 user request): the calibration worker + # Follow-up (2026-05-25): the calibration worker # now saves each step as a real result directory (via save_result) # so users can re-open them from the History tab like any other # calc. ``None`` when save_result failed (best-effort) or the step @@ -746,7 +746,7 @@ class CalibrationResult: steps: List[BenchmarkStep] = field(default_factory=list) stopped_early: bool = False mode: str = "tier1" - # EST.5 cross-device probe expands the execution plan beyond + # The cross-device probe expands the execution plan beyond # ``len(_MODE_TO_SUITE[mode])`` for tier 3/4 on GPU hosts. Store # the plan length explicitly so progress denominators stay correct; # 0 (default) means "fall back to suite size" for back-compat with @@ -780,12 +780,12 @@ def _count_electrons(atoms: list[str], charge: int) -> int: # --------------------------------------------------------------------------- -# Subprocess worker (M-EST follow-up, 2026-05-25) +# Subprocess worker (2026-05-25) # --------------------------------------------------------------------------- # # Originally calibration ran each step in a ThreadPoolExecutor with a # ``future.result(timeout=...)`` block. That had three blockers exposed by -# the user's tier-4 attempt (session 55): +# a tier-4 attempt: # # 1. The Stop button only checked between steps, so an in-flight 5-minute # freq calc could not be killed mid-run. @@ -984,7 +984,7 @@ def _calibration_worker( ``force_cpu=True`` sets ``QUANTUI_DISABLE_GPU=1`` in the worker's environment BEFORE any quantui / gpu4pyscf import so the cached ``is_gpu_available()`` probe sees the override and the calc actually - runs on CPU. Used by the EST.5 cross-device probe — tier 3/4 on a + runs on CPU. Used by the cross-device probe — tier 3/4 on a GPU host runs selected entries twice (once forced-CPU, once GPU) so the analytics speedup table is populated from one calibration run. @@ -1002,7 +1002,7 @@ def _calibration_worker( from datetime import datetime as _dt from pathlib import Path as _P - # EST.5: must run BEFORE any quantui / pyscf / gpu4pyscf import so + # Must run BEFORE any quantui / pyscf / gpu4pyscf import so # the ``is_gpu_available()`` cache sees the override on first probe. if force_cpu: _os.environ["QUANTUI_DISABLE_GPU"] = "1" @@ -1057,7 +1057,7 @@ def _calibration_worker( # verbose=3 gives per-iteration SCF energies in the log — # enough signal to confirm the worker hasn't frozen on a - # slow tier-4 entry. (Was verbose=0 pre-session-55.) + # slow tier-4 entry. (Was verbose=0 previously.) res = _sp( mol, method=method, @@ -1072,9 +1072,9 @@ def _calibration_worker( elapsed = _t.perf_counter() - t0 log_fh.write(f"\n[QuantUI_STATUS] COMPLETED in {elapsed:.2f} s\n") - # Save as a regular result directory (M-EST follow-up, - # 2026-05-25 user request — tier 4's MP2 + CCSD + benzene - # freq are scientifically valuable; don't discard them). + # Save as a regular result directory (2026-05-25 — tier 4's + # MP2 + CCSD + benzene freq are scientifically valuable; + # don't discard them). saved_dir = _save_calibration_step( res, calc_type=calc_type, @@ -1230,10 +1230,10 @@ def run_calibration( steps are abandoned (no further work). timeout_per_step: Wall-clock seconds allowed per step. ``None`` (default) means no timeout — the user controls - stoppage via the Stop / Skip buttons. The session-55 tier-4 + stoppage via the Stop / Skip buttons. A tier-4 run had a benzene B3LYP/6-31G* freq calc finish at ~1500 s but be cut off at the old 1800 s hard cap, losing - the data; the no-timeout default removes that footgun. + the data; the no-timeout default removes that hazard. Pass a numeric value only when running headlessly (e.g. CI) where you genuinely want a wall-clock cap. mode: One of ``"tier1"`` / ``"tier2"`` / ``"tier3"`` / ``"tier4"``. @@ -1270,7 +1270,7 @@ def run_calibration( mode = "tier1" suite = _MODE_TO_SUITE[mode] - # EST.5: probe GPU availability once in the parent so we know whether + # Probe GPU availability once in the parent so we know whether # to duplicate cross-device entries. Failure (e.g. gpu_offload import # error on a misconfigured install) defaults to "no GPU" — the # calibration still runs, it just doesn't collect speedup pairs. @@ -1309,7 +1309,7 @@ def run_calibration( # the per-step progress trail. pass - # Use ``spawn`` everywhere (session 55 follow-up): ``fork`` from a + # Use ``spawn`` everywhere: ``fork`` from a # background thread (run_calibration runs inside ``_do_calibration`` # which is itself a daemon thread) collides hard with CUDA contexts # that the parent process may have initialized via the GPU-detection @@ -1394,7 +1394,7 @@ def _emit_progress(*args, live_message=None, step=None) -> None: str(log_path), result_queue, timestamp, # calibration_run_id — the parent's run timestamp - force_cpu, # EST.5 cross-device probe flag + force_cpu, # cross-device probe flag ), daemon=True, ) @@ -1413,7 +1413,7 @@ def _emit_progress(*args, live_message=None, step=None) -> None: break # Timeout is now opt-in (was a hard 1800 s for tier 4 which - # cut off a near-finishing benzene freq in session 55). + # cut off a near-finishing benzene freq). # ``None`` means "user controls; never auto-kill". if timeout_per_step is not None and elapsed > timeout_per_step: worker.terminate() @@ -1435,8 +1435,8 @@ def _emit_progress(*args, live_message=None, step=None) -> None: # Skip = "abandon THIS step, continue to the next." Distinct # from Stop. Clear the event after consuming so the next # step starts fresh — the UI re-sets it if the user clicks - # Skip again. (session 55 user request — replaces the - # hard timeout that was cutting off near-finishing calcs.) + # Skip again. (Replaces the hard timeout that was cutting + # off near-finishing calcs.) if skip_event is not None and skip_event.is_set(): worker.terminate() worker.join(timeout=5) @@ -1462,8 +1462,8 @@ def _emit_progress(*args, live_message=None, step=None) -> None: # the queue. Capture the exit code + the tail of the # calibration log so the user can see what actually # happened — "worker exited without result" alone is - # useless for diagnosis (the original session-55 - # symptom of every step failing at 0.04 s). + # useless for diagnosis (the original symptom of every + # step failing at 0.04 s). _exitcode = getattr(worker, "exitcode", None) _tail = _tail_last_status_line(log_path) or "(no log output)" _hint = "" @@ -1512,7 +1512,7 @@ def _emit_progress(*args, live_message=None, step=None) -> None: ) result.steps.append(step) - # Fix 2: persist after EVERY step so an interrupt at step N + # Persist after EVERY step so an interrupt at step N # still leaves a partial-state record on disk. _save_calibration_json(result, log_path) diff --git a/quantui/c_stderr.py b/quantui/c_stderr.py index a714518..ea5a2de 100644 --- a/quantui/c_stderr.py +++ b/quantui/c_stderr.py @@ -1,4 +1,4 @@ -"""POSIX file-descriptor stderr capture (M-STDERR / STDERR.1). +"""POSIX file-descriptor stderr capture. PySCF and its C-extension dependencies (libcint, BLAS/LAPACK, dftd3) write diagnostic messages directly to file-descriptor 2 (the OS-level stderr), diff --git a/quantui/calc_log.py b/quantui/calc_log.py index d4f9607..80be74f 100644 --- a/quantui/calc_log.py +++ b/quantui/calc_log.py @@ -270,9 +270,9 @@ def _event_path() -> Path: def _prediction_log_path() -> Path: - """Path to ``prediction_log.jsonl`` — the M-EST / EST.6 file - capturing one record per ``_do_run`` invocation with the - estimator's pre-run prediction and the actual wall-clock outcome. + """Path to ``prediction_log.jsonl`` — the file capturing one record + per ``_do_run`` invocation with the estimator's pre-run prediction + and the actual wall-clock outcome. Kept indefinitely (like ``perf_log.jsonl``) so the analytics dashboard can plot prediction accuracy over time without manual @@ -359,7 +359,7 @@ def count_basis_functions(atoms: list[str], basis: str) -> Optional[int]: # --------------------------------------------------------------------------- -# Statistical helpers (M-EST / EST.3, 2026-05-25) +# Statistical helpers (2026-05-25) # --------------------------------------------------------------------------- @@ -407,7 +407,7 @@ def _coefficient_of_variation(values: list[float]) -> float: def _confidence_label(values: list[float], n_samples: int) -> str: - """Variance-aware confidence label (M-EST / EST.3). + """Variance-aware confidence label. Combines coefficient of variation (CV) with sample count: @@ -456,11 +456,12 @@ def log_calculation( calc_type: Optional[str] = None, gpu_used: Optional[bool] = None, gpu_name: Optional[str] = None, + n_steps: Optional[int] = None, ) -> None: """Append one performance record to ``perf_log.jsonl``. - ``gpu_used`` / ``gpu_name`` (added M-GPU follow-up, 2026-05-25) record - whether GPU offload was active for the run; reading these back lets + ``gpu_used`` / ``gpu_name`` (added 2026-05-25) record whether GPU + offload was active for the run; reading these back lets ``quantui.analytics.build_dashboard`` compute GPU-vs-CPU speedups across runs of the same (method, basis, formula) tuple. """ @@ -485,10 +486,14 @@ def log_calculation( record["gpu_used"] = bool(gpu_used) if gpu_name is not None: record["gpu_name"] = gpu_name + # Outer-loop step count (geom-opt BFGS steps / PES scan points). Enables + # a history-based "step k / ~N" prior for the live progress fraction. + if n_steps is not None: + record["n_steps"] = n_steps _append(_perf_path(), record) -#: Hessian-cost multipliers used by the EST.2 frequency cost model. +#: Hessian-cost multipliers used by the frequency cost model. #: PySCF's analytical Hessian for HF/DFT runs in ~2-3× SCF time; for #: post-HF methods it falls back to numerical Hessian which is much #: more expensive (effectively 6N SCFs by itself, on top of the IR @@ -511,7 +516,7 @@ def _estimate_frequency_cost( n_cores: Optional[int] = None, gpu_used: Optional[bool] = None, ) -> Optional[dict]: - """EST.2: structured frequency-time estimate from an SP anchor. + """Structured frequency-time estimate from an SP anchor. Decomposition:: @@ -641,13 +646,13 @@ def estimate_time( (for example, Single Point). Legacy records without ``calc_type`` are only included when estimating ``single_point``. - **GPU-aware filtering** (M-EST / EST.1, 2026-05-25): when ``gpu_used`` + **GPU-aware filtering** (2026-05-25): when ``gpu_used`` is passed, the candidate pool is partitioned by device — GPU-history - predicts GPU runs and CPU-history predicts CPU runs. Records written - before session 55 don't have ``gpu_used`` at all; those are treated + predicts GPU runs and CPU-history predicts CPU runs. Older records + don't have ``gpu_used`` at all; those are treated as "device unknown" and admitted only when ``gpu_used=False`` is requested (the conservative assumption, since QuantUI was CPU-only - before M-GPU shipped). When ``gpu_used=None`` (default), the device + before GPU offload shipped). When ``gpu_used=None`` (default), the device axis is ignored and all records are eligible — back-compat with callers that don't know which device the upcoming run will use. @@ -677,7 +682,7 @@ def estimate_time( scoped = [r for r in converged if r.get("calc_type") == calc_type] if len(scoped) < 2: - # EST.2: frequency calcs can still produce a prediction via the + # Frequency calcs can still produce a prediction via the # SP-anchored cost model even when direct freq history is empty. # The cost model lives at the end of this function — fall through # for freq, bail for everything else. @@ -687,8 +692,8 @@ def estimate_time( # will all no-op (their pool checks require len >= 2), and the # freq cost-model fallback at the end will fire. - # M-EST / EST.1: partition by device when the caller specified one. - # Records pre-dating session 55 don't carry ``gpu_used`` — admit them + # Partition by device when the caller specified one. + # Older records don't carry ``gpu_used`` — admit them # only into the CPU pool, since QuantUI was CPU-only when they were # written. Track whether we downgraded for the fall-back path below. _gpu_filtered = False @@ -738,7 +743,7 @@ def _eff(r: dict) -> Optional[float]: ] effs = [e for r in exact_nb for e in [_eff(r)] if e is not None] if len(effs) >= 2: - # EST.3: drop Tukey outliers before computing the predictor. + # Drop Tukey outliers before computing the predictor. # The variance of the *filtered* pool drives confidence. filtered_effs = _iqr_filter(effs) predicted = ( @@ -818,7 +823,7 @@ def _eff(r: dict) -> Optional[float]: "n_samples": len(same_basis), } - # ── EST.2 frequency cost-model fallback ─────────────────────────────────── + # ── Frequency cost-model fallback ───────────────────────────────────────── # When all four direct-history strategies fail for a freq calc, fall # back to the structural decomposition: SP anchor + Hessian + 6N # inner SCFs. The SP anchor comes from the much richer single-point @@ -840,6 +845,47 @@ def _eff(r: dict) -> Optional[float]: return None +def estimate_opt_steps( + method: str, basis: str, calc_type: str = "geometry_opt" +) -> Optional[float]: + """Median historical outer-step count for *calc_type* (progress prior). + + Reads ``perf_log`` for converged records of *calc_type* that recorded + ``n_steps``. Prefers exact method+basis (>= 2 records), falls back + to same-basis (>= 2), then to all matching-calc_type records. Returns the + median or ``None`` when there is no usable history — a rough prior used only + to seed / floor the live progress fraction, not a hard prediction. + """ + try: + records = _read_all(_perf_path()) + except Exception: + return None + + def _usable(r: dict) -> bool: + return ( + r.get("calc_type") == calc_type + and bool(r.get("converged")) + and isinstance(r.get("n_steps"), (int, float)) + and r["n_steps"] > 0 + ) + + pool = [r for r in records if _usable(r)] + if not pool: + return None + exact = [r for r in pool if r.get("method") == method and r.get("basis") == basis] + same_basis = [r for r in pool if r.get("basis") == basis] + if len(exact) >= 2: + chosen = exact + elif len(same_basis) >= 2: + chosen = same_basis + else: + chosen = pool + try: + return float(statistics.median(r["n_steps"] for r in chosen)) + except Exception: + return None + + def format_estimate(est: Optional[dict]) -> str: """ Return an HTML string summarising *est* for display in the notebook. @@ -876,7 +922,7 @@ def get_perf_history() -> list[dict]: # --------------------------------------------------------------------------- -# Prediction log (M-EST / EST.6, 2026-05-25) +# Prediction log (2026-05-25) # --------------------------------------------------------------------------- # # Captures one record per ``_do_run`` invocation with the estimator's @@ -971,7 +1017,7 @@ def clear_event_log() -> None: # Event log (7-day TTL) # --------------------------------------------------------------------------- -# M8 audit fix (2026-07-14): log_event() used to call prune_events() after +# Audit fix (2026-07-14): log_event() used to call prune_events() after # every single append, and prune_events() itself read the file (acquiring # and releasing _LOCK) and only later reacquired _LOCK to rewrite it. Two # problems: diff --git a/quantui/cancellation.py b/quantui/cancellation.py index a4b3371..2ae631c 100644 --- a/quantui/cancellation.py +++ b/quantui/cancellation.py @@ -1,9 +1,9 @@ -"""Shared cancellation primitive for in-flight calculations (UXP.5). +"""Shared cancellation primitive for in-flight calculations. 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 +(unsafe) thread-kill. This module 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. @@ -57,9 +57,9 @@ def attach_scf_cancel_callback( 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**: 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 + - **Progress**: 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). diff --git a/quantui/descriptor_cards.py b/quantui/descriptor_cards.py index f18d19b..464c015 100644 --- a/quantui/descriptor_cards.py +++ b/quantui/descriptor_cards.py @@ -1,4 +1,4 @@ -"""Method / basis descriptor cards (UXP.7 — FR-DESCRIPTOR-CARDS). +"""Method / basis descriptor cards. Replaces the inline multi-paragraph educational-notes block next to the method / basis dropdowns with two compact "descriptor cards" — an icon, a diff --git a/quantui/freq_calc.py b/quantui/freq_calc.py index 8c16f87..5e58411 100644 --- a/quantui/freq_calc.py +++ b/quantui/freq_calc.py @@ -186,7 +186,7 @@ def run_freq_calc( stream: IO[str] = progress_stream if progress_stream is not None else sys.stdout - # M-STDERR / STDERR.1: see quantui/c_stderr.py — captures fd-2 stderr + # See quantui/c_stderr.py — captures fd-2 stderr # from libcint / BLAS / LAPACK / Hessian C code and relays to ``stream`` # on exit. POSIX-only; no-op on Windows. from quantui.c_stderr import capture_c_stderr @@ -217,7 +217,7 @@ def _run_freq_calc_body( _pyscf_thermo: Any, stream: IO[str], ) -> FreqResult: - """Inner body of :func:`run_freq_calc` (split out for STDERR.1 wrap).""" + """Inner body of :func:`run_freq_calc` (split out for stderr-capture wrap).""" dft, gto, scf, pyscf_thermo = _dft, _gto, _scf, _pyscf_thermo def _status(msg: str) -> None: @@ -244,7 +244,7 @@ def _status(msg: str) -> None: elif method_upper == "UHF": mf = scf.UHF(mol) else: - # session 55: route through resolve_xc + maybe_apply_d3 so + # Route through resolve_xc + maybe_apply_d3 so # methods like wB97X-D (PySCF rejects "wb97x-d") map to the # bare functional + external D3 dispersion. from .session_calc import maybe_apply_d3, resolve_xc @@ -253,7 +253,7 @@ 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 + # 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 @@ -316,9 +316,9 @@ def _status(msg: str) -> None: for atom, coords in zip(molecule.atoms, molecule.coordinates) ] except Exception as exc: - # Same class as session_calc bug-A: silent failure here ships - # a FreqResult with no MO data, breaking the Energies panel on - # history replay. Log to surface in the Log tab. + # Silent failure here ships a FreqResult with no MO data, + # breaking the Energies panel on history replay. Log to surface + # in the Log tab. logger.warning( "MO data extraction failed in freq calc for %s: %s", molecule.get_formula(), @@ -398,7 +398,7 @@ def _status(msg: str) -> None: _dm0 = mf.make_rdm1() _dpdx = _np_ir.zeros((_n_ir * 3, 3)) _xc = getattr(mf, "xc", None) - # M5 audit fix (2026-07-14): whether the inner displaced-SCF + # Fix (2026-07-14): whether the inner displaced-SCF # loop needs an unrestricted (UHF/UKS) object is determined # by _dm0's actual shape — (2, nao, nao) for UHF/UKS/ROHF, # (nao, nao) for RHF/RKS — NOT by mol.spin == 0. Those two @@ -421,7 +421,7 @@ def _status(msg: str) -> None: # Inner-SCF helper: builds the right RHF/UHF/RKS/UKS object # for the current ``mol`` geometry, attempts gpu4pyscf - # offload (M-GPU extension to the IR-intensity loop — + # offload (GPU extension to the IR-intensity loop — # without this wrap, the per-displacement SCFs run on CPU # even when the outer SCF was GPU-offloaded), and returns # the dipole moment as a numpy array. Used for both +Δ and diff --git a/quantui/gpu_offload.py b/quantui/gpu_offload.py index 3f7916d..a3b9d77 100644 --- a/quantui/gpu_offload.py +++ b/quantui/gpu_offload.py @@ -1,9 +1,9 @@ -"""GPU offload helpers (M-GPU / GPU.1). +"""GPU offload helpers. Wraps the runtime decision "should this SCF object be migrated to GPU?". Detection probes ``gpu4pyscf`` + ``cupy`` for a CUDA-capable device; if anything is missing or broken the helpers silently report "no GPU" so the -caller falls back to CPU. This means M-GPU integration is safe to leave +caller falls back to CPU. This means GPU integration is safe to leave enabled by default on every platform — Windows users without CUDA, WSL users without gpu4pyscf installed, and remote machines with broken NVIDIA drivers all converge to the same "CPU" outcome with no exception leakage. @@ -39,7 +39,7 @@ # # - ``CCSD(T)`` is documented as unsupported in the gpu4pyscf README. # - ``MP2`` and ``CCSD`` are labelled "experimental" by gpu4pyscf and -# were observed (session 55, 2026-05-25 user tier-4 run) to fail +# were observed (2026-05-25) to fail # immediately after a successful RHF reference on GPU — the failure # fingerprint was "step completed in RHF wall time + small delta, # then errored", which fits the post-HF code choking on a diff --git a/quantui/log_utils.py b/quantui/log_utils.py index a74e35b..f85e20b 100644 --- a/quantui/log_utils.py +++ b/quantui/log_utils.py @@ -176,7 +176,7 @@ def get_system_info() -> Dict[str, Any]: # ============================================================================ -# Live run-status helpers (M-PROGRESS Phase A) +# Live run-status helpers # ============================================================================ @@ -198,6 +198,24 @@ def emit_status(stream: Any, message: str) -> None: pass +def emit_progress(stream: Any, fraction: float) -> None: + """Report a completion fraction (0..1) via a progress stream, if supported. + + Calc modules that know a real completion fraction (PES scan + points, optimizer fmax-convergence trend) call this so the live ticker can + show a *self-correcting* ``elapsed·(1−f)/f`` remaining-time estimate instead + of the static total. Duck-typed on ``stream.set_progress_fraction`` — a + no-op for plain streams, keeping calc modules decoupled from the widget layer. + """ + setter = getattr(stream, "set_progress_fraction", None) + if setter is None: + return + try: + setter(fraction) + except Exception: # noqa: BLE001 — progress 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: diff --git a/quantui/nmr_calc.py b/quantui/nmr_calc.py index fc35ac2..c6476c8 100644 --- a/quantui/nmr_calc.py +++ b/quantui/nmr_calc.py @@ -37,7 +37,7 @@ class NMRResult: formula: str reference_compound: str = "TMS" converged: bool = True - # M4 audit fix (2026-07-14): which config.NMR_REFERENCE_SHIELDINGS entry + # Fix (2026-07-14): which config.NMR_REFERENCE_SHIELDINGS entry # was actually applied, and whether it's an exact match for method/basis # or a fallback. NMR_REFERENCE_SHIELDINGS only tabulates a handful of # method/basis combinations; any other combination previously fell back @@ -140,7 +140,7 @@ def run_nmr_calc( stream = progress_stream if progress_stream is not None else sys.stdout - # M-STDERR / STDERR.1: see quantui/c_stderr.py — captures fd-2 stderr + # See quantui/c_stderr.py — captures fd-2 stderr # from libcint / BLAS / LAPACK / GIAO / NMR-CPHF C code and relays to # ``stream`` on exit. POSIX-only; no-op on Windows. from quantui.c_stderr import capture_c_stderr @@ -158,7 +158,7 @@ def run_nmr_calc( ) -# L audit fix (2026-07-14): bump this whenever the patch bodies below +# Fix (2026-07-14): bump this whenever the patch bodies below # change — it doubles as the idempotency sentinel's value, so a bumped # version forces re-patching instead of silently keeping stale closures # from an older QuantUI version installed earlier in the process. @@ -329,7 +329,7 @@ def _run_nmr_calc_body( _scf: Any, stream: Any, ) -> NMRResult: - """Inner body of :func:`run_nmr_calc` (split out for STDERR.1 wrap).""" + """Inner body of :func:`run_nmr_calc` (split out for stderr-capture wrap).""" dft, gto, scf = _dft, _gto, _scf import numpy as _np @@ -352,7 +352,7 @@ def _run_nmr_calc_body( elif method_upper == "UHF": mf = scf.UHF(mol) else: - # session 55: route through resolve_xc + maybe_apply_d3 so + # Route through resolve_xc + maybe_apply_d3 so # wB97X-D / PBE-D3 work for NMR calcs (was using raw _XC_ALIAS # lookup before, which would fail for wB97X-D after the alias # change to "wb97x" + external D3). @@ -360,13 +360,13 @@ 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. + # 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 + emit_status(stream, "Running SCF…") try: mf.kernel() except Exception as exc: @@ -390,7 +390,7 @@ def _run_nmr_calc_body( _ensure_nmr_compat_patches_applied() - emit_status(stream, "Computing NMR shielding tensors (GIAO)…") # M-PROGRESS A4 + emit_status(stream, "Computing NMR shielding tensors (GIAO)…") try: if method_upper == "RHF": nmr_obj = _pyscf_nmr.RHF(mf) diff --git a/quantui/optimizer.py b/quantui/optimizer.py index 47582cc..6abd3f3 100644 --- a/quantui/optimizer.py +++ b/quantui/optimizer.py @@ -96,6 +96,7 @@ def __init__( cancel_check=None, progress_stream=None, status_label: str = "Optimizing geometry", + expected_steps=None, **kwargs, ) -> None: super().__init__(**kwargs) @@ -103,15 +104,16 @@ def __init__( self.basis = basis self.charge = charge self.spin = spin - # UXP.5: cooperative-cancel predicate; checked per step + wired into + # 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 - # M-PROGRESS A2: progress stream + label for per-step status + # 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.expected_steps = expected_steps # history-based ~N prior self._eval_count = 0 def calculate( @@ -122,7 +124,7 @@ def calculate( ) -> None: super().calculate(atoms, properties, system_changes) - # UXP.5: bail before starting this step's SCF if cancel was clicked + # 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, @@ -131,14 +133,19 @@ def calculate( raise_if_cancelled(self.cancel_check) - # M-PROGRESS A2: heartbeat so the status line advances during the + # Heartbeat so the status line advances during the # (silent) per-step SCF + gradient. self._eval_count += 1 from .log_utils import emit_status + _step_of = ( + f"{self._eval_count}/~{int(self.expected_steps)}" + if self.expected_steps + else f"{self._eval_count}" + ) emit_status( self.progress_stream, - f"{self.status_label} — SCF + gradient (step {self._eval_count})…", + f"{self.status_label} — SCF + gradient (step {_step_of})…", ) import numpy as np @@ -174,7 +181,7 @@ def calculate( elif method_upper == "UHF": mf = scf.UHF(mol) else: - # DFT functional. session 55: route through resolve_xc + + # DFT functional. Route through resolve_xc + # maybe_apply_d3 so wB97X-D / PBE-D3 work mid-optimization. from .session_calc import maybe_apply_d3, resolve_xc @@ -185,7 +192,7 @@ def calculate( mf.verbose = 0 mf.stdout = _sink - # M-PROGRESS A3: per-SCF-cycle heartbeat during the (silent) step, + # Per-SCF-cycle heartbeat during the (silent) step, # so the status advances mid-SCF, not just per optimizer step. _k = self._eval_count @@ -341,6 +348,8 @@ def optimize_geometry( steps: int = DEFAULT_OPT_STEPS, progress_stream: Optional[IO[str]] = None, status_label: str = "Optimizing geometry", + report_fraction: bool = True, + expected_steps: Optional[int] = None, ) -> OptimizationResult: """ Optimize a molecular geometry at the QM level using ASE-BFGS + PySCF. @@ -445,9 +454,10 @@ def optimize_geometry( cancel_check=_cancel_check, progress_stream=_stream, status_label=status_label, + expected_steps=expected_steps, ) - # M-STDERR / STDERR.1: PySCF gradients (called by ASE-BFGS at every + # 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- # text channel. POSIX-only; no-op on Windows. @@ -464,11 +474,43 @@ 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 + # 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) + # Estimate completion from the fmax-convergence trend + # (log-scale between the first step's fmax and the target). Data-free + # and self-correcting. Skipped when report_fraction is False (e.g. + # reorg drives several sub-optimizations, whose 0→1 resets would make + # an overall remaining-time estimate oscillate). + if report_fraction: + from .log_utils import emit_progress + + _fmax0: list = [] # first-step fmax, captured on first callback + + def _report_opt_fraction() -> None: + try: + forces = atoms.get_forces() + fmax_now = float(math.sqrt((forces**2).sum(axis=1).max())) + except Exception: # noqa: BLE001 — progress is best-effort + return + if fmax_now <= 0: + return + if not _fmax0: + _fmax0.append(fmax_now) + return + denom = math.log(_fmax0[0] / fmax) if fmax > 0 else 0.0 + frac = math.log(_fmax0[0] / fmax_now) / denom if denom > 0 else 0.0 + # Floor with the history-based step prior so early steps + # (where the fmax trend is noisy / near 0) still advance. + if expected_steps: + step = getattr(atoms.calc, "_eval_count", 0) + frac = max(frac, min(step / float(expected_steps), 0.9)) + emit_progress(_stream, max(0.0, min(frac, 0.99))) + + dyn.attach(_report_opt_fraction, interval=1) + with capture_c_stderr(_stream), contextlib.redirect_stdout(_null): converged = bool(dyn.run(fmax=fmax, steps=steps)) @@ -530,9 +572,9 @@ def optimize_geometry( _opt_mol_atom = _last_atom_list _opt_mol_basis = basis except Exception as exc: - # Bug-A class — silent failure here ships an OptimizationResult - # with no MO data, breaking Energies + Isosurface panels on - # history replay. (Same root-cause class as session_calc.) + # Silent failure here ships an OptimizationResult with no MO data, + # breaking Energies + Isosurface panels on history replay. + # (Same root-cause class as session_calc.) logger.warning( "Final-step MO extraction failed in optimizer for %s: %s", molecule.get_formula(), diff --git a/quantui/pes_scan.py b/quantui/pes_scan.py index 7e54fce..8140369 100644 --- a/quantui/pes_scan.py +++ b/quantui/pes_scan.py @@ -79,7 +79,7 @@ class PESScanResult: def _finite_energies(self) -> List[float]: """``energies_hartree`` with failed-point NaN placeholders dropped. - M6 audit fix (2026-07-14): a failed scan point appends + Fix (2026-07-14): a failed scan point appends ``float("nan")`` to ``energies_hartree`` (see :func:`run_pes_scan`). Python's ``min``/``max`` are order-dependent with NaN present — a NaN as the first element "wins" (everything compares False against @@ -290,7 +290,7 @@ 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, + # 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 @@ -304,7 +304,7 @@ def run_pes_scan( energies_hartree: List[float] = [] coordinates_list: List[Molecule] = [] converged_all = True - # M6 audit fix (2026-07-14): on a failed scan point, fall back to the + # Fix (2026-07-14): on a failed scan point, fall back to the # last successfully-computed geometry rather than the original input # molecule. Snapping every failed frame back to the starting geometry # produced a bogus discontinuity in the trajectory animation/plot — @@ -318,13 +318,15 @@ 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 + # Live per-point status + exact completion fraction + # (points already done / total) for the self-correcting time estimate. + from .log_utils import emit_progress, emit_status emit_status( _stream, f"Scan point {step_num}/{steps} — relaxing (SCF + gradient)…", ) + emit_progress(_stream, (step_num - 1) / steps) _stream.write( f"\nScan point {step_num}/{steps}: " f"{scan_type} = {val:.4f} {('Å' if scan_type == 'bond' else '°')}\n" @@ -346,7 +348,7 @@ def run_pes_scan( constraint = FixInternals(bonds=[[val, [i1, i2]]]) elif scan_type == "angle": atoms.set_angle(i1, i2, i3, val) - # M7 audit fix (2026-07-14): ASE's radian-based `angles=` + # (2026-07-14): ASE's radian-based `angles=` # kwarg is not just deprecated, it's flat-out broken with # the currently-targeted ASE (>=3.22, verified against # 3.29.0) — internally it does @@ -368,7 +370,7 @@ def run_pes_scan( 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 + # 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/preopt.py b/quantui/preopt.py index 81f077a..fda5875 100644 --- a/quantui/preopt.py +++ b/quantui/preopt.py @@ -17,7 +17,7 @@ If RDKit is unavailable, bond perception fails, or no force field has parameters for the molecule, :func:`preoptimize` returns the **original** geometry unchanged (RMSD 0.0) rather than a mangled one. Pre-opt can only improve or -no-op — never degrade. (M-PREOPT / PREOPT.1.) +no-op — never degrade. Limitation: bond perception is distance-based, so a *wildly* broken input (atoms so far apart or so clashed that bonds can't be inferred) yields the no-op rather @@ -314,8 +314,8 @@ def preoptimize_with_trajectory( Like :func:`preoptimize`, but returns ``(optimized_molecule, rmsd, frames)`` where ``frames`` is a list of per-iteration coordinate snapshots (the starting geometry first, the relaxed geometry last) for animating the - relaxation in the interactive "Preview pre-optimization" flow (M-PREOPT - PREOPT.2). Same non-destructive guarantee: on any failure the original + relaxation in the interactive "Preview pre-optimization" flow. Same + non-destructive guarantee: on any failure the original geometry is returned unchanged with ``rmsd = 0.0`` and a single-frame trajectory (just the input), so the viewer always has something to show. """ diff --git a/quantui/reorganization_energy.py b/quantui/reorganization_energy.py index 06d8e30..ca780ec 100644 --- a/quantui/reorganization_energy.py +++ b/quantui/reorganization_energy.py @@ -320,6 +320,7 @@ def _single_point(mol: Molecule, mth: str, tag: str) -> float: steps=steps, progress_stream=stream, # type: ignore[arg-type] status_label="Reorg: optimizing neutral geometry", + report_fraction=False, # Don't let sub-opt 0→1 resets oscillate ETA ) neutral_mol = neutral_opt.molecule n_total_steps = neutral_opt.n_steps @@ -363,6 +364,7 @@ def _single_point(mol: Molecule, mth: str, tag: str) -> float: steps=steps, progress_stream=stream, # type: ignore[arg-type] status_label=f"Reorg: optimizing {kind} ion geometry", + report_fraction=False, # See neutral-opt note above ) ion_mol = ion_opt.molecule n_total_steps += ion_opt.n_steps diff --git a/quantui/results_storage.py b/quantui/results_storage.py index 17b1bc2..54c5cb8 100644 --- a/quantui/results_storage.py +++ b/quantui/results_storage.py @@ -206,8 +206,8 @@ def save_result( "ccsd_t_correction_hartree": _opt_float( getattr(result, "ccsd_t_correction_hartree", None) ), - # Persisted so the saved-result card matches the live card (M-CLEAN - # formatter-parity fix). Additive — absent on older results, where the + # Persisted so the saved-result card matches the live card + # (formatter-parity fix). Additive — absent on older results, where the # history card falls back exactly as before (CPU / no dipole / no # charges). Coerced JSON-safe (numpy scalars/arrays → float/list). "solvent": getattr(result, "solvent", None), @@ -318,7 +318,7 @@ def save_molden( normal_modes=None, filename: str = "result.molden", ) -> Optional[Path]: - """Write a Molden-format file alongside ``result.json`` (M-EXPORT / EXPORT.1+2). + """Write a Molden-format file alongside ``result.json``. Molden is the lingua franca for orbital + vibration interop with Avogadro / IQmol / Jmol / Multiwfn. This helper writes whichever data @@ -450,7 +450,7 @@ def save_trajectory_xyz( energies: list, filename: str = "trajectory.xyz", ) -> Optional[Path]: - """Write a multi-frame XYZ trajectory file (M-EXPORT / EXPORT.3). + """Write a multi-frame XYZ trajectory file. Universal format readable by Avogadro, VMD, OVITO, Jmol, Pymol, OpenBabel, ASE (``ase.io.read``), and basically any molecular tool @@ -508,7 +508,7 @@ def save_trajectory_ase( energies: list, filename: str = "trajectory.traj", ) -> Optional[Path]: - """Write an ASE Trajectory (.traj) file (M-EXPORT / EXPORT.7). + """Write an ASE Trajectory (.traj) file. Lets users open the result in ``ase gui trajectory.traj``, slice frames (``trajectory.traj@0:10:2``), and use ASE-GUI's interactive @@ -568,7 +568,7 @@ def export_cube( *, orbital_label: str = "orbital", ) -> Optional[Path]: - """Copy a cube file to the top-level result dir with a friendly name (EXPORT.5). + """Copy a cube file to the top-level result dir with a friendly name. Internal cube files live in ``/isosurfaces/`` with timestamped filenames (``H2O_HOMO_2026-05-23_19-30-00.cube``) — fine @@ -603,7 +603,7 @@ def export_result_bundle( *, output_dir: Optional[Path] = None, ) -> Optional[Path]: - """Zip an entire result directory for sharing (EXPORT.5 stretch goal). + """Zip an entire result directory for sharing. Produces ``/.zip`` containing every file the calc wrote — ``result.json``, ``pyscf.log``, ``orbitals.npz``, @@ -772,7 +772,7 @@ def save_thumbnail(result_dir: Path, data: dict) -> None: except ImportError: return - # M9 audit fix (2026-07-14): only the matplotlib import itself was + # Fix (2026-07-14): only the matplotlib import itself was # guarded — figure construction, text rendering, and fig.savefig() (a # real filesystem write, so it can hit disk-full / permission errors) # could all raise past this function despite the docstring's promise @@ -818,7 +818,7 @@ def _build_thumbnail_figure(plt: Any, data: dict) -> Any: fg, bg = _colors.get(ct, ("#555555", "#f3f4f6")) ct_label = _ct_labels.get(ct, ct.replace("_", " ").title()) - # POLISH.7 (M-POLISH, 2026-05-25): bumped figsize 2.4→3.6 + dpi 72→144 + # (2026-05-25): bumped figsize 2.4→3.6 + dpi 72→144 # so the History-card text is readable on 1× displays. Source PNG goes # from 173×108 px (~8 KB) to 518×324 px (~25 KB); the History dropdown # downscales to its native ~250–300 px width, so the user sees crisp diff --git a/quantui/session_calc.py b/quantui/session_calc.py index b7dc438..6184962 100644 --- a/quantui/session_calc.py +++ b/quantui/session_calc.py @@ -77,7 +77,7 @@ class SessionResult: # method is ``"CCSD(T)"``. ``None`` for plain CCSD. Again, included in # ``energy_hartree`` when set. ccsd_t_correction_hartree: Optional[float] = None - # GPU offload status (M-GPU / GPU.2). ``gpu_used`` is True only when the + # GPU offload status. ``gpu_used`` is True only when the # SCF object was successfully migrated to gpu4pyscf for this run. # ``gpu_name`` carries the CUDA device name when ``gpu_used`` is True so # the result card can show *which* GPU ran the calc. @@ -157,8 +157,8 @@ def resolve_xc(method: str) -> str: translation. Every DFT entry point — ``session_calc``, ``freq_calc``, ``tddft_calc``, ``optimizer``, ``freq_ir_workers``, ``nmr_calc``, and the script-export path in ``config.py`` — should use this - helper rather than passing ``method`` to PySCF directly. (Before - session 55 they didn't, which is why wB97X-D errored in tier 3 + helper rather than passing ``method`` to PySCF directly. (Previously + they didn't, which is why wB97X-D errored in tier 3 SP calcs but ALSO would have errored in freq / opt / tddft.) """ method_upper = method.upper() @@ -258,7 +258,7 @@ def run_in_session( stream: IO[str] = progress_stream if progress_stream is not None else sys.stdout - # M-STDERR / STDERR.1: capture C-level (fd-2) stderr from libcint / BLAS + # Capture C-level (fd-2) stderr from libcint / BLAS # / LAPACK and relay it to ``stream`` on exit. Without this wrapper, the # bytes surface as red text above the cell output in Voilà / Jupyter. # POSIX-only; no-op on Windows. See quantui/c_stderr.py for design. @@ -348,7 +348,7 @@ def _run_session_calc_body( else: # DFT: resolve alias then auto-select RKS / UKS. ``resolve_xc`` # handles the wB97X-D → wb97x + external D3 dispersion mapping - # (session 55 fix; see _XC_ALIAS docstring). + # (see _XC_ALIAS docstring). if mol.spin == 0: mf = dft.RKS(mol) else: @@ -378,7 +378,7 @@ def _run_session_calc_body( "\n⚠ PCM solvent unavailable — running in gas phase.\n" ) - # --- Try GPU offload (M-GPU / GPU.1) --- + # --- Try GPU offload --- # Migrate the SCF object to gpu4pyscf when (a) the package is installed, # (b) a CUDA device is available, and (c) the method is supported. # Failures fall back to CPU silently — the calc still runs. The @@ -393,7 +393,7 @@ def _run_session_calc_body( except Exception: # noqa: BLE001 — cleanup (progress stream may be closed) pass - # --- Cooperative cancellation (UXP.5) --- + # --- Cooperative cancellation --- # 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. @@ -404,7 +404,7 @@ def _run_session_calc_body( attach_scf_cancel_callback(mf, _cancel_check) # --- Run SCF --- - emit_status(stream, "Running SCF…") # M-PROGRESS A4 + emit_status(stream, "Running SCF…") try: energy_hartree = float(mf.kernel()) except Exception as exc: @@ -419,7 +419,7 @@ def _run_session_calc_body( try: from pyscf import mp as _mp - emit_status(stream, "Running MP2 correlation…") # M-PROGRESS A4 + emit_status(stream, "Running MP2 correlation…") _mp2 = _mp.MP2(mf) _e_corr, _ = _mp2.kernel() mp2_correlation_hartree = float(_e_corr) @@ -429,7 +429,7 @@ def _run_session_calc_body( f"MP2 correction failed for {molecule.get_formula()}: {exc}" ) from exc - # --- Coupled cluster correlation (M8.1) --- + # --- Coupled cluster correlation --- # CCSD adds singles + doubles excitations on top of the RHF reference; # CCSD(T) adds a perturbative-triples correction on top of CCSD. Both # report their corrections as separate result fields so the UI can @@ -440,7 +440,7 @@ def _run_session_calc_body( try: from pyscf import cc as _cc - emit_status(stream, "Running CCSD correlation…") # M-PROGRESS A4 + emit_status(stream, "Running CCSD correlation…") _ccsd_obj = _cc.CCSD(mf) _e_corr_ccsd, _t1, _t2 = _ccsd_obj.kernel() ccsd_correlation_hartree = float(_e_corr_ccsd) @@ -451,7 +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 + emit_status(stream, "Computing CCSD(T) triples…") _e_t = _ccsd_obj.ccsd_t() ccsd_t_correction_hartree = float(_e_t) energy_hartree += float(_e_t) @@ -511,7 +511,7 @@ def _to_numpy_array(arr: Any) -> Any: mulliken_charges: Optional[List[float]] = None dipole_moment_debye: Optional[float] = None - # M3 audit fix (2026-07-14): both mf.mulliken_pop() and mf.dip_moment() + # Audit fix (2026-07-14): both mf.mulliken_pop() and mf.dip_moment() # are well-defined and work correctly for a genuine UHF object (verified # empirically against PySCF) — the previous ``method_upper != "UHF"`` # guard around this whole block was an unnecessary restriction that @@ -540,7 +540,7 @@ def _to_numpy_array(arr: Any) -> Any: # MO arrays for orbital visualization (non-fatal if extraction fails). # Uses the same ``_to_numpy_array`` CuPy→host helper defined above - # (GPU-offload note, BUG fix 2026-05-25): + # (GPU-offload note, fix 2026-05-25): # when gpu4pyscf migrated ``mf`` to the GPU, ``mf.mo_energy`` / ``mo_coeff`` # / ``mo_occ`` are CuPy arrays. ``numpy.array(cupy_array)`` raises (numpy # refuses implicit device transfers), which silently shipped a @@ -562,7 +562,7 @@ def _to_numpy_array(arr: Any) -> Any: ] _pyscf_mol_basis = basis except Exception as exc: - # Bug-A class (session 55): a silent failure here ships a + # A silent failure here ships a # SessionResult with mo_coeff=None, which makes save_orbitals # no-op and breaks Energies + Isosurface panels on history # replay. Surface to the event log so a future regression is diff --git a/quantui/tddft_calc.py b/quantui/tddft_calc.py index 633fa5b..a0c9312 100644 --- a/quantui/tddft_calc.py +++ b/quantui/tddft_calc.py @@ -156,7 +156,7 @@ def run_tddft_calc( stream: IO[str] = progress_stream if progress_stream is not None else sys.stdout - # M-STDERR / STDERR.1: see quantui/c_stderr.py — captures fd-2 stderr + # See quantui/c_stderr.py — captures fd-2 stderr # from libcint / BLAS / LAPACK / TDA solver C code and relays to # ``stream`` on exit. POSIX-only; no-op on Windows. from quantui.c_stderr import capture_c_stderr @@ -187,7 +187,7 @@ def _run_tddft_calc_body( _scf: Any, stream: IO[str], ) -> TDDFTResult: - """Inner body of :func:`run_tddft_calc` (split out for STDERR.1 wrap).""" + """Inner body of :func:`run_tddft_calc` (split out for stderr-capture wrap).""" dft, gto, scf = _dft, _gto, _scf # ── Build Mole object ──────────────────────────────────────────────────── @@ -209,7 +209,7 @@ def _run_tddft_calc_body( elif method_upper == "UHF": mf = scf.UHF(mol) else: - # session 55: route through resolve_xc + maybe_apply_d3 so + # Route through resolve_xc + maybe_apply_d3 so # methods like wB97X-D (PySCF rejects "wb97x-d") map cleanly. from .session_calc import maybe_apply_d3, resolve_xc @@ -227,13 +227,13 @@ def _run_tddft_calc_body( except Exception: # noqa: BLE001 — cleanup (stream may be closed) pass - # UXP.5: cooperative cancel between SCF cycles. + # 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 + emit_status(stream, "Running SCF (ground state)…") try: energy_hartree = float(mf.kernel()) except Exception as exc: @@ -270,7 +270,7 @@ def _run_tddft_calc_body( oscillator_strengths: List[float] = [] try: - emit_status( # M-PROGRESS A4 + emit_status( stream, f"Solving {'TDHF (CIS)' if using_hf else 'TD-DFT'} " f"excited states ({nstates})…", diff --git a/quantui/vib_cache.py b/quantui/vib_cache.py index f3cee29..57a249c 100644 --- a/quantui/vib_cache.py +++ b/quantui/vib_cache.py @@ -1,5 +1,5 @@ """ -Vibrational animation disk cache (VIZBACK.9). +Vibrational animation disk cache. Caches rendered vibrational-animation HTML per calculation result directory so mode switches on repeat visits and history replay are instant — no diff --git a/quantui/visualization_py3dmol.py b/quantui/visualization_py3dmol.py index a6ccaef..52b82df 100644 --- a/quantui/visualization_py3dmol.py +++ b/quantui/visualization_py3dmol.py @@ -426,8 +426,9 @@ def render_molecule_html( Mirrors :func:`display_molecule` but emits a single HTML string so callers can route through an atomic ``Output.outputs`` swap (Rule 6 in ``reflections/01-voila-rendering-and-display.md``) rather than - ``with output: display(viz)`` — the latter is the BUG.6/BUG.7 root cause - family. Errors are caught and returned as inline HTML so the caller sees a + ``with output: display(viz)`` — the latter is a known root-cause + family for trajectory and Analysis-tab rendering regressions. Errors are + caught and returned as inline HTML so the caller sees a visible failure message in the viewer slot instead of a blank 🙁 panel. """ if not is_visualization_available(): diff --git a/quantui/viz_backend_router.py b/quantui/viz_backend_router.py index 326883c..fc3a734 100644 --- a/quantui/viz_backend_router.py +++ b/quantui/viz_backend_router.py @@ -5,8 +5,7 @@ render task, taking into account user preference and installed-package availability. Pure function — no I/O, no widget state, no app reference. -The routing policy mirrors the Capability Routing Policy table in -`M-VIZ-BACKEND` roadmap (`22-m-viz-backend-routing-roadmap.md`). +The routing policy mirrors a capability-based routing policy table. Typical usage ------------- diff --git a/tests/test_app.py b/tests/test_app.py index a6fc0db..ca741c4 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -807,6 +807,55 @@ def test_overdue_estimate_switches_message(self): assert "left" not in chip +class TestFractionProgress: + """M-PROGRESS B2: fraction-complete drives a self-correcting remaining time.""" + + def test_emit_progress_sets_and_clamps_fraction(self): + from quantui.app import _LogCapture + from quantui.log_utils import emit_progress + + cap = _LogCapture(widgets.Output()) + emit_progress(cap, 0.4) + assert cap._fraction == 0.4 + emit_progress(cap, 1.5) # clamped below 1.0 + assert cap._fraction == 0.999 + emit_progress(cap, -0.2) # clamped at 0 + assert cap._fraction == 0.0 + + def test_emit_progress_noop_on_plain_stream(self): + import io + + from quantui.log_utils import emit_progress + + emit_progress(io.StringIO(), 0.5) # must not raise + + def test_chip_prefers_fraction_over_total(self): + from quantui.app import _LogCapture + + app = QuantUIApp() + # A total estimate is present, but a real fraction should win. + app._run_estimate_s = 9999.0 + cap = _LogCapture(widgets.Output()) + cap.set_progress_fraction(0.5) + app._active_log = cap + # 50% done at 60 s elapsed → ~60 s remaining (elapsed·(1−f)/f). + chip = app._format_elapsed_chip(60) + assert "~1:00 left" in chip + assert "(rough)" not in chip # fraction path is not "rough" + + def test_chip_ignores_tiny_fraction_and_falls_back(self): + from quantui.app import _LogCapture + + app = QuantUIApp() + app._run_estimate_s = 120.0 + cap = _LogCapture(widgets.Output()) + cap.set_progress_fraction(0.01) # below the 0.03 floor + app._active_log = cap + chip = app._format_elapsed_chip(30) + # Falls back to B1 total: 120 − 30 = 90 s. + assert "~1:30 left" in chip + + class TestStatusHeartbeat: """M-PROGRESS A2: emit_status drives run_status without touching the log.""" diff --git a/tests/test_calc_log.py b/tests/test_calc_log.py index d7b38a3..c7dd0c7 100644 --- a/tests/test_calc_log.py +++ b/tests/test_calc_log.py @@ -340,3 +340,69 @@ def test_basis_function_table_internally_consistent(isolated_log_dir): assert ( table["H"] == table["He"] ), f"{basis}: H={table['H']} but He={table['He']}, expected equal" + + +# ── B3: outer-step telemetry + geom-opt step prior ────────────────────────── + + +def _log_geom_opt(clog, *, n_steps, method="B3LYP", basis="6-31G", converged=True): + clog.log_calculation( + formula="CH2O", + n_atoms=4, + n_electrons=16, + method=method, + basis=basis, + n_iterations=10, + elapsed_s=30.0, + converged=converged, + n_basis=44, + n_cores=1, + calc_type="geometry_opt", + n_steps=n_steps, + ) + + +def test_log_calculation_records_n_steps(isolated_log_dir): + import quantui.calc_log as clog + + _log_geom_opt(clog, n_steps=7) + records = clog._read_all(clog._perf_path()) + assert records and records[-1]["n_steps"] == 7 + + +def test_estimate_opt_steps_returns_median(isolated_log_dir): + import quantui.calc_log as clog + + for n in (5, 7, 9): + _log_geom_opt(clog, n_steps=n) + assert clog.estimate_opt_steps("B3LYP", "6-31G") == 7.0 + + +def test_estimate_opt_steps_none_without_history(isolated_log_dir): + import quantui.calc_log as clog + + assert clog.estimate_opt_steps("B3LYP", "6-31G") is None + + +def test_estimate_opt_steps_excludes_unconverged_and_other_types(isolated_log_dir): + import quantui.calc_log as clog + + # Unconverged geom-opt + a single-point with n_steps must be ignored. + _log_geom_opt(clog, n_steps=99, converged=False) + clog.log_calculation( + formula="CH2O", + n_atoms=4, + n_electrons=16, + method="B3LYP", + basis="6-31G", + n_iterations=10, + elapsed_s=12.0, + converged=True, + n_basis=44, + calc_type="single_point", + n_steps=42, + ) + assert clog.estimate_opt_steps("B3LYP", "6-31G") is None + # One valid converged geom-opt → still None (needs the record to exist). + _log_geom_opt(clog, n_steps=6) + assert clog.estimate_opt_steps("B3LYP", "6-31G") == 6.0