diff --git a/quantui/app.py b/quantui/app.py index cc40a7f..9ea1672 100644 --- a/quantui/app.py +++ b/quantui/app.py @@ -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, ) @@ -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 @@ -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 @@ -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)) @@ -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: @@ -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. @@ -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 @@ -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. @@ -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() diff --git a/quantui/app_builders.py b/quantui/app_builders.py index 746a9b3..7f414ec 100644 --- a/quantui/app_builders.py +++ b/quantui/app_builders.py @@ -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() @@ -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", @@ -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( @@ -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], @@ -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( diff --git a/quantui/app_runflow.py b/quantui/app_runflow.py index f0dea85..a0e5886 100644 --- a/quantui/app_runflow.py +++ b/quantui/app_runflow.py @@ -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 @@ -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 @@ -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 = ( + '' + f"⚠ Open-shell: {n_unpaired} unpaired electron{plural} " + f"(multiplicity {mult}). RHF assumes all electrons are paired — " + "switch to UHF (or a DFT method) for this system." + ) + else: + # Informational: UHF / DFT already handle open-shell correctly. + app._open_shell_hint.value = ( + '' + f"Open-shell: {n_unpaired} unpaired electron{plural} " + f"(multiplicity {mult}) — running unrestricted." + ) + app._open_shell_hint.layout.display = "" def update_estimate(app: Any, *, calc_log_mod: Any, change: Any = None) -> None: diff --git a/quantui/help_content.py b/quantui/help_content.py index e6431d7..7d800f7 100644 --- a/quantui/help_content.py +++ b/quantui/help_content.py @@ -48,6 +48,51 @@ "context-sensitive help on that specific option.

" ), }, + "calc_type": { + "title": "Calculation types — what does each one do?", + "body": ( + "

The Calc. Type dropdown chooses what QuantUI computes for " + "your molecule at the selected method / basis.

" + "" + "" + " " + " " + " " + "" + " " + " " + "" + " " + " " + "" + " " + " " + "" + " " + " " + "" + " " + " " + "" + " " + " " + "" + " " + " " + "
TypeComputesCost
Single PointEnergy + properties at the input " + "geometry (no atoms move)Fastest
Geometry OptRelaxes the structure to a minimum-" + "energy geometryModerate
FrequencyVibrational modes + IR spectrum; " + "confirms a true minimum (no imaginary modes)Higher
UV-Vis (TD-DFT)Electronic excitations / absorption " + "spectrum (needs a DFT functional)Higher
NMR ShieldingIsotropic shielding → predicted " + "¹H / ¹³C chemical shiftsHigher
PES ScanEnergy profile along one bond / " + "angle / dihedral coordinateMany points
Reorganization EnergyMarcus 4-point λ for charge transfer " + "(hole / electron channels)Slowest (2–3 optimizations)
" + "

Typical workflow: optimize the geometry first " + "(Geometry Opt), then run Frequency, UV-Vis, or " + "NMR on the optimized structure for meaningful results — most " + "spectra are only valid at a minimum-energy geometry.

" + ), + }, "method": { "title": "RHF vs UHF — which method should I use?", "body": ( diff --git a/tests/test_app.py b/tests/test_app.py index 7db3740..a88c50e 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -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. @@ -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() @@ -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"