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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 13 additions & 3 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -29,17 +29,27 @@ repos:

# ── Code formatting (black) ───────────────────────────────────────────────
- repo: https://github.com/psf/black
rev: 24.4.2
# Keep this rev, the `black` floor in pyproject.toml's `dev` extra, and
# local-setup/environment.yml in lockstep. When they drift, a bare
# `black --check` disagrees with CI — see M-CLEAN CLEAN.2.
rev: 26.5.1
hooks:
- id: black
language_version: python3
files: ^(quantui|tests)/

# ── Linting (ruff — superset of flake8) ──────────────────────────────────
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.4.7
# Keep in lockstep with the `ruff` floor in pyproject.toml's `dev` extra and
# local-setup/environment.yml. A ruff minor bump can enable rules that were
# previously folded into an ignored code (UP045 split out of UP007) — when
# bumping, run `ruff check quantui/ tests/ --statistics` and decide each new
# code deliberately rather than letting it appear as noise.
rev: v0.16.0
hooks:
- id: ruff
# `ruff-check`, not `ruff` — the bare `ruff` id still works but is a
# legacy alias in current ruff-pre-commit and reports itself as deprecated.
- id: ruff-check
args: ["--fix"] # auto-fix safe issues (unused imports, etc.)
files: ^(quantui|tests)/

Expand Down
9 changes: 6 additions & 3 deletions local-setup/environment.yml
Original file line number Diff line number Diff line change
Expand Up @@ -42,9 +42,12 @@ dependencies:
- voila>=0.5.0
# Notebook smoke testing
- nbmake>=1.4.0
# Code formatting and linting (not on conda-forge)
- black>=24.0.0
- ruff>=0.4.0
# Code formatting and linting (not on conda-forge). Pinned to a compatible
# range so a fresh env matches the revs CI enforces in
# .pre-commit-config.yaml — open floors let these drift ahead of CI, which
# makes a bare `ruff check` report rules CI never sees (M-CLEAN CLEAN.2).
- black~=26.5.1
- ruff~=0.16.0
# Install QuantUI in editable mode
# On Linux/WSL also run: conda install -c conda-forge pyscf
# (pyscf-properties is pip-only; installed here automatically)
Expand Down
23 changes: 21 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -122,8 +122,21 @@ dev = [
"pytest-xdist>=3.0.0", # parallel test execution (-n=auto in addopts)
"mypy>=1.0.0",
"types-requests>=2.28.0",
"black>=24.0.0",
"ruff>=0.4.0",
# Formatter/linter versions are pinned to a compatible range, NOT an open
# floor: they must agree with the revs in .pre-commit-config.yaml, which is
# what CI enforces. With open floors a fresh `pip install` pulls whatever is
# newest and a bare `ruff check` reports rules CI never sees (M-CLEAN CLEAN.2).
#
# black is split by Python version: 26.x requires >=3.10, but requires-python
# here is >=3.9 and 3.9 is in the CI matrix, so an unconditional ~=26.5.1
# makes the dev extra uninstallable on 3.9. 25.11.0 is the last black that
# supports 3.9. Consequence to know: a developer on 3.9 formats with 25.11 and
# could differ slightly from CI — CI's authority is the pinned pre-commit rev,
# whose lint job runs on 3.11, so run `pre-commit run` before pushing if you
# develop on 3.9. (ruff 0.16 needs only >=3.7, so it needs no split.)
"black~=26.5.1; python_version >= '3.10'",
"black~=25.11.0; python_version < '3.10'",
"ruff~=0.16.0",
"pre-commit>=3.7.0",
]

Expand Down Expand Up @@ -152,6 +165,12 @@ ignore = [
# They are style-only and do not affect correctness.
"UP006", # use built-in dict/list/tuple for annotations (requires 3.9 runtime)
"UP007", # use X | Y for unions (requires 3.10 at runtime)
# UP045 is the Optional[X] -> X | None half of UP007, which newer ruff split
# into its own code. Ignored on exactly the same grounds as UP007 above:
# requires-python is >=3.9 and 3.9 is in the CI matrix. Without this entry a
# ruff upgrade silently un-ignores a rule the project already decided against
# (205 sites), which is what happened when the pins drifted (M-CLEAN CLEAN.2).
"UP045", # use X | None instead of Optional[X] — same as UP007
"UP035", # typing.Dict/List/Tuple deprecated — same as UP006
"B904", # raise-without-from in except — cosmetic, verbatim copies
"E722", # bare except — cosmetic, verbatim copies
Expand Down
86 changes: 35 additions & 51 deletions quantui/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -592,6 +592,27 @@ def _load_last_calibration_label() -> str:
border-bottom: none !important;
}

/* Live calculation log — must stay fixed-width --------------------------- */
/* The system-font rule above lists ``.jp-OutputArea-output``, which is exactly
the element the streaming calc log renders into — so the log inherited a
PROPORTIONAL font. Two things in the header depend on fixed-width cells and
both broke together: the ASCII wordmark (letters slid into each other) and
the padded ``Label : value`` provenance rows (colons drifted out of
line even though the padding is correct). Re-assert monospace for the log
only. Two classes out-specifies the single-class rule above, and
``!important`` is required to beat its ``!important``. */
/* The first two selectors cover the historical widgets.Output rendering; the
[class*=] selector covers the LiveLog container (M-LOGSCROLL route C), whose
class carries a per-app uid suffix. LiveLog also sets the stack inline — this
is belt-and-braces, since a directly-applied rule beats an inherited one. */
.quantui-run-output .jp-OutputArea-output,
.quantui-run-output pre,
[class*="quantui-live-log"] {
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas,
"Liberation Mono", "Courier New", monospace !important;
font-variant-ligatures: none !important; /* no ligatures in ASCII art */
}

/* Section headers ------------------------------------------------------- */
h3 {
font-size: 11px !important;
Expand Down Expand Up @@ -3495,7 +3516,7 @@ def _copy_plot_data(
# Best-effort clipboard copy via the browser's clipboard API.
# Wrapped in try/catch on the JS side so a permissions error
# doesn't show up as a Voilà console exception.
from IPython.display import Javascript, display
from IPython.display import display

try:
js_payload = _json.dumps(csv_text)
Expand Down Expand Up @@ -3897,57 +3918,20 @@ def _queue_main_thread_callback(self, callback, *args, **kwargs) -> None:
callback(*args, **kwargs)

def _install_run_output_scroll_guard(self) -> None:
"""Install a JS guard that keeps the live calc log scrolled to the bottom.

Re-queries the run-output element each animation frame (ipywidgets can
replace the node) and pins it to the bottom while output is streaming.
Pinning on ``requestAnimationFrame`` runs after ipywidgets' per-line
``scrollTop = 0`` reset but before paint, so the log follows without
flicker; pinning stops once the log is idle so a finished log can be
scrolled freely.
"""No-op: superseded by :class:`quantui.live_log.LiveLog` (M-LOGSCROLL).

This used to inject a ``requestAnimationFrame`` loop that re-pinned the
live log to the bottom every frame, to out-race ipywidgets' per-line
``scrollTop = 0`` reset. That made the log follow output, but at the cost
of making it impossible to scroll up during a run — the reported bug.

Route C removed the reset instead of racing it: the log is now a
QuantUI-owned container that is appended to rather than re-rendered, so
native ``overflow-anchor`` preserves the user's scroll position and no
per-frame pinning is needed. Kept as a no-op because ``display()`` calls
it unconditionally; delete once nothing references it.
"""
if self._run_output_scroll_guard_installed:
return

js_code = r"""
(() => {
// Keep the live calc log pinned to the bottom while output streams.
//
// ipywidgets resets scrollTop to 0 on each appended line and may replace the
// Output node, so: re-query ".quantui-run-output" every animation frame and,
// while it is still growing, pin it to the bottom. Pinning on rAF runs after
// the per-line reset but before paint (no flicker); re-querying each frame
// avoids binding to a stale node. Idle logs (no growth for ~600ms) are left
// alone so they can be scrolled freely.
const ROOT_CLASS = "quantui-run-output";
let lastScrollHeight = -1;
let lastChangeTs = 0;

function frame(ts) {
const el = document.querySelector("." + ROOT_CLASS);
if (el) {
el.style.overflowAnchor = "none";
if (el.scrollHeight !== lastScrollHeight) {
lastScrollHeight = el.scrollHeight;
lastChangeTs = ts;
}
if (ts - lastChangeTs < 600) {
el.scrollTop = el.scrollHeight;
}
}
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
})();
"""

try:
with self._exit_output:
display(Javascript(js_code))
self._run_output_scroll_guard_installed = True
except Exception:
# Non-notebook contexts may not support JS display; fail silently.
self._run_output_scroll_guard_installed = False
self._run_output_scroll_guard_installed = True

def _set_molecule_state_only(self, mol) -> None:
"""Apply only thread-safe molecule state updates."""
Expand Down
30 changes: 14 additions & 16 deletions quantui/app_builders.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
import quantui
from quantui import molecule_library as _ml
from quantui.help_content import HELP_TOPICS
from quantui.live_log import LiveLog

# Friendlier labels for the library category filter.
_CATEGORY_LABELS = {
Expand Down Expand Up @@ -646,23 +647,20 @@ def build_shared_widgets(
# scrollbar that resets to the top on every backend/palette swap.
app.viz_output = widgets.Output(layout=layout_fn(height="510px", overflow="hidden"))
app.viz_output.add_class("quantui-viewer-frame")
app.run_output = widgets.Output(
layout=layout_fn(
border="1px solid #c0ccd8",
height="300px",
padding="8px",
overflow_y="auto",
)
)
# Live calc log — a QuantUI-owned scroll container, not a widgets.Output
# (M-LOGSCROLL route C). An Output rebuilds its DOM subtree and resets
# scrollTop on every appended line, which made it impossible to scroll up
# during a run; LiveLog appends text nodes to a node that is never
# re-rendered, so native overflow-anchor holds the user's position. It
# exposes the same append_stdout / clear_output / .outputs surface the app
# already used, so the write paths are unchanged. Border, height, padding
# and the monospace stack live in LiveLog's own container style.
# No marshaller needed: LiveLog ships text over a traitlet, which is
# thread-safe and independent of message parentage. (An earlier revision
# pushed display(Javascript(...)) per chunk and silently dropped every
# streaming line — see the module docstring.)
app.run_output = LiveLog(uid=str(id(app)), layout=layout_fn(margin="0"))
app.run_output.add_class("quantui-run-output")
with app.run_output:
display(
HTML(
'<p style="color:#999;font-style:italic;font-size:13px;margin:2px 0">'
"No calculation run yet. PySCF output and any errors will appear here."
"</p>"
)
)
app.result_output = widgets.Output()
app.result_viz_output = widgets.Output()
app.result_viz_output.add_class("quantui-viewer-frame")
Expand Down
12 changes: 8 additions & 4 deletions quantui/app_visualization.py
Original file line number Diff line number Diff line change
Expand Up @@ -2058,7 +2058,7 @@ def _preopt_controls_html(uid: str, n: int, interval_ms: int) -> str:
n,
interval_ms,
label_js=label_js,
initial_label="Frame %d/%d &bull; Relaxed (final)" % (n, n),
initial_label=f"Frame {n}/{n} &bull; Relaxed (final)",
loop=False, # one-shot: stop on the relaxed frame (no lingering "relaxing…")
ab_at_start="⇄ Show relaxed",
ab_other="⇄ Show input",
Expand Down Expand Up @@ -2185,7 +2185,7 @@ def build_trajectory_viewer_html(
n,
interval_ms,
label_js=label_js,
initial_label="Step %d / %d" % (n - 1, n - 1),
initial_label=f"Step {n - 1} / {n - 1}",
loop=True, # optimization animation: loop continuously
ab_at_start="⇄ Final geometry",
ab_other="⇄ First step (input)",
Expand Down Expand Up @@ -2388,8 +2388,10 @@ def _vib_bridge_set_mode(app: Any, mode_number: int) -> None:
return
from IPython.display import Javascript, display

# %-formatting is deliberate here: the payload is JavaScript, which is dense
# with braces, so an f-string or .format() would require doubling every one.
js = (
"(function(){var n=0;function go(){n++;"
"(function(){var n=0;function go(){n++;" # noqa: UP031 — see above
"if(window.__quantuiVibSetMode){window.__quantuiVibSetMode(%d,false);}"
"else if(n<40){setTimeout(go,50);}}go();})();" % int(mode_number)
)
Expand All @@ -2409,8 +2411,10 @@ def _vib_bridge_set_fps(app: Any, fps: int) -> None:
return
from IPython.display import Javascript, display

# %-formatting is deliberate here — same JavaScript brace-density reason as
# ``_vib_bridge_set_mode`` above.
js = (
"(function(){var n=0;function go(){n++;"
"(function(){var n=0;function go(){n++;" # noqa: UP031 — see above
"if(window.__quantuiVibSetFps){window.__quantuiVibSetFps(%d);}"
"else if(n<40){setTimeout(go,50);}}go();})();" % int(fps)
)
Expand Down
12 changes: 11 additions & 1 deletion quantui/calculator.py
Original file line number Diff line number Diff line change
Expand Up @@ -147,11 +147,21 @@ def get_educational_notes(self) -> str:
"Very fast but low accuracy. Good for learning, not research."
)
elif "6-31G" in self.basis:
notes.append(
note = (
"**6-31G family**: Split-valence basis sets with a good balance of "
"speed and accuracy. The * adds polarization functions for better "
"description of molecular bonding and lone pairs."
)
# 6-31G* and 6-31G(d) are the same basis set in two notations; say so
# rather than leave a reader thinking their textbook's spelling is a
# different, unavailable option.
alias = config.pople_notation_alias(self.basis)
if alias:
note += (
f" Equivalently written **{alias}** — the same basis set, "
"either spelling is accepted."
)
notes.append(note)
elif "cc-pV" in self.basis:
notes.append(
"**Correlation-consistent basis sets (cc-pVXZ)**: High-quality basis "
Expand Down
27 changes: 27 additions & 0 deletions quantui/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,33 @@
"def2-TZVP",
]


def pople_notation_alias(basis: str) -> str:
"""Return the parenthesis spelling of a starred Pople basis, else ``""``.

``6-31G*`` and ``6-31G(d)`` are the *same basis set* written two ways, and a
student who learned one notation has no way to know the other is not a
different, missing option. Both spellings are accepted by PySCF (verified
2026-07-29: identical AO counts for the ``*``/``(d)`` and ``**``/``(d,p)``
pairs), so either is safe to show and to type.

The mapping is purely notational, so it is derived rather than tabulated —
that way it also covers names not currently in ``SUPPORTED_BASIS_SETS``
(``6-311G**``, ``6-31+G*``, …) if the dropdown grows:

- trailing ``**`` → ``(d,p)`` — polarisation on heavy atoms *and* hydrogens
- trailing ``*`` → ``(d)`` — polarisation on heavy atoms only

Returns an empty string for anything without a trailing star (``6-31G``,
``STO-3G``, ``cc-pVDZ``, ``def2-SVP``), which have no alternate spelling.
"""
if basis.endswith("**"):
return f"{basis[:-2]}(d,p)"
if basis.endswith("*"):
return f"{basis[:-1]}(d)"
return ""


# Implicit solvent options — name → dielectric constant (ε)
SOLVENT_OPTIONS: Dict[str, float] = {
"Water": 78.39,
Expand Down
9 changes: 9 additions & 0 deletions quantui/descriptor_cards.py
Original file line number Diff line number Diff line change
Expand Up @@ -174,4 +174,13 @@ def basis_card_html(basis: str) -> str:
fg, bg, icon = _BASIS_FAMILY_STYLE[fam]
fam_label, body = _BASIS_COPY[fam]
title = f"{basis} · {fam_label}"
# Starred Pople sets have an equivalent parenthesis spelling that textbooks
# use interchangeably (6-31G* == 6-31G(d)). Show it so the dropdown entry is
# recognisable to someone who only knows the other form. Deliberately ONE
# short line: these cards exist because the previous inline notes were "a lot
# of word clutter" (FR-DESCRIPTOR-CARDS), so the full notation table lives in
# the basis-set help topic instead.
alias = config.pople_notation_alias(basis)
if alias:
body += f' <span style="color:#64748b">Also written <b>{alias}</b>.</span>'
return _card_html(fg=fg, bg=bg, icon=icon, title=title, body=body)
33 changes: 33 additions & 0 deletions quantui/help_content.py
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,39 @@
"<p><b>Recommendation:</b> Start with <b>STO-3G</b> for learning. "
"Use <b>6-31G*</b> for serious work. Only use cc-pVTZ if you need "
"high-accuracy results and have time to wait.</p>"
# UXP2.1: the two Pople notations are a recurring source of
# confusion — a reader who only knows 6-31G(d) can conclude the
# 6-31G* in the dropdown is a different set they can't select.
"<h4 style='margin:14px 0 4px'>Reading the names: two notations, "
"one basis set</h4>"
"<p>Pople basis sets are written two equivalent ways. The starred "
"and parenthesised forms are <b>the same basis set</b> — QuantUI's "
"dropdown uses the starred spelling, and either is accepted:</p>"
"<table style='border-collapse:collapse; margin:4px 0 8px 0;'>"
"<tr style='border-bottom:1px solid #ddd;'>"
" <th style='padding:3px 12px; text-align:left;'>Starred</th>"
" <th style='padding:3px 12px; text-align:left;'>Parenthesised</th>"
" <th style='padding:3px 12px; text-align:left;'>Adds</th></tr>"
"<tr><td style='padding:3px 12px;'><b>6-31G*</b></td>"
" <td style='padding:3px 12px;'><b>6-31G(d)</b></td>"
" <td style='padding:3px 12px;'>d functions on heavy atoms</td></tr>"
"<tr><td style='padding:3px 12px;'><b>6-31G**</b></td>"
" <td style='padding:3px 12px;'><b>6-31G(d,p)</b></td>"
" <td style='padding:3px 12px;'>…plus p functions on hydrogens</td></tr>"
"</table>"
"<p>Two more markers you will meet in the literature:</p>"
"<ul>"
"<li><b>+ and ++</b> add <i>diffuse</i> functions, which extend the "
"basis further from the nucleus — needed for <b>anions</b>, lone "
"pairs and weakly-bound species. <code>6-31+G*</code> puts them on "
"heavy atoms; <code>6-31++G**</code> on hydrogens too.</li>"
"<li><b>Dunning sets have no star notation at all.</b> "
"<code>cc-pVDZ</code> / <code>cc-pVTZ</code> include polarisation "
"by construction (that is the <i>p</i> in pV), so a missing "
"<code>*</code> does not mean a missing feature. The diffuse "
"counterpart is the <code>aug-</code> prefix "
"(<code>aug-cc-pVDZ</code>), not a <code>+</code>.</li>"
"</ul>"
),
},
"homo_lumo": {
Expand Down
Loading