quantui 0.5.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. quantui/__init__.py +311 -0
  2. quantui/analytics.py +609 -0
  3. quantui/app.py +5650 -0
  4. quantui/app_analysis.py +662 -0
  5. quantui/app_builders.py +2465 -0
  6. quantui/app_exports.py +194 -0
  7. quantui/app_formatters.py +493 -0
  8. quantui/app_history.py +624 -0
  9. quantui/app_runflow.py +1544 -0
  10. quantui/app_visualization.py +2620 -0
  11. quantui/ase_bridge.py +236 -0
  12. quantui/benchmarks.py +1543 -0
  13. quantui/c_stderr.py +124 -0
  14. quantui/cactus.py +88 -0
  15. quantui/calc_log.py +1116 -0
  16. quantui/calculator.py +204 -0
  17. quantui/cancellation.py +88 -0
  18. quantui/cli.py +288 -0
  19. quantui/comparison.py +306 -0
  20. quantui/config.py +725 -0
  21. quantui/data/js/3Dmol-min.js +2 -0
  22. quantui/data/js/3Dmol-min.js.LICENSE.txt +5 -0
  23. quantui/data/library/library.sqlite +0 -0
  24. quantui/data/manifests/bulk_qm9.json +1 -0
  25. quantui/data/manifests/curated.json +15482 -0
  26. quantui/data/manifests/presets.json +816 -0
  27. quantui/descriptor_cards.py +186 -0
  28. quantui/freq_calc.py +712 -0
  29. quantui/freq_ir_workers.py +229 -0
  30. quantui/gpu_offload.py +278 -0
  31. quantui/help_content.py +474 -0
  32. quantui/ir_plot.py +130 -0
  33. quantui/issue_tracker.py +170 -0
  34. quantui/live_log.py +387 -0
  35. quantui/log_utils.py +492 -0
  36. quantui/molecule.py +577 -0
  37. quantui/molecule_library.py +433 -0
  38. quantui/nmr_calc.py +437 -0
  39. quantui/optimizer.py +670 -0
  40. quantui/orbital_visualization.py +1102 -0
  41. quantui/pes_scan.py +420 -0
  42. quantui/preopt.py +355 -0
  43. quantui/progress.py +111 -0
  44. quantui/pubchem.py +1157 -0
  45. quantui/reorganization_energy.py +435 -0
  46. quantui/results_storage.py +902 -0
  47. quantui/security.py +14 -0
  48. quantui/session_calc.py +622 -0
  49. quantui/structure_providers.py +277 -0
  50. quantui/tddft_calc.py +307 -0
  51. quantui/user_settings.py +238 -0
  52. quantui/utils.py +287 -0
  53. quantui/vib_cache.py +247 -0
  54. quantui/visualization_py3dmol.py +593 -0
  55. quantui/viz_assets.py +101 -0
  56. quantui/viz_backend_router.py +243 -0
  57. quantui-0.5.1.dist-info/METADATA +533 -0
  58. quantui-0.5.1.dist-info/RECORD +62 -0
  59. quantui-0.5.1.dist-info/WHEEL +5 -0
  60. quantui-0.5.1.dist-info/entry_points.txt +2 -0
  61. quantui-0.5.1.dist-info/licenses/LICENSE +21 -0
  62. quantui-0.5.1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,493 @@
1
+ """Result-card HTML formatters used by QuantUIApp."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Any, Optional
7
+
8
+
9
+ def _result_extra_rows(get: Any) -> str:
10
+ """Build the shared 'extra' result-card rows from an accessor.
11
+
12
+ ``get(key, default=None)`` reads a field from either a result object
13
+ (``getattr``) or a saved ``result.json`` dict (``dict.get``). Used by BOTH
14
+ :func:`format_result` (live) and :func:`format_past_result` (history) so the
15
+ two cards can never drift again — a past regression had the compute
16
+ device / dipole / Mulliken rows existed only on the live card. Rows:
17
+ post-HF correlation breakdown (MP2 / CCSD / (T)), solvent, compute device
18
+ (always shown), dipole moment, Mulliken charges.
19
+ """
20
+
21
+ def _num(label: str, value: str) -> str:
22
+ return (
23
+ f'<tr><td style="padding:3px 18px 3px 0;color:#444">{label}</td>'
24
+ f'<td style="color:#000">{value}</td></tr>'
25
+ )
26
+
27
+ rows = ""
28
+ energy = get("energy_hartree", 0.0)
29
+
30
+ # Post-HF correlation breakdown — energy_hartree already includes every
31
+ # contribution, so the HF reference is the total minus those.
32
+ _mp2 = get("mp2_correlation_hartree")
33
+ if _mp2 is not None:
34
+ rows += _num("HF reference", f"{energy - _mp2:.8f} Ha")
35
+ rows += _num("MP2 correlation", f"{_mp2:.8f} Ha")
36
+ _ccsd = get("ccsd_correlation_hartree")
37
+ _ccsd_t = get("ccsd_t_correction_hartree")
38
+ if _ccsd is not None:
39
+ rows += _num("HF reference", f"{energy - _ccsd - (_ccsd_t or 0.0):.8f} Ha")
40
+ rows += _num("CCSD correlation", f"{_ccsd:.8f} Ha")
41
+ if _ccsd_t is not None:
42
+ rows += _num("(T) triples correction", f"{_ccsd_t:.8f} Ha")
43
+
44
+ _solvent = get("solvent")
45
+ if _solvent is not None:
46
+ rows += _num("Solvent (PCM)", str(_solvent))
47
+
48
+ # Compute device — always shown; old saved results lack the
49
+ # field and safely read "CPU".
50
+ if bool(get("gpu_used", False)):
51
+ _name = get("gpu_name")
52
+ _device = (
53
+ f'<span style="color:#16a34a">🚀 GPU</span>'
54
+ f' &mdash; <span style="font-family:monospace">{_name}</span>'
55
+ if _name
56
+ else '<span style="color:#16a34a">🚀 GPU</span>'
57
+ )
58
+ else:
59
+ _device = '<span style="color:#555">CPU</span>'
60
+ rows += (
61
+ f'<tr><td style="padding:3px 18px 3px 0;color:#444">Compute device</td>'
62
+ f"<td>{_device}</td></tr>"
63
+ )
64
+
65
+ _dip = get("dipole_moment_debye")
66
+ if _dip is not None:
67
+ rows += _num("Dipole moment", f"{_dip:.4f} D")
68
+
69
+ _chg = get("mulliken_charges")
70
+ _syms = get("atom_symbols")
71
+ if _chg is not None and _syms is not None:
72
+ _charge_str = " ".join(f"{sym}:{c:+.3f}" for sym, c in zip(_syms, _chg))
73
+ rows += (
74
+ f'<tr><td style="padding:3px 18px 3px 0;color:#444;vertical-align:top">'
75
+ f"Mulliken charges</td>"
76
+ f'<td style="color:#000;font-family:monospace;font-size:12px;'
77
+ f'word-break:break-all">{_charge_str}</td></tr>'
78
+ )
79
+ return rows
80
+
81
+
82
+ def format_result(r: Any) -> str:
83
+ """Format a single-point-style result card."""
84
+ _conv = "Yes" if r.converged else "No (treat results with caution)"
85
+ _cc = "green" if r.converged else "#c00"
86
+ _gap = f"{r.homo_lumo_gap_ev:.4f} eV" if r.homo_lumo_gap_ev is not None else "N/A"
87
+ _rows = "".join(
88
+ f"<tr>"
89
+ f'<td style="padding:3px 18px 3px 0;color:#444">{k}</td>'
90
+ f'<td style="color:{vc}">{v}</td>'
91
+ f"</tr>"
92
+ for k, v, vc in [
93
+ (
94
+ "Total energy",
95
+ f"{r.energy_hartree:.8f} Ha &ensp;({r.energy_ev:.4f} eV)",
96
+ "#000",
97
+ ),
98
+ ("HOMO-LUMO gap", _gap, "#000"),
99
+ ("SCF converged", _conv, _cc),
100
+ (
101
+ "SCF iterations",
102
+ (
103
+ "—"
104
+ if getattr(r, "n_iterations", None) in (None, -1)
105
+ else str(r.n_iterations)
106
+ ),
107
+ "#000",
108
+ ),
109
+ ]
110
+ )
111
+ _extra = _result_extra_rows(lambda k, d=None: getattr(r, k, d))
112
+ return (
113
+ f'<div style="background:#f0fff0;border-left:4px solid #4CAF50;'
114
+ f'padding:10px 14px;border-radius:4px;margin:6px 0">'
115
+ f"<b>{r.formula} &mdash; {r.method}/{r.basis}</b>"
116
+ f'<table style="margin-top:8px;font-size:14px;border-collapse:collapse">'
117
+ f"{_rows}{_extra}</table></div>"
118
+ )
119
+
120
+
121
+ def format_opt_result(r: Any) -> str:
122
+ """Format a geometry-optimization result card."""
123
+ _conv = "Yes" if r.converged else "No (max steps reached)"
124
+ _cc = "green" if r.converged else "#c00"
125
+ _rows = "".join(
126
+ f"<tr>"
127
+ f'<td style="padding:3px 18px 3px 0;color:#444">{k}</td>'
128
+ f'<td style="color:{vc}">{v}</td>'
129
+ f"</tr>"
130
+ for k, v, vc in [
131
+ ("Final energy", f"{r.energy_hartree:.8f} Ha", "#000"),
132
+ ("Energy change", f"{r.energy_change_hartree:+.6f} Ha", "#000"),
133
+ ("Opt converged", _conv, _cc),
134
+ ("Steps taken", str(r.n_steps), "#000"),
135
+ ("Geometry RMSD", f"{r.rmsd_angstrom:.4f} Å", "#000"),
136
+ ]
137
+ )
138
+ return (
139
+ f'<div style="background:#f0fff0;border-left:4px solid #4CAF50;'
140
+ f'padding:10px 14px;border-radius:4px;margin:6px 0">'
141
+ f"<b>Geometry Optimisation &mdash; {r.formula} ({r.method}/{r.basis})</b>"
142
+ f'<table style="margin-top:8px;font-size:14px;border-collapse:collapse">'
143
+ f"{_rows}</table></div>"
144
+ )
145
+
146
+
147
+ def format_freq_result(r: Any) -> str:
148
+ """Format a frequency-analysis result card."""
149
+ _conv = "Yes" if r.converged else "No (treat with caution)"
150
+ _cc = "green" if r.converged else "#c00"
151
+ n_real = r.n_real_modes()
152
+ n_imag = r.n_imaginary_modes()
153
+ real_freqs = sorted(f for f in r.frequencies_cm1 if f > 0)[:6]
154
+ freq_str = " ".join(f"{f:.1f}" for f in real_freqs)
155
+ if len([f for f in r.frequencies_cm1 if f > 0]) > 6:
156
+ freq_str += " …"
157
+ imag_note = ""
158
+ if n_imag > 0:
159
+ imag_note = (
160
+ f'<tr><td style="padding:3px 18px 3px 0;color:#444">Imaginary modes</td>'
161
+ f'<td style="color:#c00">{n_imag} — geometry may not be a minimum</td></tr>'
162
+ )
163
+ _rows = (
164
+ f'<tr><td style="padding:3px 18px 3px 0;color:#444">SCF energy</td>'
165
+ f'<td style="color:#000">{r.energy_hartree:.8f} Ha</td></tr>'
166
+ f'<tr><td style="padding:3px 18px 3px 0;color:#444">SCF converged</td>'
167
+ f'<td style="color:{_cc}">{_conv}</td></tr>'
168
+ f'<tr><td style="padding:3px 18px 3px 0;color:#444">Real modes</td>'
169
+ f'<td style="color:#000">{n_real}</td></tr>'
170
+ + imag_note
171
+ + (
172
+ f'<tr><td style="padding:3px 18px 3px 0;color:#444">Frequencies (cm⁻¹)</td>'
173
+ f'<td style="color:#000;font-family:monospace">{freq_str or "none"}</td></tr>'
174
+ if real_freqs
175
+ else ""
176
+ )
177
+ + f'<tr><td style="padding:3px 18px 3px 0;color:#444">ZPVE</td>'
178
+ f'<td style="color:#000">{r.zpve_hartree:.6f} Ha '
179
+ f"({r.zpve_hartree * 27.211386245988:.4f} eV)</td></tr>"
180
+ )
181
+ _thermo_rows = ""
182
+ _thermo = getattr(r, "thermo", None)
183
+ if _thermo is not None:
184
+ _kj = 2625.5 # kJ/mol per Hartree
185
+ _thermo_rows = (
186
+ f'<tr><td colspan="2" style="padding:6px 0 2px 0;color:#666;'
187
+ f'font-size:12px;font-style:italic">'
188
+ f"&#8212; Thermochemistry at {_thermo.temperature_k:.0f} K / 1 atm &#8212;"
189
+ f"</td></tr>"
190
+ f'<tr><td style="padding:3px 18px 3px 0;color:#444">H (298 K)</td>'
191
+ f'<td style="color:#000">{_thermo.H_hartree:.6f} Ha</td></tr>'
192
+ f'<tr><td style="padding:3px 18px 3px 0;color:#444">S (298 K)</td>'
193
+ f'<td style="color:#000">{_thermo.S_jmol:.2f} J/(mol·K)</td></tr>'
194
+ f'<tr><td style="padding:3px 18px 3px 0;color:#444">G (298 K)</td>'
195
+ f'<td style="color:#000">{_thermo.G_hartree:.6f} Ha'
196
+ f" ({_thermo.G_hartree * _kj:.2f} kJ/mol)</td></tr>"
197
+ )
198
+ return (
199
+ f'<div style="background:#f0fff0;border-left:4px solid #4CAF50;'
200
+ f'padding:10px 14px;border-radius:4px;margin:6px 0">'
201
+ f"<b>Frequency Analysis &mdash; {r.formula} ({r.method}/{r.basis})</b>"
202
+ f'<table style="margin-top:8px;font-size:14px;border-collapse:collapse">'
203
+ f"{_rows}{_thermo_rows}</table></div>"
204
+ )
205
+
206
+
207
+ def format_tddft_result(r: Any) -> str:
208
+ """Format a TD-DFT / UV-Vis result card."""
209
+ _conv = "Yes" if r.converged else "No (treat with caution)"
210
+ _cc = "green" if r.converged else "#c00"
211
+ header_rows = (
212
+ f'<tr><td style="padding:3px 18px 3px 0;color:#444">Ground-state energy</td>'
213
+ f'<td style="color:#000">{r.energy_hartree:.8f} Ha</td></tr>'
214
+ f'<tr><td style="padding:3px 18px 3px 0;color:#444">SCF converged</td>'
215
+ f'<td style="color:{_cc}">{_conv}</td></tr>'
216
+ f'<tr><td style="padding:3px 18px 3px 0;color:#444">States computed</td>'
217
+ f'<td style="color:#000">{len(r.excitation_energies_ev)}</td></tr>'
218
+ )
219
+ exc_table = ""
220
+ if r.excitation_energies_ev:
221
+ wl = r.wavelengths_nm()
222
+ exc_rows = []
223
+ for i, (e_ev, f_osc) in enumerate(
224
+ zip(r.excitation_energies_ev[:8], r.oscillator_strengths[:8]), 1
225
+ ):
226
+ bold = "font-weight:bold" if f_osc > 0.05 else ""
227
+ exc_rows.append(
228
+ f'<tr style="{bold}">'
229
+ f'<td style="padding:2px 12px 2px 0;color:#555">S{i}</td>'
230
+ f'<td style="padding:2px 12px 2px 0;color:#000">{e_ev:.3f} eV</td>'
231
+ f'<td style="padding:2px 12px 2px 0;color:#000">{wl[i - 1]:.1f} nm</td>'
232
+ f'<td style="padding:2px 4px 2px 0;color:#000">f = {f_osc:.4f}</td>'
233
+ f"</tr>"
234
+ )
235
+ if len(r.excitation_energies_ev) > 8:
236
+ exc_rows.append(
237
+ f'<tr><td colspan="4" style="color:#888;font-size:12px">… '
238
+ f"and {len(r.excitation_energies_ev) - 8} more states</td></tr>"
239
+ )
240
+ exc_table = (
241
+ '<tr><td colspan="2" style="padding:8px 0 2px;color:#444;font-weight:bold">'
242
+ "Vertical excitations:</td></tr>"
243
+ "<tr>"
244
+ '<th style="text-align:left;color:#555;font-size:12px;padding:2px 12px 2px 0">State</th>'
245
+ '<th style="text-align:left;color:#555;font-size:12px;padding:2px 12px 2px 0">Energy</th>'
246
+ '<th style="text-align:left;color:#555;font-size:12px;padding:2px 12px 2px 0">λ</th>'
247
+ '<th style="text-align:left;color:#555;font-size:12px">Osc. str.</th></tr>'
248
+ + "".join(exc_rows)
249
+ )
250
+ return (
251
+ f'<div style="background:#f0fff0;border-left:4px solid #4CAF50;'
252
+ f'padding:10px 14px;border-radius:4px;margin:6px 0">'
253
+ f"<b>TD-DFT / UV-Vis &mdash; {r.formula} ({r.method}/{r.basis})</b>"
254
+ f'<table style="margin-top:8px;font-size:14px;border-collapse:collapse">'
255
+ f"{header_rows}{exc_table}</table></div>"
256
+ )
257
+
258
+
259
+ def format_nmr_result(r: Any) -> str:
260
+ """Format an NMR shielding result card."""
261
+ _conv = "Yes" if r.converged else "No (treat with caution)"
262
+ _cc = "green" if r.converged else "#c00"
263
+ header_rows = (
264
+ f'<tr><td style="padding:3px 18px 3px 0;color:#444">SCF converged</td>'
265
+ f'<td style="color:{_cc}">{_conv}</td></tr>'
266
+ f'<tr><td style="padding:3px 18px 3px 0;color:#444">Reference</td>'
267
+ f'<td style="color:#000">{r.reference_compound} ({r.method}/{r.basis})</td></tr>'
268
+ )
269
+
270
+ def _nmr_table(label: str, shifts: list, sym: str) -> str:
271
+ if not shifts:
272
+ return ""
273
+ rows = "".join(
274
+ f"<tr>"
275
+ f'<td style="padding:2px 14px 2px 0;color:#555">{sym}-{n}</td>'
276
+ f'<td style="color:#000">{d:.2f} ppm</td>'
277
+ f"</tr>"
278
+ for n, (_i, d) in enumerate(shifts, 1)
279
+ )
280
+ return (
281
+ f'<tr><td colspan="2" style="padding:8px 0 2px;color:#444;font-weight:bold">'
282
+ f"{label} shifts (vs. TMS):</td></tr>"
283
+ f"<tr>"
284
+ f'<th style="text-align:left;color:#555;font-size:12px;padding:2px 14px 2px 0">Atom</th>'
285
+ f'<th style="text-align:left;color:#555;font-size:12px">δ (ppm)</th></tr>'
286
+ + rows
287
+ )
288
+
289
+ h_table = _nmr_table("¹H", r.h_shifts(), "H")
290
+ c_table = _nmr_table("¹³C", r.c_shifts(), "C")
291
+
292
+ _basis_warn = ""
293
+ if r.basis.upper() in ("STO-3G", "3-21G"):
294
+ _basis_warn = (
295
+ '<tr><td colspan="2" style="padding:6px 0 0">'
296
+ '<span style="color:#b45309;font-size:12px">'
297
+ f"⚠ {r.basis} gives qualitative NMR only — use 6-31G* or better.</span>"
298
+ "</td></tr>"
299
+ )
300
+
301
+ # M4 audit fix (2026-07-14): the reference shielding constants table only
302
+ # covers a handful of method/basis combinations; any other combination
303
+ # silently substitutes the B3LYP/6-31G* constants, which can shift the
304
+ # reported ppm values by several ppm relative to a properly calibrated
305
+ # reference. Surface that substitution rather than let it pass silently.
306
+ _ref_warn = ""
307
+ if getattr(r, "is_fallback_reference", False):
308
+ _ref_warn = (
309
+ '<tr><td colspan="2" style="padding:6px 0 0">'
310
+ '<span style="color:#b45309;font-size:12px">'
311
+ f"⚠ No calibrated TMS reference for {r.method}/{r.basis} — using "
312
+ f"{getattr(r, 'reference_key', 'B3LYP/6-31G*')} constants instead. "
313
+ "Shifts may be off by a few ppm.</span>"
314
+ "</td></tr>"
315
+ )
316
+
317
+ _empty = ""
318
+ if not r.h_shifts() and not r.c_shifts():
319
+ _empty = (
320
+ '<tr><td colspan="2" style="color:#888;font-size:12px">'
321
+ "No ¹H or ¹³C atoms found in this molecule.</td></tr>"
322
+ )
323
+
324
+ return (
325
+ f'<div style="background:#f0fff0;border-left:4px solid #4CAF50;'
326
+ f'padding:10px 14px;border-radius:4px;margin:6px 0">'
327
+ f"<b>NMR Shielding &mdash; {r.formula} ({r.method}/{r.basis})</b>"
328
+ f'<table style="margin-top:8px;font-size:14px;border-collapse:collapse">'
329
+ f"{header_rows}{h_table}{c_table}{_empty}{_basis_warn}{_ref_warn}</table></div>"
330
+ )
331
+
332
+
333
+ def format_pes_scan_result(r: Any) -> str:
334
+ """Format a PESScanResult as an HTML result card."""
335
+ _conv = "Yes" if r.converged_all else "No (some points did not converge)"
336
+ _cc = "green" if r.converged_all else "#c00"
337
+ if r.energies_hartree:
338
+ e_min = min(r.energies_hartree)
339
+ e_max = max(r.energies_hartree)
340
+ barrier_kcal = (e_max - e_min) * 627.509474
341
+ _e_row = (
342
+ f'<tr><td style="padding:3px 18px 3px 0;color:#444">Min energy</td>'
343
+ f'<td style="color:#000">{e_min:.8f} Ha</td></tr>'
344
+ f'<tr><td style="padding:3px 18px 3px 0;color:#444">Energy range</td>'
345
+ f'<td style="color:#000">{barrier_kcal:.2f} kcal/mol</td></tr>'
346
+ )
347
+ else:
348
+ _e_row = ""
349
+ _idx_str = "–".join(str(i + 1) for i in r.atom_indices)
350
+ return (
351
+ f'<div style="background:#f0fff0;border-left:4px solid #4CAF50;'
352
+ f'padding:10px 14px;border-radius:4px;margin:6px 0">'
353
+ f"<b>PES Scan &mdash; {r.formula} ({r.method}/{r.basis})</b>"
354
+ f'<table style="margin-top:8px;font-size:14px;border-collapse:collapse">'
355
+ f'<tr><td style="padding:3px 18px 3px 0;color:#444">Scan type</td>'
356
+ f'<td style="color:#000">{r.scan_type.capitalize()} ({_idx_str})</td></tr>'
357
+ f'<tr><td style="padding:3px 18px 3px 0;color:#444">Range</td>'
358
+ f'<td style="color:#000">{r.scan_parameter_values[0]:.3f} → '
359
+ f"{r.scan_parameter_values[-1]:.3f} {r.scan_unit} "
360
+ f"({r.n_steps} points)</td></tr>"
361
+ f"{_e_row}"
362
+ f'<tr><td style="padding:3px 18px 3px 0;color:#444">All converged</td>'
363
+ f'<td style="color:{_cc}">{_conv}</td></tr>'
364
+ f"</table></div>"
365
+ )
366
+
367
+
368
+ def format_reorg_result(r: Any) -> str:
369
+ """Format a reorganization-energy (Marcus 4-point) result card."""
370
+ _conv = "Yes" if r.converged else "No (some steps did not converge)"
371
+ _cc = "green" if r.converged else "#c00"
372
+
373
+ def _channel_block(ch: Any) -> str:
374
+ rows = "".join(
375
+ f'<tr><td style="padding:2px 18px 2px 0;color:#444">{k}</td>'
376
+ f'<td style="color:#000;font-family:monospace">{v}</td></tr>'
377
+ for k, v in [
378
+ ("λ", f"{ch.lambda_ev:.4f} eV ({ch.lambda_kcal:.2f} kcal/mol)"),
379
+ ("λ₁ ion relaxation", f"{ch.lambda1_hartree * 27.211386245988:.4f} eV"),
380
+ (
381
+ "λ₂ neutral relaxation",
382
+ f"{ch.lambda2_hartree * 27.211386245988:.4f} eV",
383
+ ),
384
+ (
385
+ "Ion state",
386
+ f"charge {ch.ion_charge:+d}, mult {ch.ion_multiplicity}",
387
+ ),
388
+ ]
389
+ )
390
+ return (
391
+ f'<div style="margin-top:8px">'
392
+ f'<b style="font-size:13px;color:#166534">{ch.label}</b>'
393
+ f'<table style="margin-top:2px;font-size:13px;border-collapse:collapse">'
394
+ f"{rows}</table></div>"
395
+ )
396
+
397
+ _channels_html = "".join(_channel_block(ch) for ch in r.channels)
398
+ return (
399
+ f'<div style="background:#f0fff0;border-left:4px solid #4CAF50;'
400
+ f'padding:10px 14px;border-radius:4px;margin:6px 0">'
401
+ f"<b>Reorganization Energy (Marcus 4-point) &mdash; "
402
+ f"{r.formula} ({r.method}/{r.basis})</b>"
403
+ f'<table style="margin-top:8px;font-size:14px;border-collapse:collapse">'
404
+ f'<tr><td style="padding:3px 18px 3px 0;color:#444">Neutral energy</td>'
405
+ f'<td style="color:#000">{r.neutral_energy_hartree:.8f} Ha</td></tr>'
406
+ f'<tr><td style="padding:3px 18px 3px 0;color:#444">Total opt steps</td>'
407
+ f'<td style="color:#000">{r.n_total_opt_steps}</td></tr>'
408
+ f'<tr><td style="padding:3px 18px 3px 0;color:#444">All converged</td>'
409
+ f'<td style="color:{_cc}">{_conv}</td></tr>'
410
+ f"</table>{_channels_html}</div>"
411
+ )
412
+
413
+
414
+ def format_past_result(data: dict[str, Any], result_dir: Optional[Path] = None) -> str:
415
+ """Format a saved result.json payload as an HTML result card."""
416
+ import base64 as _b64
417
+
418
+ _ct_labels = {
419
+ "single_point": ("Single Point", "#2563eb", "#dbeafe"),
420
+ "geometry_opt": ("Geometry Optimization", "#7c3aed", "#ede9fe"),
421
+ "frequency": ("Frequency Analysis", "#15803d", "#dcfce7"),
422
+ "tddft": ("TD-DFT", "#b45309", "#fef3c7"),
423
+ "nmr": ("NMR", "#0d9488", "#ccfbf1"),
424
+ "pes_scan": ("PES Scan", "#c2410c", "#ffedd5"),
425
+ }
426
+ ct = data.get("calc_type", "")
427
+ _ct_label, _ct_fg, _ct_bg = _ct_labels.get(
428
+ ct, (ct.replace("_", " ").title(), "#555", "#f3f4f6")
429
+ )
430
+ _ct_badge = (
431
+ f'<span style="display:inline-block;padding:2px 10px;border-radius:12px;'
432
+ f"background:{_ct_bg};color:{_ct_fg};font-size:12px;font-weight:700;"
433
+ f'letter-spacing:0.03em;margin-bottom:6px">{_ct_label}</span>'
434
+ )
435
+ _conv = "Yes" if data.get("converged") else "No (treat results with caution)"
436
+ _cc = "green" if data.get("converged") else "#c00"
437
+ _gap = (
438
+ f"{data['homo_lumo_gap_ev']:.4f} eV"
439
+ if data.get("homo_lumo_gap_ev") is not None
440
+ else "N/A"
441
+ )
442
+ _rows = "".join(
443
+ f"<tr>"
444
+ f'<td style="padding:3px 18px 3px 0;color:#444">{k}</td>'
445
+ f'<td style="color:{vc}">{v}</td>'
446
+ f"</tr>"
447
+ for k, v, vc in [
448
+ (
449
+ "Total energy",
450
+ f"{data['energy_hartree']:.8f} Ha &ensp;({data['energy_ev']:.4f} eV)",
451
+ "#000",
452
+ ),
453
+ ("HOMO-LUMO gap", _gap, "#000"),
454
+ ("SCF converged", _conv, _cc),
455
+ (
456
+ "SCF iterations",
457
+ (
458
+ "—"
459
+ if data.get("n_iterations") in (None, -1)
460
+ else str(data.get("n_iterations"))
461
+ ),
462
+ "#000",
463
+ ),
464
+ ]
465
+ )
466
+ ts = data.get("timestamp", "")
467
+
468
+ # Shared 'extra' rows (correlation breakdown / solvent / device / dipole /
469
+ # Mulliken) — same builder as the live card so the two never drift.
470
+ _extra = _result_extra_rows(lambda k, d=None: data.get(k, d))
471
+
472
+ # Embed thumbnail if saved
473
+ _thumb_html = ""
474
+ if result_dir is not None:
475
+ _thumb_path = Path(result_dir) / "thumbnail.png"
476
+ if _thumb_path.exists():
477
+ _img_b64 = _b64.b64encode(_thumb_path.read_bytes()).decode()
478
+ _thumb_html = (
479
+ f'<img src="data:image/png;base64,{_img_b64}" '
480
+ f'style="float:right;margin:0 0 6px 14px;border-radius:4px;'
481
+ f'border:1px solid #e2e8f0" width="173" height="108" />'
482
+ )
483
+
484
+ return (
485
+ f'<div style="background:#f0fff0;border-left:4px solid #4CAF50;'
486
+ f'padding:10px 14px;border-radius:4px;margin:6px 0;overflow:hidden">'
487
+ f"{_thumb_html}"
488
+ f"{_ct_badge}<br>"
489
+ f'<b>{data["formula"]} &mdash; {data["method"]}/{data["basis"]}</b>'
490
+ f'&ensp;<small style="color:#777">{ts}</small>'
491
+ f'<table style="margin-top:8px;font-size:14px;border-collapse:collapse">'
492
+ f"{_rows}{_extra}</table></div>"
493
+ )