From 9ee935a082755b105d680f8799f47dfa884df9c2 Mon Sep 17 00:00:00 2001 From: Jada-lee Chung Date: Fri, 12 Jun 2026 10:43:05 -0400 Subject: [PATCH 1/3] Update launch-native.command --- launch-native.command | 2 ++ 1 file changed, 2 insertions(+) mode change 100644 => 100755 launch-native.command diff --git a/launch-native.command b/launch-native.command old mode 100644 new mode 100755 index 0146a11..7d9379a --- a/launch-native.command +++ b/launch-native.command @@ -21,6 +21,8 @@ echo # we try a few common fallback locations before giving up. CONDA_SH="" for candidate in \ + "/opt/miniconda3/etc/profile.d/conda.sh" \ + "/opt/miniconda3/condabin/conda" \ "$HOME/miniconda3/etc/profile.d/conda.sh" \ "$HOME/opt/miniconda3/etc/profile.d/conda.sh" \ "/opt/homebrew/Caskroom/miniconda/base/etc/profile.d/conda.sh" \ From 8f952ddafdb1cb67fb7f6a2d56985df483fb01b7 Mon Sep 17 00:00:00 2001 From: Jada-lee Chung Date: Sat, 4 Jul 2026 12:59:12 -0400 Subject: [PATCH 2/3] Add reorganization energy (Marcus 4-point) calculation mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a new "Reorganization Energy" calculation type that computes the internal reorganization energy λ for hole (cation) and/or electron (anion) charge transfer using the standard 4-point scheme. It optimizes the neutral and ion geometries and evaluates the four single-point energies, reporting λ with its λ₁ (ion) and λ₂ (neutral) relaxation components in eV and kcal/mol. mode="both" shares the neutral optimization across both channels; open-shell HF ions are auto-promoted to UHF. Also adds a one-click "Calc. Reorganization Energy" button that sets up the mode (channel defaults to both hole + electron) and launches the run. - New module quantui/reorganization_energy.py (run_reorganization_energy, ReorganizationEnergyResult, ReorgChannelResult), exported from the package - Wired into the calc-type dropdown, run dispatch, result formatter, log header, history badge/colors/labels, and time estimator - Tests: pure-logic unit tests + app UI wiring tests Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 14 + quantui/__init__.py | 12 + quantui/app.py | 60 +++- quantui/app_builders.py | 43 ++- quantui/app_formatters.py | 46 +++ quantui/app_runflow.py | 11 +- quantui/log_utils.py | 2 + quantui/reorganization_energy.py | 431 ++++++++++++++++++++++++++++ quantui/results_storage.py | 2 + tests/test_app.py | 40 ++- tests/test_reorganization_energy.py | 147 ++++++++++ 11 files changed, 803 insertions(+), 5 deletions(-) create mode 100644 quantui/reorganization_energy.py create mode 100644 tests/test_reorganization_energy.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 68f34ec..255f958 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,20 @@ and this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.ht ## [Unreleased] +### Added + +- **Reorganization energy (Marcus 4-point)** — a new "Reorganization Energy" + calculation type that computes the internal reorganization energy λ for hole + (cation) and/or electron (anion) charge transfer. It optimizes the neutral and + ion geometries and evaluates the four single-point energies of the 4-point + scheme (λ = [E_ion(R_neutral) − E_ion(R_ion)] + [E_neutral(R_ion) − + E_neutral(R_neutral)]), reporting λ, its λ₁ (ion) and λ₂ (neutral) relaxation + components, in eV and kcal/mol. `mode="both"` shares the neutral optimization + across both channels. Open-shell HF ions are automatically promoted to UHF. +- **One-click "Calc. Reorganization Energy" button** — sets the calculation type + to Reorganization Energy, defaults the channel to both hole + electron, and + launches the run in a single click. + ## [0.3.0] - 2026-06-11 Structure-sourcing release. Repairs the external-database structure search and diff --git a/quantui/__init__.py b/quantui/__init__.py index f9648d2..3e29650 100644 --- a/quantui/__init__.py +++ b/quantui/__init__.py @@ -121,6 +121,15 @@ except ImportError: pass +# Reorganization energy — Marcus 4-point (optional — requires optimizer stack) +try: + from .reorganization_energy import ( # noqa: F401 + ReorganizationEnergyResult, + run_reorganization_energy, + ) +except ImportError: + pass + # PubChem integration (optional — requires internet) try: from .cactus import fetch_from_cactus @@ -249,6 +258,9 @@ # QM geometry optimizer (optional — Linux/WSL) "OptimizationResult", "optimize_geometry", + # Reorganization energy — Marcus 4-point (optional — Linux/WSL) + "ReorganizationEnergyResult", + "run_reorganization_energy", # PubChem (optional) "fetch_molecule", "fetch_structure", diff --git a/quantui/app.py b/quantui/app.py index e06a10a..5c30c11 100644 --- a/quantui/app.py +++ b/quantui/app.py @@ -161,6 +161,9 @@ from quantui.app_formatters import ( format_pes_scan_result as _fmt_pes_scan_result, ) +from quantui.app_formatters import ( + format_reorg_result as _fmt_reorg_result, +) from quantui.app_formatters import ( format_result as _fmt_result, ) @@ -1507,6 +1510,7 @@ def _wire_callbacks(self) -> None: self.basis_help_btn.on_click(self._on_basis_help) # Run self.run_btn.on_click(self._on_run_clicked) + self._reorg_auto_btn.on_click(self._on_reorg_auto_clicked) self.log_clear_btn.on_click(self._on_clear_log) self._ir_mode_toggle.observe( self._safe_cb(self._on_ir_mode_changed), names="value" @@ -2733,6 +2737,22 @@ 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_solvent_cb_changed(self, change) -> None: _run_on_solvent_cb_changed(self, change) @@ -3299,6 +3319,7 @@ 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 @@ -3658,6 +3679,7 @@ def _do_run(self) -> None: kind="compute", ) self.run_btn.disabled = True + self._reorg_auto_btn.disabled = True self.run_status.value = "Starting..." self.step_progress.complete(1) @@ -3689,6 +3711,7 @@ def _do_run(self) -> None: "UV-Vis (TD-DFT)": "tddft", "NMR Shielding": "nmr", "PES Scan": "pes_scan", + "Reorganization Energy": "reorganization_energy", }.get(self.calc_type_dd.value, "single_point") _nb_for_est = _calc_log.count_basis_functions( mol.atoms, self.basis_dd.value @@ -3792,6 +3815,7 @@ def _run_required_final_single_point(target_mol, reason: str): "UV-Vis (TD-DFT)": "tddft", "NMR Shielding": "nmr", "PES Scan": "pes_scan", + "Reorganization Energy": "reorganization_energy", }.get(self.calc_type_dd.value, "single_point") log.write( _fmt_log_hdr( @@ -4173,6 +4197,26 @@ def _run_required_final_single_point(target_mol, reason: str): } } save_type = "pes_scan" + elif ct == "Reorganization Energy": + self.run_status.value = "Computing reorganization energy..." + from quantui.reorganization_energy import ( + run_reorganization_energy, + ) + + _solvent = self.solvent_dd.value if self.solvent_cb.value else None + result = run_reorganization_energy( + molecule=calc_mol, + mode=self._reorg_mode_dd.value, + method=self.method_dd.value, + basis=self.basis_dd.value, + fmax=self.fmax_fi.value, + steps=self.max_steps_si.value, + progress_stream=log, # type: ignore[arg-type] + solvent=_solvent, + ) + result_html = self._format_reorg_result(result) + save_spectra = result.to_spectra() + save_type = "reorganization_energy" else: # Single Point self.run_status.value = "Calculating..." from quantui import run_in_session @@ -4212,13 +4256,23 @@ def _run_required_final_single_point(target_mol, reason: str): self.run_status.value = "Finalizing results..." # Show 3D structure in the result panel and mirrored in Analysis tab - _viz_mol = result.molecule if ct == "Geometry Opt" else calc_mol + _viz_mol = ( + result.molecule + if ct in ("Geometry Opt", "Reorganization Energy") + else calc_mol + ) if ct == "Geometry Opt": self._viz_label.value = ( '

Optimized geometry

' ) self._viz_label.layout.display = "" + elif ct == "Reorganization Energy": + self._viz_label.value = ( + '

Optimized neutral geometry

' + ) + self._viz_label.layout.display = "" self._queue_main_thread_callback( self._show_result_3d, _viz_mol, @@ -4583,6 +4637,7 @@ 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 self._activity_end(kind="compute") def _update_notes(self, change=None) -> None: @@ -4864,6 +4919,9 @@ def _format_nmr_result(self, r) -> str: def _format_pes_scan_result(self, r) -> str: return _fmt_pes_scan_result(r) + def _format_reorg_result(self, r) -> str: + return _fmt_reorg_result(r) + def _show_pes_scan_result(self, result) -> bool: return _viz_show_pes_scan_result(self, result) diff --git a/quantui/app_builders.py b/quantui/app_builders.py index ded4d8c..bbf9459 100644 --- a/quantui/app_builders.py +++ b/quantui/app_builders.py @@ -636,6 +636,7 @@ def build_shared_widgets( "UV-Vis (TD-DFT)", "NMR Shielding", "PES Scan", + "Reorganization Energy", ], value="Single Point", description="Calc. Type:", @@ -784,6 +785,28 @@ def build_shared_widgets( 'Å' ) + # Reorganization energy (Marcus 4-point). The mode selector chooses which + # charge-transfer channel(s) to compute; "Both" shares the neutral geometry + # optimization across the hole and electron channels. + app._reorg_mode_dd = widgets.Dropdown( + options=[ + ("Both (hole + electron)", "both"), + ("Hole (cation, +1)", "hole"), + ("Electron (anion, −1)", "electron"), + ], + value="both", + description="Channel:", + style={"description_width": "80px"}, + layout=layout_fn(width="320px"), + tooltip="Which reorganization energy channel(s) to compute", + ) + app._reorg_note = widgets.HTML( + '' + "4-point Marcus scheme: optimizes the neutral and ion geometries, then " + "evaluates the four single-point energies to obtain λ. Runs 2–3 geometry " + "optimizations, so it is slower than a single calculation." + ) + app.calc_extra_opts = widgets.VBox([]) app.method_help_btn = widgets.Button( @@ -808,6 +831,21 @@ 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" + ), + ) + app.log_clear_btn = widgets.Button( description="Clear", button_style="", @@ -1213,7 +1251,10 @@ def build_run_section(app: Any, *, layout_fn: Any) -> None: "sets may take several minutes on a laptop.

" ), app.perf_estimate_html, - widgets.HBox([app.run_btn, app.run_status]), + widgets.HBox( + [app.run_btn, app._reorg_auto_btn, app.run_status], + layout=layout_fn(align_items="center", gap="8px"), + ), widgets.HBox( [ widgets.HTML( diff --git a/quantui/app_formatters.py b/quantui/app_formatters.py index 292cda8..fe6b17a 100644 --- a/quantui/app_formatters.py +++ b/quantui/app_formatters.py @@ -347,6 +347,52 @@ def format_pes_scan_result(r: Any) -> str: ) +def format_reorg_result(r: Any) -> str: + """Format a reorganization-energy (Marcus 4-point) result card.""" + _conv = "Yes" if r.converged else "No (some steps did not converge)" + _cc = "green" if r.converged else "#c00" + + def _channel_block(ch: Any) -> str: + rows = "".join( + f'{k}' + f'{v}' + for k, v in [ + ("λ", f"{ch.lambda_ev:.4f} eV ({ch.lambda_kcal:.2f} kcal/mol)"), + ("λ₁ ion relaxation", f"{ch.lambda1_hartree * 27.211386245988:.4f} eV"), + ( + "λ₂ neutral relaxation", + f"{ch.lambda2_hartree * 27.211386245988:.4f} eV", + ), + ( + "Ion state", + f"charge {ch.ion_charge:+d}, mult {ch.ion_multiplicity}", + ), + ] + ) + return ( + f'
' + f'{ch.label}' + f'' + f"{rows}
" + ) + + _channels_html = "".join(_channel_block(ch) for ch in r.channels) + return ( + f'
' + f"Reorganization Energy (Marcus 4-point) — " + f"{r.formula} ({r.method}/{r.basis})" + f'' + f'' + f'' + f'' + f'' + f'' + f'' + f"
Neutral energy{r.neutral_energy_hartree:.8f} Ha
Total opt steps{r.n_total_opt_steps}
All converged{_conv}
{_channels_html}
" + ) + + def format_past_result(data: dict[str, Any], result_dir: Optional[Path] = None) -> str: """Format a saved result.json payload as an HTML result card.""" import base64 as _b64 diff --git a/quantui/app_runflow.py b/quantui/app_runflow.py index 40f9b19..b7dae40 100644 --- a/quantui/app_runflow.py +++ b/quantui/app_runflow.py @@ -18,6 +18,7 @@ def _calc_type_badge(calc_type: str) -> str: "tddft": "UV-Vis", "nmr": "NMR", "pes_scan": "PES", + "reorganization_energy": "Reorg", }.get(calc_type, calc_type or "Unknown") @@ -51,7 +52,10 @@ def on_calc_type_changed(app: Any, change: Any, *, layout_fn: Any) -> None: # workflow). POLISH.9: this was called "pre-optimisation" pre-2026-05-25; # the underlying operation is a full DFT geom-opt — distinct from the # LJ classical pre-opt in quantui/preopt.py. - if ct == "Geometry Opt": + # Reorganization Energy runs its own neutral + ion optimizations, so the + # standalone "geometry optimization before this calc" checkbox is + # meaningless there too (as with Geometry Opt itself). + if ct in ("Geometry Opt", "Reorganization Energy"): app._freq_preopt_cb.value = False app._freq_preopt_cb.layout.display = "none" else: @@ -97,6 +101,11 @@ def on_calc_type_changed(app: Any, change: Any, *, layout_fn: Any) -> None: "Start from an optimised geometry for best accuracy." ), ] + elif ct == "Reorganization Energy": + app.calc_extra_opts.children = [ + app._reorg_mode_dd, + app._reorg_note, + ] elif ct == "PES Scan": app._update_scan_widgets() app.calc_extra_opts.children = [ diff --git a/quantui/log_utils.py b/quantui/log_utils.py index e0cbd6d..200e384 100644 --- a/quantui/log_utils.py +++ b/quantui/log_utils.py @@ -155,6 +155,8 @@ def _fmt_duration(seconds: float) -> str: "frequency": "Frequency Analysis", "tddft": "TD-DFT (UV-Vis)", "nmr": "NMR Shielding", + "pes_scan": "PES Scan", + "reorganization_energy": "Reorganization Energy (4-point)", } diff --git a/quantui/reorganization_energy.py b/quantui/reorganization_energy.py new file mode 100644 index 0000000..dbf1287 --- /dev/null +++ b/quantui/reorganization_energy.py @@ -0,0 +1,431 @@ +""" +QuantUI Reorganization Energy Module + +Computes the internal (inner-sphere) reorganization energy ``λ`` from Marcus +theory using the standard **4-point scheme**. + +For a charge-transfer event between a neutral molecule and its ion, the +reorganization energy is the energy penalty for relaxing each charge state +from the *other* state's equilibrium geometry to its own: + + λ = λ₁ + λ₂ + λ₁ = E_ion(R_neutral) − E_ion(R_ion) # ion relaxation + λ₂ = E_neutral(R_ion) − E_neutral(R_neutral) # neutral relaxation + +where ``R_x`` is the fully relaxed (optimized) geometry of charge state ``x``. +The "4 points" are the four single-point energies that appear above: + + E_neutral(R_neutral), E_ion(R_ion) — the two optimized minima + E_ion(R_neutral), E_neutral(R_ion) — the two cross evaluations + +Two channels are supported: + +* **hole** (charge +1) — relevant for hole/p-type charge transport, +* **electron** (charge −1) — relevant for electron/n-type transport. + +Running ``mode="both"`` computes both channels while sharing the single +neutral geometry optimization, so it costs three optimizations rather than +four. + +The heavy lifting (SCF + gradients) is delegated to the same code paths as +the rest of QuantUI: :func:`quantui.optimizer.optimize_geometry` for the +relaxations and :func:`quantui.session_calc.run_in_session` for the +single-point cross evaluations. +""" + +from __future__ import annotations + +import sys +from dataclasses import dataclass, field +from typing import IO, List, Optional + +from .molecule import Molecule +from .optimizer import DEFAULT_FMAX, DEFAULT_OPT_STEPS, optimize_geometry +from .session_calc import HARTREE_TO_EV, run_in_session + +# 1 Hartree in kcal/mol (CODATA-consistent with HARTREE_TO_EV). +HARTREE_TO_KCAL: float = 627.509474 + +VALID_MODES = ("hole", "electron", "both") + + +# ============================================================================ +# Result dataclasses +# ============================================================================ + + +@dataclass +class ReorgChannelResult: + """Reorganization energy for a single charge-transfer channel. + + Attributes: + kind: ``"hole"`` (cation, +1) or ``"electron"`` (anion, −1). + ion_charge: Total charge of the ion for this channel. + ion_multiplicity: Spin multiplicity used for the ion. + e_neutral_at_neutral: E_neutral(R_neutral) in Hartree (shared point). + e_ion_at_ion: E_ion(R_ion) in Hartree. + e_ion_at_neutral: E_ion(R_neutral) in Hartree. + e_neutral_at_ion: E_neutral(R_ion) in Hartree. + lambda1_hartree: Ion relaxation energy λ₁ (Ha). + lambda2_hartree: Neutral relaxation energy λ₂ (Ha). + lambda_hartree: Total reorganization energy λ = λ₁ + λ₂ (Ha). + converged: True if every SCF/opt feeding this channel converged. + """ + + kind: str + ion_charge: int + ion_multiplicity: int + e_neutral_at_neutral: float + e_ion_at_ion: float + e_ion_at_neutral: float + e_neutral_at_ion: float + lambda1_hartree: float + lambda2_hartree: float + lambda_hartree: float + converged: bool + + @property + def lambda_ev(self) -> float: + """Total reorganization energy in electronvolts.""" + return self.lambda_hartree * HARTREE_TO_EV + + @property + def lambda_mev(self) -> float: + """Total reorganization energy in millielectronvolts.""" + return self.lambda_ev * 1000.0 + + @property + def lambda_kcal(self) -> float: + """Total reorganization energy in kcal/mol.""" + return self.lambda_hartree * HARTREE_TO_KCAL + + @property + def label(self) -> str: + """Human-readable channel label.""" + return "Hole (cation)" if self.kind == "hole" else "Electron (anion)" + + +@dataclass +class ReorganizationEnergyResult: + """Structured output from a 4-point reorganization energy calculation. + + Exposes ``formula``/``method``/``basis``/``energy_hartree``/``converged`` + so it can be persisted by :func:`quantui.results_storage.save_result` + like every other result type. ``energy_hartree`` reports the optimized + neutral SCF energy (the physical reference for the run). + """ + + formula: str + method: str + basis: str + mode: str + molecule: Molecule # optimized neutral geometry (used for 3D display) + neutral_charge: int + neutral_multiplicity: int + neutral_energy_hartree: float + channels: List[ReorgChannelResult] = field(default_factory=list) + converged: bool = True + n_total_opt_steps: int = 0 + + @property + def energy_hartree(self) -> float: + """Optimized neutral SCF energy (Ha) — the run's reference energy.""" + return self.neutral_energy_hartree + + @property + def energy_ev(self) -> float: + """Optimized neutral SCF energy in electronvolts.""" + return self.neutral_energy_hartree * HARTREE_TO_EV + + def channel(self, kind: str) -> Optional[ReorgChannelResult]: + """Return the channel result for ``"hole"``/``"electron"`` or None.""" + for ch in self.channels: + if ch.kind == kind: + return ch + return None + + def to_spectra(self) -> dict: + """Serialisable payload stored under result.json ``spectra`` key.""" + return { + "reorganization_energy": { + "mode": self.mode, + "neutral_charge": self.neutral_charge, + "neutral_multiplicity": self.neutral_multiplicity, + "neutral_energy_hartree": self.neutral_energy_hartree, + "n_total_opt_steps": self.n_total_opt_steps, + "channels": [ + { + "kind": ch.kind, + "ion_charge": ch.ion_charge, + "ion_multiplicity": ch.ion_multiplicity, + "e_neutral_at_neutral": ch.e_neutral_at_neutral, + "e_ion_at_ion": ch.e_ion_at_ion, + "e_ion_at_neutral": ch.e_ion_at_neutral, + "e_neutral_at_ion": ch.e_neutral_at_ion, + "lambda1_hartree": ch.lambda1_hartree, + "lambda2_hartree": ch.lambda2_hartree, + "lambda_hartree": ch.lambda_hartree, + "lambda_ev": ch.lambda_ev, + "lambda_kcal": ch.lambda_kcal, + "converged": ch.converged, + } + for ch in self.channels + ], + } + } + + def summary(self) -> str: + """Return a multi-line human-readable result summary.""" + lines = [ + "=" * 60, + "Reorganization Energy (Marcus 4-point)", + "=" * 60, + f" Molecule : {self.formula}", + f" Method/Basis : {self.method}/{self.basis}", + f" Neutral state : charge {self.neutral_charge:+d}, " + f"mult {self.neutral_multiplicity}", + f" All converged : {'Yes' if self.converged else 'NO'}", + f" Total opt steps: {self.n_total_opt_steps}", + "-" * 60, + ] + for ch in self.channels: + lines.append( + f" {ch.label:<18}: λ = {ch.lambda_ev:.4f} eV " + f"({ch.lambda_kcal:.2f} kcal/mol)" + ) + lines.append( + f" λ₁ (ion relax) = {ch.lambda1_hartree * HARTREE_TO_EV:.4f} eV," + f" λ₂ (neutral relax) = {ch.lambda2_hartree * HARTREE_TO_EV:.4f} eV" + ) + lines.append("=" * 60) + return "\n".join(lines) + + +# ============================================================================ +# Helpers +# ============================================================================ + + +def _promote_method(method: str, multiplicity: int) -> str: + """Return a method suitable for the given spin state. + + PySCF's restricted RHF cannot treat an open-shell ion, and the QuantUI + optimizer only special-cases ``RHF``/``UHF`` for Hartree-Fock (DFT is + auto-restricted/unrestricted from the spin). So promote a closed-shell + HF request to UHF whenever the species is open-shell. + """ + if multiplicity > 1 and method.upper() in ("RHF", "HF"): + return "UHF" + return method + + +def _ion_multiplicity(molecule: Molecule, ion_charge: int) -> int: + """Low-spin multiplicity for an ion at ``ion_charge``. + + Removing/adding one electron flips the electron-count parity, so the + ground-state multiplicity is 1 (even electrons) or 2 (odd electrons). + This picks the minimal valid multiplicity; users wanting a high-spin ion + can build the calculation manually. + """ + n_electrons = molecule.get_electron_count() - (ion_charge - molecule.charge) + return 1 if n_electrons % 2 == 0 else 2 + + +def _emit(stream: IO[str], message: str) -> None: + try: + stream.write(message) + except Exception: # noqa: BLE001 — logging must never kill the run + pass + + +# ============================================================================ +# Main entry point +# ============================================================================ + + +def run_reorganization_energy( + molecule: Molecule, + mode: str = "both", + method: str = "B3LYP", + basis: str = "6-31G*", + fmax: float = DEFAULT_FMAX, + steps: int = DEFAULT_OPT_STEPS, + progress_stream: Optional[IO[str]] = None, + solvent: Optional[str] = None, +) -> ReorganizationEnergyResult: + """Compute the 4-point Marcus reorganization energy for a molecule. + + Args: + molecule: The **neutral** (reference) molecule. Its charge and + multiplicity define the reference state; ions are derived from it. + mode: ``"hole"``, ``"electron"``, or ``"both"``. + method: SCF method / DFT functional (e.g. ``"B3LYP"``). Automatically + promoted to UHF for open-shell HF cases. + basis: Basis set name recognised by PySCF. + fmax: Force convergence threshold (eV/Å) for the optimizations. + steps: Maximum optimizer steps per optimization. + progress_stream: Writable stream for live log output (Jupyter widget + stream in the app, ``sys.stdout`` otherwise). + solvent: Optional PCM solvent name for the single-point evaluations. + + Returns: + :class:`ReorganizationEnergyResult`. + + Raises: + ValueError: If ``mode`` is not one of :data:`VALID_MODES`. + RuntimeError: If a required single-point evaluation fails to converge. + """ + mode = (mode or "both").lower() + if mode not in VALID_MODES: + raise ValueError( + f"Invalid mode '{mode}'. Choose one of {', '.join(VALID_MODES)}." + ) + + stream: IO[str] = progress_stream if progress_stream is not None else sys.stdout + base_charge = molecule.charge + base_mult = molecule.multiplicity + neutral_method = _promote_method(method, base_mult) + + def _single_point(mol: Molecule, mth: str, tag: str) -> float: + """Run a single point and return its energy, asserting convergence.""" + _emit(stream, f"\n── Single point: {tag} ──────────────────────────\n") + res = run_in_session( + molecule=mol, + method=mth, + basis=basis, + progress_stream=stream, # type: ignore[arg-type] + solvent=solvent, + ) + if not bool(getattr(res, "converged", False)): + raise RuntimeError(f"Single point did not converge: {tag}") + return float(res.energy_hartree) + + _emit( + stream, + "\n" + + "═" * 62 + + f"\n Reorganization energy (4-point) — mode: {mode}\n" + + f" {molecule.get_formula()} @ {method}/{basis}\n" + + "═" * 62 + + "\n", + ) + + # ── Step 1: optimize the neutral reference geometry ────────────────────── + _emit(stream, "\n── Optimizing neutral geometry (R_neutral) ──────────\n") + neutral_opt = optimize_geometry( + molecule=molecule, + method=neutral_method, + basis=basis, + fmax=fmax, + steps=steps, + progress_stream=stream, # type: ignore[arg-type] + ) + neutral_mol = neutral_opt.molecule + n_total_steps = neutral_opt.n_steps + all_converged = bool(neutral_opt.converged) + + # E_neutral(R_neutral) — shared "point 1" across both channels. + e_neutral_at_neutral = _single_point( + neutral_mol, neutral_method, "E_neutral(R_neutral)" + ) + + # ── Step 2: per-channel ion optimization + cross single points ─────────── + targets: List[tuple[str, int]] = [] + if mode in ("hole", "both"): + targets.append(("hole", base_charge + 1)) + if mode in ("electron", "both"): + targets.append(("electron", base_charge - 1)) + + channels: List[ReorgChannelResult] = [] + for kind, ion_charge in targets: + ion_mult = _ion_multiplicity(molecule, ion_charge) + ion_method = _promote_method(method, ion_mult) + _emit( + stream, + f"\n── {kind.capitalize()} channel: optimizing ion geometry " + f"(charge {ion_charge:+d}, mult {ion_mult}) ──\n", + ) + + # Seed the ion optimization from the relaxed neutral geometry — it is + # closer to the ion minimum than the raw input geometry. + ion_seed = Molecule( + atoms=list(molecule.atoms), + coordinates=[list(c) for c in neutral_mol.coordinates], + charge=ion_charge, + multiplicity=ion_mult, + ) + ion_opt = optimize_geometry( + molecule=ion_seed, + method=ion_method, + basis=basis, + fmax=fmax, + steps=steps, + progress_stream=stream, # type: ignore[arg-type] + ) + ion_mol = ion_opt.molecule + n_total_steps += ion_opt.n_steps + all_converged = all_converged and bool(ion_opt.converged) + + # The four energies (two already share R_neutral / R_ion optimizations). + e_ion_at_ion = _single_point(ion_mol, ion_method, f"E_{kind}(R_{kind})") + e_ion_at_neutral = _single_point( + Molecule( + atoms=list(molecule.atoms), + coordinates=[list(c) for c in neutral_mol.coordinates], + charge=ion_charge, + multiplicity=ion_mult, + ), + ion_method, + f"E_{kind}(R_neutral)", + ) + e_neutral_at_ion = _single_point( + Molecule( + atoms=list(molecule.atoms), + coordinates=[list(c) for c in ion_mol.coordinates], + charge=base_charge, + multiplicity=base_mult, + ), + neutral_method, + f"E_neutral(R_{kind})", + ) + + lambda1 = e_ion_at_neutral - e_ion_at_ion # ion relaxation + lambda2 = e_neutral_at_ion - e_neutral_at_neutral # neutral relaxation + lambda_total = lambda1 + lambda2 + + channels.append( + ReorgChannelResult( + kind=kind, + ion_charge=ion_charge, + ion_multiplicity=ion_mult, + e_neutral_at_neutral=e_neutral_at_neutral, + e_ion_at_ion=e_ion_at_ion, + e_ion_at_neutral=e_ion_at_neutral, + e_neutral_at_ion=e_neutral_at_ion, + lambda1_hartree=lambda1, + lambda2_hartree=lambda2, + lambda_hartree=lambda_total, + converged=bool(ion_opt.converged), + ) + ) + _emit( + stream, + f"\n → λ_{kind} = {lambda_total * HARTREE_TO_EV:.4f} eV " + f"({lambda_total * HARTREE_TO_KCAL:.2f} kcal/mol)\n", + ) + + result = ReorganizationEnergyResult( + formula=molecule.get_formula(), + method=method, + basis=basis, + mode=mode, + molecule=neutral_mol, + neutral_charge=base_charge, + neutral_multiplicity=base_mult, + neutral_energy_hartree=e_neutral_at_neutral, + channels=channels, + converged=all_converged, + n_total_opt_steps=n_total_steps, + ) + _emit(stream, "\n" + result.summary() + "\n") + return result diff --git a/quantui/results_storage.py b/quantui/results_storage.py index d25c03a..8fe9f54 100644 --- a/quantui/results_storage.py +++ b/quantui/results_storage.py @@ -676,6 +676,7 @@ def save_thumbnail(result_dir: Path, data: dict) -> None: "frequency": ("#15803d", "#dcfce7"), "tddft": ("#b45309", "#fef3c7"), "nmr": ("#0d9488", "#ccfbf1"), + "reorganization_energy": ("#be123c", "#ffe4e6"), } _ct_labels: dict = { "single_point": "Single Point", @@ -683,6 +684,7 @@ def save_thumbnail(result_dir: Path, data: dict) -> None: "frequency": "Frequency", "tddft": "TD-DFT", "nmr": "NMR", + "reorganization_energy": "Reorg Energy", } ct = data.get("calc_type", "") fg, bg = _colors.get(ct, ("#555555", "#f3f4f6")) diff --git a/tests/test_app.py b/tests/test_app.py index 2a770df..2ba88d8 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -896,9 +896,10 @@ def test_nmr_in_calc_type_options(self): app = QuantUIApp() assert "NMR Shielding" in app.calc_type_dd.options - def test_calc_type_dd_has_six_options(self): + def test_calc_type_dd_has_expected_options(self): app = QuantUIApp() - assert len(app.calc_type_dd.options) == 6 + assert len(app.calc_type_dd.options) == 7 + assert "Reorganization Energy" in app.calc_type_dd.options def test_nmr_calc_type_shows_note(self): app = QuantUIApp() @@ -922,6 +923,41 @@ def test_switching_away_from_nmr_clears_opts(self): assert len(app.calc_extra_opts.children) == 0 +class TestReorganizationEnergyUI: + """UI wiring for the Reorganization Energy calc-type + auto-setup button.""" + + def test_reorg_mode_shows_channel_selector(self): + app = QuantUIApp() + app.calc_type_dd.value = "Reorganization Energy" + kids = app.calc_extra_opts.children + assert app._reorg_mode_dd in kids + assert app._reorg_note in kids + assert app._reorg_mode_dd.value == "both" + + def test_reorg_hides_preopt_checkbox(self): + app = QuantUIApp() + 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): + 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. + app.calc_type_dd.value = "Reorganization Energy" + app._reorg_mode_dd.value = "both" + assert app.calc_type_dd.value == "Reorganization Energy" + assert app._reorg_mode_dd in app.calc_extra_opts.children + + class TestFormatNMRResult: """_format_nmr_result produces correct HTML.""" diff --git a/tests/test_reorganization_energy.py b/tests/test_reorganization_energy.py new file mode 100644 index 0000000..abe704b --- /dev/null +++ b/tests/test_reorganization_energy.py @@ -0,0 +1,147 @@ +"""Unit tests for the reorganization-energy (Marcus 4-point) module. + +These cover the pure-Python pieces — the λ arithmetic, unit conversions, +spin/method bookkeeping helpers, and the serialisable payload — none of which +require PySCF/ASE, so they run everywhere. The full SCF-backed +``run_reorganization_energy`` pipeline is exercised by the end-to-end run +smoke test in the calculation tests. +""" + +import pytest + +from quantui.molecule import Molecule +from quantui.reorganization_energy import ( + HARTREE_TO_KCAL, + VALID_MODES, + ReorganizationEnergyResult, + ReorgChannelResult, + _ion_multiplicity, + _promote_method, + run_reorganization_energy, +) + + +def _channel(**kw): + """Build a ReorgChannelResult with sensible defaults for math checks.""" + defaults = dict( + kind="hole", + ion_charge=1, + ion_multiplicity=2, + e_neutral_at_neutral=-100.0, + e_ion_at_ion=-99.5, + e_ion_at_neutral=-99.48, + e_neutral_at_ion=-99.97, + lambda1_hartree=0.02, + lambda2_hartree=0.03, + lambda_hartree=0.05, + converged=True, + ) + defaults.update(kw) + return ReorgChannelResult(**defaults) + + +class TestChannelUnitConversions: + def test_lambda_ev_matches_hartree(self): + ch = _channel(lambda_hartree=0.05) + assert ch.lambda_ev == pytest.approx(0.05 * 27.211386245988) + + def test_lambda_mev_is_1000x_ev(self): + ch = _channel(lambda_hartree=0.05) + assert ch.lambda_mev == pytest.approx(ch.lambda_ev * 1000.0) + + def test_lambda_kcal_matches_hartree(self): + ch = _channel(lambda_hartree=0.05) + assert ch.lambda_kcal == pytest.approx(0.05 * HARTREE_TO_KCAL) + + def test_labels(self): + assert _channel(kind="hole").label == "Hole (cation)" + assert _channel(kind="electron").label == "Electron (anion)" + + +class TestPromoteMethod: + def test_rhf_promoted_to_uhf_when_open_shell(self): + assert _promote_method("RHF", 2) == "UHF" + assert _promote_method("HF", 2) == "UHF" + + def test_rhf_kept_when_closed_shell(self): + assert _promote_method("RHF", 1) == "RHF" + + def test_dft_left_untouched(self): + # DFT is auto restricted/unrestricted from spin downstream. + assert _promote_method("B3LYP", 2) == "B3LYP" + assert _promote_method("B3LYP", 1) == "B3LYP" + + +class TestIonMultiplicity: + def test_cation_of_closed_shell_is_doublet(self): + # Neutral H2O (10 e-, closed shell) → cation (9 e-) is a doublet. + h2o = Molecule(["O", "H", "H"], [[0, 0, 0], [0, 0, 1], [0, 1, 0]]) + assert _ion_multiplicity(h2o, ion_charge=1) == 2 + + def test_anion_of_closed_shell_is_doublet(self): + h2o = Molecule(["O", "H", "H"], [[0, 0, 0], [0, 0, 1], [0, 1, 0]]) + assert _ion_multiplicity(h2o, ion_charge=-1) == 2 + + def test_ion_of_open_shell_is_singlet(self): + # A doublet radical (odd e-) → removing/adding one e- gives even → mult 1. + no = Molecule(["N", "O"], [[0, 0, 0], [0, 0, 1.15]], multiplicity=2) + assert _ion_multiplicity(no, ion_charge=1) == 1 + assert _ion_multiplicity(no, ion_charge=-1) == 1 + + +class TestResultAggregate: + def _result(self, channels): + return ReorganizationEnergyResult( + formula="H3N", + method="B3LYP", + basis="6-31G*", + mode="both", + molecule=Molecule(["H", "H"], [[0, 0, 0], [0, 0, 0.74]]), + neutral_charge=0, + neutral_multiplicity=1, + neutral_energy_hartree=-56.5, + channels=channels, + converged=True, + n_total_opt_steps=12, + ) + + def test_energy_hartree_is_neutral_energy(self): + r = self._result([_channel()]) + assert r.energy_hartree == -56.5 + assert r.energy_ev == pytest.approx(-56.5 * 27.211386245988) + + def test_channel_lookup(self): + hole = _channel(kind="hole") + elec = _channel(kind="electron", ion_charge=-1) + r = self._result([hole, elec]) + assert r.channel("hole") is hole + assert r.channel("electron") is elec + assert r.channel("nope") is None + + def test_to_spectra_roundtrips_key_fields(self): + r = self._result([_channel(lambda_hartree=0.05)]) + payload = r.to_spectra() + assert "reorganization_energy" in payload + block = payload["reorganization_energy"] + assert block["mode"] == "both" + assert block["channels"][0]["lambda_hartree"] == 0.05 + assert block["channels"][0]["lambda_ev"] == pytest.approx( + 0.05 * 27.211386245988 + ) + + def test_summary_mentions_channels(self): + r = self._result([_channel(kind="hole"), _channel(kind="electron")]) + text = r.summary() + assert "Reorganization Energy" in text + assert "Hole (cation)" in text + assert "Electron (anion)" in text + + +class TestRunValidation: + def test_invalid_mode_raises(self): + h2 = Molecule(["H", "H"], [[0, 0, 0], [0, 0, 0.74]]) + with pytest.raises(ValueError): + run_reorganization_energy(h2, mode="banana") + + def test_valid_modes_constant(self): + assert set(VALID_MODES) == {"hole", "electron", "both"} From b1afdfe5280b7ae112bd5e260554a6a9eb05d9bf Mon Sep 17 00:00:00 2001 From: Jada-lee Chung Date: Fri, 17 Jul 2026 13:47:47 -0400 Subject: [PATCH 3/3] Fix builder punctuation and spacing in app files Close an open call in build_shared_widgets and add a blank line before _on_cancel in app.py. These small formatting fixes restore correct grouping/indentation around the reorganization-energy runner UI and improve readability; no behavioral change. --- quantui/app.py | 1 + quantui/app_builders.py | 2 ++ 2 files changed, 3 insertions(+) diff --git a/quantui/app.py b/quantui/app.py index 00a6af4..0aefe5a 100644 --- a/quantui/app.py +++ b/quantui/app.py @@ -2951,6 +2951,7 @@ def _on_reorg_auto_clicked(self, btn) -> None: 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. diff --git a/quantui/app_builders.py b/quantui/app_builders.py index f43237c..60ec080 100644 --- a/quantui/app_builders.py +++ b/quantui/app_builders.py @@ -903,6 +903,8 @@ def build_shared_widgets( "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(