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
31 changes: 11 additions & 20 deletions quantui/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,9 @@
from quantui.app_runflow import (
on_calc_type_changed as _run_on_calc_type_changed,
)
from quantui.app_runflow import (
on_calc_type_help as _run_on_calc_type_help,
)
from quantui.app_runflow import (
on_clear as _run_on_clear,
)
Expand Down Expand Up @@ -972,6 +975,7 @@ class QuantUIApp:
basis_help_btn: Any
calc_extra_opts: Any
calc_type_dd: Any
calc_type_help_btn: Any
charge_si: Any
clear_btn: Any
_completion_banner: Any
Expand All @@ -993,6 +997,7 @@ class QuantUIApp:
_method_card_html: Any
_basis_card_html: Any
_descriptor_cards_box: Any
_open_shell_hint: Any
nstates_si: Any
perf_estimate_html: Any
post_calc_panel: Any
Expand Down Expand Up @@ -1677,14 +1682,16 @@ def _wire_callbacks(self) -> None:
# Notes + estimate
self.method_dd.observe(self._safe_cb(self._update_notes), names="value")
self.basis_dd.observe(self._safe_cb(self._update_notes), names="value")
# Multiplicity drives the open-shell hint (part of _update_notes).
self.mult_si.observe(self._safe_cb(self._update_notes), names="value")
self.method_dd.observe(self._safe_cb(self._update_estimate), names="value")
self.basis_dd.observe(self._safe_cb(self._update_estimate), names="value")
# Help buttons
self.method_help_btn.on_click(self._on_method_help)
self.basis_help_btn.on_click(self._on_basis_help)
self.calc_type_help_btn.on_click(self._on_calc_type_help)
# Run
self.run_btn.on_click(self._on_run_clicked)
self._reorg_auto_btn.on_click(self._on_reorg_auto_clicked)
self.cancel_btn.on_click(self._safe_cb(self._on_cancel))
self.preopt_preview_btn.on_click(self._safe_cb(self._on_preopt_preview))
self.preopt_accept_btn.on_click(self._safe_cb(self._on_preopt_accept))
Expand Down Expand Up @@ -3014,6 +3021,9 @@ def _on_method_help(self, btn) -> None:
def _on_basis_help(self, btn) -> None:
_run_on_basis_help(self, btn)

def _on_calc_type_help(self, btn) -> None:
_run_on_calc_type_help(self, btn)

# ── Run ───────────────────────────────────────────────────────────────

def _on_run_clicked(self, btn) -> None:
Expand All @@ -3024,22 +3034,6 @@ def _on_run_clicked(self, btn) -> None:
)
_run_on_run_clicked(self, btn)

def _on_reorg_auto_clicked(self, btn) -> None:
"""One-click reorganization energy: set up the mode, then run.

Switches the calc-type to "Reorganization Energy" (which reveals the
channel selector via the calc-type observer), defaults the channel to
both hole + electron, and immediately launches the 4-point run.
"""
if self._molecule is None:
self.run_status.value = "Load a molecule first."
return
# Setting the dropdown value fires _on_calc_type_changed synchronously,
# which swaps calc_extra_opts to show the channel selector + note.
self.calc_type_dd.value = "Reorganization Energy"
self._reorg_mode_dd.value = "both"
self._on_run_clicked(btn)

def _on_cancel(self, btn=None) -> None:
"""Request graceful cancellation of the in-flight calculation.

Expand Down Expand Up @@ -3639,7 +3633,6 @@ def _set_molecule(self, mol: Molecule, label: str = "") -> None:
"""Update shared state and refresh dependent widgets."""
self._molecule = mol
self.run_btn.disabled = False
self._reorg_auto_btn.disabled = False
self.export_btn.disabled = False
self.export_xyz_btn.disabled = False
self.export_mol_btn.disabled = not _RDKIT_AVAILABLE
Expand Down Expand Up @@ -4021,7 +4014,6 @@ def _do_run(self) -> None:
kind="compute",
)
self.run_btn.disabled = True
self._reorg_auto_btn.disabled = True
self.run_status.value = "Starting..."
# Run-in-flight state: arm Cancel, lock out Clear (so it can't wipe the
# live output mid-run), reset the cancel flag for this fresh run.
Expand Down Expand Up @@ -5018,7 +5010,6 @@ def _run_required_final_single_point(target_mol, reason: str):

finally:
self.run_btn.disabled = False
self._reorg_auto_btn.disabled = self._molecule is None
# Disarm Cancel, re-enable Clear, clear run-in-flight state.
self._calc_running = False
self._cancel_event.clear()
Expand Down
47 changes: 27 additions & 20 deletions quantui/app_builders.py
Original file line number Diff line number Diff line change
Expand Up @@ -578,9 +578,22 @@ def build_shared_widgets(

app._method_card_html = widgets.HTML(value=method_card_html(default_method))
app._basis_card_html = widgets.HTML(value=basis_card_html(default_basis))
app._descriptor_cards_box = widgets.VBox(
# Cards sit side-by-side (horizontal) so the setup row stays short — a
# vertical stack made the row tall and left a large gap above Calc. Type.
# flex_wrap lets the pair wrap gracefully on a narrow window.
app._descriptor_cards_box = widgets.HBox(
[app._method_card_html, app._basis_card_html],
layout=layout_fn(margin="0 0 0 4px"),
layout=layout_fn(
gap="8px",
align_items="flex-start",
flex_wrap="wrap",
margin="0 0 0 4px",
),
)
# Open-shell hint — shown only when multiplicity > 1 (updated by
# ``update_notes``). Restores the guidance the old notes block carried.
app._open_shell_hint = widgets.HTML(
value="", layout=layout_fn(display="none", margin="2px 0 0 0")
)
app.perf_estimate_html = widgets.HTML()

Expand Down Expand Up @@ -893,6 +906,12 @@ def build_shared_widgets(
layout=layout_fn(width="28px", height="28px"),
tooltip="Choosing a basis set — opens Help tab",
)
app.calc_type_help_btn = widgets.Button(
description="?",
button_style="",
layout=layout_fn(width="28px", height="28px"),
tooltip="What each calculation type computes — opens Help tab",
)

app.run_btn = widgets.Button(
description="Run Calculation",
Expand All @@ -903,21 +922,6 @@ def build_shared_widgets(
)
app.run_status = widgets.Label()

# One-click reorganization energy: switches the calc type to
# "Reorganization Energy", applies sensible defaults (both channels), and
# immediately launches the 4-point run.
app._reorg_auto_btn = widgets.Button(
description="Calc. Reorganization Energy",
button_style="warning",
icon="bolt",
disabled=True,
layout=layout_fn(width="250px", height="36px"),
tooltip=(
"Set up and run the 4-point Marcus reorganization energy "
"(hole + electron) on the loaded molecule"
),
)

# Gracefully stops a running calculation at the next SCF cycle / opt step.
# Disabled unless a calc is in flight (toggled by _do_run).
app.cancel_btn = widgets.Button(
Expand Down Expand Up @@ -1315,7 +1319,11 @@ def build_calc_setup(app: Any, *, layout_fn: Any) -> None:
],
layout=layout_fn(flex_wrap="wrap", align_items="flex-start"),
),
app.calc_type_dd,
app._open_shell_hint,
widgets.HBox(
[app.calc_type_dd, app.calc_type_help_btn],
layout=layout_fn(align_items="center", gap="4px"),
),
app.calc_extra_opts,
widgets.HBox(
[app.preopt_preview_label, app.preopt_preview_btn],
Expand Down Expand Up @@ -1343,10 +1351,9 @@ def build_run_section(app: Any, *, layout_fn: Any) -> None:
),
app.perf_estimate_html,
widgets.HBox(
[app.run_btn, app._reorg_auto_btn, app.run_status],
[app.run_btn, app.cancel_btn, app.run_status],
layout=layout_fn(align_items="center", gap="8px"),
),
widgets.HBox([app.run_btn, app.cancel_btn, app.run_status]),
widgets.HBox(
[
widgets.HTML(
Expand Down
46 changes: 42 additions & 4 deletions quantui/app_runflow.py
Original file line number Diff line number Diff line change
Expand Up @@ -784,6 +784,12 @@ def on_basis_help(app: Any, btn: Any) -> None:
app._show_help_topic("basis_set")


def on_calc_type_help(app: Any, btn: Any) -> None:
"""Open help overlay focused on calculation-type guidance."""
_ = btn
app._show_help_topic("calc_type")


def on_exit_clicked(app: Any, _unused: Any = None) -> None:
"""Update UI and request shutdown of Voilà/Jupyter parent and kernel."""
import os
Expand Down Expand Up @@ -1107,11 +1113,12 @@ def _progress(


def update_notes(app: Any, change: Any = None) -> None:
"""Refresh the method / basis descriptor cards (UXP.7).
"""Refresh the method / basis descriptor cards + open-shell hint.

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.
Replaces the old inline educational-notes text block (UXP.7). 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.
"""
try:
from quantui.descriptor_cards import basis_card_html, method_card_html
Expand All @@ -1120,6 +1127,37 @@ def update_notes(app: Any, change: Any = None) -> None:
app._basis_card_html.value = basis_card_html(app.basis_dd.value)
except Exception:
pass
_update_open_shell_hint(app)


def _update_open_shell_hint(app: Any) -> None:
"""Show/hide the open-shell (multiplicity > 1 → UHF) guidance hint."""
try:
mult = int(app.mult_si.value)
except Exception:
mult = 1
if mult <= 1:
app._open_shell_hint.value = ""
app._open_shell_hint.layout.display = "none"
return
n_unpaired = mult - 1
plural = "s" if n_unpaired != 1 else ""
if app.method_dd.value.upper() == "RHF":
# Actionable: RHF is the one method that will misbehave for open-shell.
app._open_shell_hint.value = (
'<span style="font-size:12px;color:#b45309">'
f"⚠ Open-shell: {n_unpaired} unpaired electron{plural} "
f"(multiplicity {mult}). RHF assumes all electrons are paired — "
"switch to <b>UHF</b> (or a DFT method) for this system.</span>"
)
else:
# Informational: UHF / DFT already handle open-shell correctly.
app._open_shell_hint.value = (
'<span style="font-size:12px;color:#64748b">'
f"Open-shell: {n_unpaired} unpaired electron{plural} "
f"(multiplicity {mult}) — running unrestricted.</span>"
)
app._open_shell_hint.layout.display = ""


def update_estimate(app: Any, *, calc_log_mod: Any, change: Any = None) -> None:
Expand Down
45 changes: 45 additions & 0 deletions quantui/help_content.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,51 @@
"context-sensitive help on that specific option.</p>"
),
},
"calc_type": {
"title": "Calculation types — what does each one do?",
"body": (
"<p>The <b>Calc. Type</b> dropdown chooses what QuantUI computes for "
"your molecule at the selected method / basis.</p>"
"<table style='border-collapse:collapse; margin:6px 0;'>"
"<tr style='border-bottom:1px solid #ccc;'>"
" <th style='padding:3px 12px; text-align:left;'>Type</th>"
" <th style='padding:3px 12px; text-align:left;'>Computes</th>"
" <th style='padding:3px 12px; text-align:left;'>Cost</th></tr>"
"<tr><td style='padding:3px 12px;'><b>Single Point</b></td>"
" <td style='padding:3px 12px;'>Energy + properties at the input "
"geometry (no atoms move)</td>"
" <td style='padding:3px 12px;'>Fastest</td></tr>"
"<tr><td style='padding:3px 12px;'><b>Geometry Opt</b></td>"
" <td style='padding:3px 12px;'>Relaxes the structure to a minimum-"
"energy geometry</td>"
" <td style='padding:3px 12px;'>Moderate</td></tr>"
"<tr><td style='padding:3px 12px;'><b>Frequency</b></td>"
" <td style='padding:3px 12px;'>Vibrational modes + IR spectrum; "
"confirms a true minimum (no imaginary modes)</td>"
" <td style='padding:3px 12px;'>Higher</td></tr>"
"<tr><td style='padding:3px 12px;'><b>UV-Vis (TD-DFT)</b></td>"
" <td style='padding:3px 12px;'>Electronic excitations / absorption "
"spectrum (needs a DFT functional)</td>"
" <td style='padding:3px 12px;'>Higher</td></tr>"
"<tr><td style='padding:3px 12px;'><b>NMR Shielding</b></td>"
" <td style='padding:3px 12px;'>Isotropic shielding → predicted "
"¹H / ¹³C chemical shifts</td>"
" <td style='padding:3px 12px;'>Higher</td></tr>"
"<tr><td style='padding:3px 12px;'><b>PES Scan</b></td>"
" <td style='padding:3px 12px;'>Energy profile along one bond / "
"angle / dihedral coordinate</td>"
" <td style='padding:3px 12px;'>Many points</td></tr>"
"<tr><td style='padding:3px 12px;'><b>Reorganization Energy</b></td>"
" <td style='padding:3px 12px;'>Marcus 4-point λ for charge transfer "
"(hole / electron channels)</td>"
" <td style='padding:3px 12px;'>Slowest (2–3 optimizations)</td></tr>"
"</table>"
"<p><b>Typical workflow:</b> optimize the geometry first "
"(<b>Geometry Opt</b>), then run <b>Frequency</b>, <b>UV-Vis</b>, or "
"<b>NMR</b> on the optimized structure for meaningful results — most "
"spectra are only valid at a minimum-energy geometry.</p>"
),
},
"method": {
"title": "RHF vs UHF — which method should I use?",
"body": (
Expand Down
66 changes: 55 additions & 11 deletions tests/test_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -706,6 +706,50 @@ def test_script_filename_sanitizes_basis_with_asterisk(self, tmp_path, monkeypat
assert "Error" not in app.export_status.value


class TestCalcTypeHelp:
"""A '?' help button next to Calc. Type opens the calc_type help topic."""

def test_calc_type_help_topic_exists(self):
from quantui.help_content import HELP_TOPICS

assert "calc_type" in HELP_TOPICS
# Covers every calc type the dropdown offers.
body = HELP_TOPICS["calc_type"]["body"]
for label in ("Single Point", "Geometry Opt", "Reorganization Energy"):
assert label in body

def test_help_button_opens_calc_type_topic(self):
app = QuantUIApp()
app._on_calc_type_help(None)
assert app.help_topic_dd.value == "calc_type"


class TestOpenShellHint:
"""The open-shell hint appears only when multiplicity > 1 (UXP.7 restore)."""

def test_hidden_for_singlet(self):
app = QuantUIApp()
app.mult_si.value = 1
assert app._open_shell_hint.layout.display == "none"

def test_shown_and_warns_for_rhf_open_shell(self):
app = QuantUIApp()
app.method_dd.value = "RHF"
app.mult_si.value = 2
assert app._open_shell_hint.layout.display == ""
val = app._open_shell_hint.value
assert "UHF" in val
assert "unpaired electron" in val

def test_informational_for_uhf_open_shell(self):
app = QuantUIApp()
app.method_dd.value = "UHF"
app.mult_si.value = 3
assert app._open_shell_hint.layout.display == ""
# UHF already handles open-shell → no "switch to UHF" nag.
assert "unrestricted" in app._open_shell_hint.value


class TestDescriptorCards:
"""UXP.7: method / basis descriptor cards replace the inline notes block.

Expand Down Expand Up @@ -1019,7 +1063,14 @@ def test_switching_away_from_nmr_clears_opts(self):


class TestReorganizationEnergyUI:
"""UI wiring for the Reorganization Energy calc-type + auto-setup button."""
"""UI wiring for the Reorganization Energy calc-type.

The reorg run is driven entirely through the calc-type dropdown + the
channel-mode selector it reveals + the shared Run Calculation button
(``_do_run`` has a ``reorganization_energy`` branch). The old dedicated
"Calc. Reorganization Energy" auto-button was removed as a redundant
duplicate of that path.
"""

def test_reorg_mode_shows_channel_selector(self):
app = QuantUIApp()
Expand All @@ -1034,19 +1085,12 @@ def test_reorg_hides_preopt_checkbox(self):
app.calc_type_dd.value = "Reorganization Energy"
assert app._freq_preopt_cb.layout.display == "none"

def test_auto_button_enabled_after_molecule_load(self):
app = QuantUIApp()
assert app._reorg_auto_btn.disabled is True
mol = Molecule(["H", "H"], [[0, 0, 0], [0, 0, 0.74]])
app._set_molecule(mol)
assert app._reorg_auto_btn.disabled is False

def test_auto_button_sets_up_mode(self):
def test_reorg_calc_type_sets_up_mode(self):
app = QuantUIApp()
mol = Molecule(["H", "H"], [[0, 0, 0], [0, 0, 0.74]])
app._set_molecule(mol)
# Drive only the setup portion (not the background run thread) by
# replicating what the handler does before dispatch.
# Selecting the calc type reveals the channel selector; Run Calculation
# then dispatches the reorg run via _do_run's reorg branch.
app.calc_type_dd.value = "Reorganization Energy"
app._reorg_mode_dd.value = "both"
assert app.calc_type_dd.value == "Reorganization Energy"
Expand Down
Loading