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'"
+ )
+
+ _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'| Neutral energy | '
+ f'{r.neutral_energy_hartree:.8f} Ha |
'
+ f'| Total opt steps | '
+ f'{r.n_total_opt_steps} |
'
+ f'| All converged | '
+ f'{_conv} |
'
+ f"
{_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(