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
2 changes: 2 additions & 0 deletions quantui/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -5186,6 +5186,8 @@ def _run_required_final_single_point(target_mol, reason: str):
steps=self.max_steps_si.value,
progress_stream=log, # type: ignore[arg-type]
solvent=_solvent,
checkpoint=_ckpt,
resume=_resume,
)
result_html = self._format_reorg_result(result)
save_spectra = result.to_spectra()
Expand Down
1 change: 1 addition & 0 deletions quantui/app_runflow.py
Original file line number Diff line number Diff line change
Expand Up @@ -1570,6 +1570,7 @@ def _update_open_shell_hint(app: Any) -> None:
"UV-Vis (TD-DFT)": "tddft",
"NMR Shielding": "nmr",
"PES Scan": "pes_scan",
"Reorganization Energy": "reorganization_energy",
}


Expand Down
59 changes: 58 additions & 1 deletion quantui/checkpoint.py
Original file line number Diff line number Diff line change
Expand Up @@ -429,9 +429,66 @@ def has_progress(self) -> bool:
return True
traj = self.trajectory_path
try:
return traj.is_file() and traj.stat().st_size > 0
if traj.is_file() and traj.stat().st_size > 0:
return True
except OSError:
pass
return self._has_leg_progress()

def _has_leg_progress(self) -> bool:
"""True when any nested leg checkpoint (see :meth:`sub`) has progress.

A multi-stage run like Reorganization Energy writes nothing directly
into its own checkpoint — all the real optimizer state lives in its
legs. Without this, the top-level checkpoint would always look empty
and the run would never be offered as resumable, no matter how much
of a leg had actually completed.
"""
try:
entries = list((self.dir / "legs").iterdir())
except OSError:
return False
return any(entry.is_dir() and _has_progress_in(entry) for entry in entries)

# ── Multi-stage runs (CHK.7) ────────────────────────────────────────────

def sub(
self,
tag: str,
*,
charge: int,
multiplicity: int,
coords: Sequence[Sequence[float]],
) -> Checkpoint:
"""A nested checkpoint for one leg of a multi-stage run.

Reorganization Energy runs several independent geometry optimizations
under a single top-level checkpoint — the neutral reference plus one
per ion channel. Sharing one ``resume_key`` across all of them would
let one leg's trajectory/Hessian overwrite another's, so each leg
needs its own identity. This derives one from *tag* (a short label
like ``"neutral_opt"`` or ``"hole_opt"``), *charge*, *multiplicity*
and *coords* — the actual starting point of that leg's optimization —
while keeping method/basis/atoms from the parent.

The child's directory is nested under this checkpoint's own
(``<dir>/legs/<leg-resume-key>``) rather than living as a sibling in
the top-level checkpoint root. That keeps per-leg internals out of
the general "unfinished calculations" listing, which has no useful
way to present or restore half of a reorg run on its own.
"""
leg_identity = CalcIdentity(
calc_type=f"{self.identity.calc_type}:{tag}",
method=self.identity.method,
basis=self.identity.basis,
charge=int(charge),
multiplicity=int(multiplicity),
atom_symbols=self.identity.atom_symbols,
coords=tuple(tuple(float(v) for v in row) for row in coords),
)
return Checkpoint(
leg_identity, root=self.dir / "legs", log_stream=self._log_stream
)

# ── Scan points (CHK.3) ─────────────────────────────────────────────────

Expand Down
70 changes: 69 additions & 1 deletion quantui/reorganization_energy.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@

import sys
from dataclasses import dataclass, field
from typing import IO, List, Optional
from typing import IO, Any, List, Optional

from .molecule import Molecule
from .optimizer import DEFAULT_FMAX, DEFAULT_OPT_STEPS, optimize_geometry
Expand Down Expand Up @@ -351,6 +351,8 @@ def run_reorganization_energy(
steps: int = DEFAULT_OPT_STEPS,
progress_stream: Optional[IO[str]] = None,
solvent: Optional[str] = None,
checkpoint: Optional[Any] = None,
resume: bool = False,
) -> ReorganizationEnergyResult:
"""Compute the 4-point Marcus reorganization energy for a molecule.

Expand All @@ -366,6 +368,27 @@ def run_reorganization_energy(
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.
checkpoint: Optional :class:`~quantui.checkpoint.Checkpoint` for the
whole run (M-CHECKPOINT CHK.7). This run is really 2-3 independent
geometry optimizations — the neutral reference plus one per ion
channel — and each needs its own resume identity, or one leg's
trajectory/Hessian would overwrite another's. So *checkpoint*
itself is never handed to ``optimize_geometry`` directly; instead
each leg gets its own nested checkpoint via
:meth:`~quantui.checkpoint.Checkpoint.sub`. A leg that fails to
open its own checkpoint (disk full, permissions) simply runs
uncheckpointed — never the reason a leg doesn't run.
resume: Continue every leg from its own checkpoint where one exists.
A leg with nothing usable to resume just starts fresh — this is
not an error, it is the ordinary case for legs added after the
interrupted run's progress. Note: a leg that had already
*completed* before the interruption has no resumable state
either (by design — see ``Checkpoint.resumable_state``), so it is
re-optimized rather than instantly reused. In practice this
re-optimization converges in a step or two, since it starts
already at the minimum, and — because BFGS is deterministic —
reproduces the same geometry, so a later leg seeded from it still
finds its own checkpoint.

Returns:
:class:`ReorganizationEnergyResult`.
Expand Down Expand Up @@ -399,6 +422,24 @@ def _single_point(mol: Molecule, mth: str, tag: str) -> float:
raise RuntimeError(f"Single point did not converge: {tag}")
return float(res.energy_hartree)

def _leg_checkpoint(
tag: str, *, charge: int, multiplicity: int, coords: Any
) -> Optional[Any]:
"""Nested checkpoint for one geometry-opt leg (CHK.7), or ``None``.

``checkpoint`` (the whole-run checkpoint) is never passed straight to
``optimize_geometry`` — see the docstring above for why each leg needs
its own. A leg whose checkpoint fails to open behaves exactly like no
checkpoint at all: the optimization still runs, it just isn't
resumable.
"""
if checkpoint is None:
return None
leg = checkpoint.sub(
tag, charge=charge, multiplicity=multiplicity, coords=coords
)
return leg if leg.begin() else None

_emit(
stream,
"\n"
Expand All @@ -411,6 +452,12 @@ def _single_point(mol: Molecule, mth: str, tag: str) -> float:

# ── Step 1: optimize the neutral reference geometry ──────────────────────
_emit(stream, "\n── Optimizing neutral geometry (R_neutral) ──────────\n")
neutral_leg = _leg_checkpoint(
"neutral_opt",
charge=base_charge,
multiplicity=base_mult,
coords=molecule.coordinates,
)
neutral_opt = optimize_geometry(
molecule=molecule,
method=neutral_method,
Expand All @@ -420,6 +467,8 @@ def _single_point(mol: Molecule, mth: str, tag: str) -> float:
progress_stream=stream, # type: ignore[arg-type]
status_label="Reorg: optimizing neutral geometry",
report_fraction=False, # Don't let sub-opt 0→1 resets oscillate ETA
checkpoint=neutral_leg,
resume=resume,
)
neutral_mol = neutral_opt.molecule
n_total_steps = neutral_opt.n_steps
Expand Down Expand Up @@ -455,6 +504,12 @@ def _single_point(mol: Molecule, mth: str, tag: str) -> float:
charge=ion_charge,
multiplicity=ion_mult,
)
ion_leg = _leg_checkpoint(
f"{kind}_opt",
charge=ion_charge,
multiplicity=ion_mult,
coords=ion_seed.coordinates,
)
ion_opt = optimize_geometry(
molecule=ion_seed,
method=ion_method,
Expand All @@ -464,6 +519,8 @@ def _single_point(mol: Molecule, mth: str, tag: str) -> float:
progress_stream=stream, # type: ignore[arg-type]
status_label=f"Reorg: optimizing {kind} ion geometry",
report_fraction=False, # See neutral-opt note above
checkpoint=ion_leg,
resume=resume,
)
ion_mol = ion_opt.molecule
n_total_steps += ion_opt.n_steps
Expand Down Expand Up @@ -518,6 +575,17 @@ def _single_point(mol: Molecule, mth: str, tag: str) -> float:
f"({lambda_total * HARTREE_TO_KCAL:.2f} kcal/mol)\n",
)

if checkpoint is not None:
# Reaching here means every required single point converged (a
# non-convergent one raises and never gets this far) — the run is
# done, whether or not an individual leg itself fully converged.
# Mirrors pes_scan.py: "every point was attempted, so there is
# nothing left to resume." A leg's own checkpoint (see _leg_checkpoint)
# only marks itself complete on convergence, independently of this —
# a leg that hit max steps without converging stays resumable at its
# own level even after the overall run completes.
checkpoint.mark_complete()

result = ReorganizationEnergyResult(
formula=molecule.get_formula(),
method=method,
Expand Down
Loading