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
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,19 @@ 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.4.1] - 2026-07-16

Bug-fix and hardening release from a full repository audit. No new features and
Expand Down
2 changes: 2 additions & 0 deletions launch-native.command
100644 → 100755
Original file line number Diff line number Diff line change
Expand Up @@ -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" \
Expand Down
12 changes: 12 additions & 0 deletions quantui/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,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
Expand Down Expand Up @@ -265,6 +274,9 @@ def __getattr__(name: str) -> Any:
# 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",
Expand Down
60 changes: 59 additions & 1 deletion quantui/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Expand Down Expand Up @@ -1653,6 +1656,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.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 @@ -2932,6 +2936,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_cancel(self, btn=None) -> None:
"""Request graceful cancellation of the in-flight calculation.

Expand Down Expand Up @@ -3521,6 +3541,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
Expand Down Expand Up @@ -3896,6 +3917,7 @@ 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 @@ -3933,6 +3955,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
Expand Down Expand Up @@ -4037,6 +4060,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(
Expand Down Expand Up @@ -4416,6 +4440,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
Expand Down Expand Up @@ -4455,13 +4499,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 = (
'<p style="color:#555;font-size:12px;font-weight:600;'
'margin:6px 0 2px">Optimized geometry</p>'
)
self._viz_label.layout.display = ""
elif ct == "Reorganization Energy":
self._viz_label.value = (
'<p style="color:#555;font-size:12px;font-weight:600;'
'margin:6px 0 2px">Optimized neutral geometry</p>'
)
self._viz_label.layout.display = ""
self._queue_main_thread_callback(
self._show_result_3d,
_viz_mol,
Expand Down Expand Up @@ -4860,6 +4914,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
# Disarm Cancel, re-enable Clear, clear run-in-flight state.
self._calc_running = False
self._cancel_event.clear()
Expand Down Expand Up @@ -5146,6 +5201,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)

Expand Down
42 changes: 42 additions & 0 deletions quantui/app_builders.py
Original file line number Diff line number Diff line change
Expand Up @@ -695,6 +695,7 @@ def build_shared_widgets(
"UV-Vis (TD-DFT)",
"NMR Shielding",
"PES Scan",
"Reorganization Energy",
],
value="Single Point",
description="Calc. Type:",
Expand Down Expand Up @@ -843,6 +844,28 @@ def build_shared_widgets(
'<span style="font-size:12px;color:#555">Å</span>'
)

# 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(
'<span style="color:#555;font-size:12px">'
"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.</span>"
)

app.calc_extra_opts = widgets.VBox([])

app.method_help_btn = widgets.Button(
Expand All @@ -867,6 +890,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"
),
)

# 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 @@ -1287,6 +1325,10 @@ def build_run_section(app: Any, *, layout_fn: Any) -> None:
"sets may take several minutes on a laptop.</p>"
),
app.perf_estimate_html,
widgets.HBox(
[app.run_btn, app._reorg_auto_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(
[
Expand Down
46 changes: 46 additions & 0 deletions quantui/app_formatters.py
Original file line number Diff line number Diff line change
Expand Up @@ -365,6 +365,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'<tr><td style="padding:2px 18px 2px 0;color:#444">{k}</td>'
f'<td style="color:#000;font-family:monospace">{v}</td></tr>'
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'<div style="margin-top:8px">'
f'<b style="font-size:13px;color:#166534">{ch.label}</b>'
f'<table style="margin-top:2px;font-size:13px;border-collapse:collapse">'
f"{rows}</table></div>"
)

_channels_html = "".join(_channel_block(ch) for ch in r.channels)
return (
f'<div style="background:#f0fff0;border-left:4px solid #4CAF50;'
f'padding:10px 14px;border-radius:4px;margin:6px 0">'
f"<b>Reorganization Energy (Marcus 4-point) &mdash; "
f"{r.formula} ({r.method}/{r.basis})</b>"
f'<table style="margin-top:8px;font-size:14px;border-collapse:collapse">'
f'<tr><td style="padding:3px 18px 3px 0;color:#444">Neutral energy</td>'
f'<td style="color:#000">{r.neutral_energy_hartree:.8f} Ha</td></tr>'
f'<tr><td style="padding:3px 18px 3px 0;color:#444">Total opt steps</td>'
f'<td style="color:#000">{r.n_total_opt_steps}</td></tr>'
f'<tr><td style="padding:3px 18px 3px 0;color:#444">All converged</td>'
f'<td style="color:{_cc}">{_conv}</td></tr>'
f"</table>{_channels_html}</div>"
)


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
Expand Down
11 changes: 10 additions & 1 deletion quantui/app_runflow.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")


Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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.</span>"
),
]
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 = [
Expand Down
2 changes: 2 additions & 0 deletions quantui/log_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)",
}


Expand Down
Loading
Loading