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
21 changes: 21 additions & 0 deletions quantui/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,9 @@
from quantui.app_exports import (
on_export_pdb as _exp_on_export_pdb,
)
from quantui.app_exports import (
on_export_reorg_geometries as _exp_on_export_reorg_geometries,
)
from quantui.app_exports import (
on_export_xyz as _exp_on_export_xyz,
)
Expand All @@ -156,6 +159,9 @@
from quantui.app_exports import (
on_orb_png_captured as _exp_on_orb_png_captured,
)
from quantui.app_exports import (
on_reorg_png_captured as _exp_on_reorg_png_captured,
)
from quantui.app_formatters import (
format_freq_result as _fmt_freq_result,
)
Expand Down Expand Up @@ -1199,6 +1205,9 @@ class QuantUIApp:
_reorg_overlay_pair: Any
_reorg_exaggerate: Any
_reorg_mode_dd: Any
_reorg_export_btn: Any
_reorg_export_status: Any
_reorg_png_inbox: Any
_result_dir_label: Any
_result_log_accordion: Any
_result_log_output: Any
Expand Down Expand Up @@ -2101,6 +2110,7 @@ def _wire_callbacks(self) -> None:
self.export_xyz_btn.on_click(self._on_export_xyz)
self.export_mol_btn.on_click(self._on_export_mol)
self.export_pdb_btn.on_click(self._on_export_pdb)
self._reorg_export_btn.on_click(self._safe_cb(self._on_export_reorg_geometries))
# History
self.past_dd.observe(self._safe_cb(self._on_past_dd_changed), names="value")
self.past_refresh_btn.on_click(self._on_past_refresh)
Expand Down Expand Up @@ -2202,6 +2212,11 @@ def _wire_callbacks(self) -> None:
self._orb_png_inbox.observe(
self._safe_cb(self._on_orb_png_captured), names="value"
)
# Same bridge, own inbox — the reorg-geometry viewer's Save-PNG button
# (M-EXPORT2 EXP2.2).
self._reorg_png_inbox.observe(
self._safe_cb(self._on_reorg_png_captured), names="value"
)
# Persist the grid choice so it survives a relaunch (ORBX.2).
self._iso_resolution_dd.observe(
self._safe_cb(self._on_iso_resolution_changed), names="value"
Expand Down Expand Up @@ -3609,6 +3624,9 @@ def _on_export_mol(self, btn) -> None:
def _on_export_pdb(self, btn) -> None:
_exp_on_export_pdb(self, btn)

def _on_export_reorg_geometries(self, btn) -> None:
_exp_on_export_reorg_geometries(self, btn)

def _on_reorg_view_changed(self, change) -> None:
_ana_on_reorg_view_changed(self, change)

Expand All @@ -3621,6 +3639,9 @@ def _on_iso_appearance_changed(self, change) -> None:
def _on_orb_png_captured(self, change) -> None:
_exp_on_orb_png_captured(self, change)

def _on_reorg_png_captured(self, change) -> None:
_exp_on_reorg_png_captured(self, change)

def _on_iso_resolution_changed(self, change) -> None:
"""Persist the isosurface grid choice.

Expand Down
11 changes: 10 additions & 1 deletion quantui/app_analysis.py
Original file line number Diff line number Diff line change
Expand Up @@ -339,7 +339,16 @@ def render_reorg_geometries(app: Any) -> None:
exaggerate=float(getattr(app._reorg_exaggerate, "value", 1.0)),
)
else:
html = build_reorg_geometry_viewer_html(geoms, bgcolor=bg)
from quantui.app_builders import _REORG_PNG_INBOX_CLASS

capture_class = (
_REORG_PNG_INBOX_CLASS
if getattr(app, "_reorg_png_inbox", None) is not None
else ""
)
html = build_reorg_geometry_viewer_html(
geoms, bgcolor=bg, capture_class=capture_class
)
app._set_html_output(app._reorg_geom_output, html)
except Exception as exc: # noqa: BLE001
app._set_html_output(
Expand Down
28 changes: 28 additions & 0 deletions quantui/app_builders.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,11 @@
# it is defined once here and passed down rather than spelled twice.
_ORB_PNG_INBOX_CLASS = "quantui-orb-png-inbox"

# Same pattern, own inbox: the reorg-geometry viewer's Save-PNG button
# (M-EXPORT2 EXP2.2) writes here rather than sharing _ORB_PNG_INBOX_CLASS, so
# a capture from one viewer can never be mistaken for the other's.
_REORG_PNG_INBOX_CLASS = "quantui-reorg-png-inbox"

# Friendlier labels for the library category filter.
_CATEGORY_LABELS = {
"diatomic": "Diatomics",
Expand Down Expand Up @@ -2229,6 +2234,24 @@ def _plot_export_row(prefix: str) -> widgets.HBox:
style={"description_width": "70px"},
layout=layout_fn(width="380px", display="none"),
)
# Export the retained geometries as XYZ (M-EXPORT2 EXP2.1) — one file per
# distinct geometry (R_neutral + each R_ion), not per energy.
app._reorg_export_btn = widgets.Button(
description="Export XYZ",
icon="download",
layout=layout_fn(width="130px", margin="4px 0 0 0"),
tooltip="Save each geometry (R_neutral, R_ion, ...) as its own XYZ file",
)
app._reorg_export_status = widgets.HTML(
value="", layout=layout_fn(margin="4px 0 0 8px")
)
# Hidden inbox for the viewer's Save-PNG button (M-EXPORT2 EXP2.2) — same
# write-into-DOM-node-then-sync-to-kernel bridge as _orb_png_inbox, own
# class so the two capture flows can never cross-fire each other.
app._reorg_png_inbox = widgets.Textarea(
value="", layout=layout_fn(width="1px", height="1px", visibility="hidden")
)
app._reorg_png_inbox.add_class(_REORG_PNG_INBOX_CLASS)
app._reorg_geom_body = widgets.VBox(
[
widgets.HTML(
Expand All @@ -2242,6 +2265,11 @@ def _plot_export_row(prefix: str) -> widgets.HBox:
app._reorg_overlay_pair,
app._reorg_exaggerate,
app._reorg_geom_output,
widgets.HBox(
[app._reorg_export_btn, app._reorg_export_status],
layout=layout_fn(align_items="center"),
),
app._reorg_png_inbox,
],
layout=layout_fn(padding="8px"),
)
Expand Down
199 changes: 198 additions & 1 deletion quantui/app_exports.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,67 @@

import logging
from pathlib import Path
from typing import Any
from typing import Any, cast

from .results_storage import _safe_name


def export_destination(
app: Any,
category: str,
*name_parts: str,
suffix: str,
) -> Path:
"""The one place that decides where a new export lands and what it's
called (M-EXPORT2 EXP2.3).

Before this, each exporter picked its own destination and built its own
filename inline (``on_export_xyz`` / ``on_export_mol`` / ``on_export_pdb``
below all repeat the same three-line pattern), so "where did it save?"
had a different answer depending on which button was pressed. New
exporters should call this instead of repeating that pattern; the three
existing structure exporters are left as-is deliberately — they already
work, and retrofitting working export paths carries real regression risk
for no user-facing benefit. This is about not repeating the inconsistency
as the export surface grows (EXP2.1, and whatever comes after it).

Every part of ``name_parts`` is sanitised (:func:`_safe_name`) and joined
with underscores, so a caller passes meaningful pieces (formula, a
geometry label, ...) instead of building a filename by hand.

Files land next to the calculation's own results (``app._last_result_dir``)
so everything about one run stays in one folder. Unlike the existing
exporters (which fall back to the current working directory when no
result folder exists yet), this raises — silently writing outside the
result folder is a worse default for anything added from here on.

Args:
app: the running QuantUIApp (only ``_last_result_dir`` is read).
category: a short, human-readable export kind, used only in the
error message (e.g. ``"reorg geometry"``).
*name_parts: filename-stem pieces, sanitised and joined with ``"_"``.
suffix: file extension including the leading dot (e.g. ``".xyz"``).

Returns:
The full destination path (parent directory already exists, since it
is always an existing result directory).

Raises:
ValueError: no result directory is available yet.
"""
result_dir = getattr(app, "_last_result_dir", None)
if result_dir is None or not isinstance(result_dir, Path):
raise ValueError(
f"No result folder yet — run a calculation before exporting {category}."
)
stem = "_".join(_safe_name(str(p)) for p in name_parts if p)
# result_dir is narrowed to Path by the isinstance check above, but mypy's
# Path.__truediv__ overload resolution still infers Any from an
# originally-Any-typed (getattr on `app: Any`) operand — verified in
# isolation; the isinstance check is the real, working type guard.
return cast(Path, result_dir / f"{stem}{suffix}")


def on_export(app: Any, btn: Any) -> None:
"""Export a standalone Python calculation script."""
if app._molecule is None:
Expand Down Expand Up @@ -100,6 +156,73 @@ def on_export_pdb(app: Any, btn: Any) -> None:
app.struct_export_status.value = f"Error: {exc}"


def on_export_reorg_geometries(app: Any, btn: Any) -> None:
"""Export every retained reorg-energy geometry as its own XYZ file
(M-EXPORT2 EXP2.1).

``app._reorg_geometries`` (built by ``reorg_geometries()``) is already
deduplicated to the DISTINCT geometries behind a run — R_neutral once,
plus one R_ion per channel — so this writes exactly that many files, not
one per energy. Naming follows EXP2.1's request directly:
``<formula>_R_neutral_<method>_<basis>.xyz`` /
``<formula>_R_hole_<method>_<basis>.xyz`` rather than three files all
called ``geometry.xyz``.

Provenance (EXP2.4): each file's comment line carries the charge,
multiplicity, method/basis, and which of the four λ energies were
evaluated on that geometry (``note`` — already computed by
``reorg_geometries()`` for the viewer, reused here rather than
recomputed) — the whole point of a free-text XYZ comment line, and
otherwise unrecoverable from the file six months later.
"""
status = getattr(app, "_reorg_export_status", None)

def _set_status(msg: str) -> None:
if status is not None:
status.value = msg

geoms = getattr(app, "_reorg_geometries", None)
if not geoms:
_set_status("No geometries to export yet.")
return

from quantui.molecule import Molecule

method = app.method_dd.value
basis = app.basis_dd.value
saved: list[str] = []
try:
for g in geoms:
# "R_neutral — optimized neutral" -> "R_neutral"
tag = g["label"].split(" — ")[0]
mol = Molecule(
atoms=list(g["atoms"]),
coordinates=[list(c) for c in g["coordinates"]],
charge=int(g.get("charge", 0)),
multiplicity=int(g.get("multiplicity", 1)),
)
dest = export_destination(
app,
"reorg geometry",
mol.get_formula(),
tag,
method,
basis,
suffix=".xyz",
)
comment = (
f"{tag} charge={mol.charge} multiplicity={mol.multiplicity} "
f"{method}/{basis} {g.get('note', '')}"
)
full_xyz = f"{len(mol.atoms)}\n{comment}\n{mol.to_xyz_string()}\n"
dest.write_text(full_xyz, encoding="utf-8")
saved.append(dest.name)
except Exception as exc:
_set_status(f"Error: {exc}")
return
_set_status(f"Saved {len(saved)} file(s): " + ", ".join(saved))


def export_molecule_and_label(app: Any) -> tuple[Any, str, str]:
"""Return (molecule, method, basis) for structure export.

Expand Down Expand Up @@ -250,6 +373,80 @@ def _clear_inbox(a: Any) -> None:
_clear_inbox(app)


def on_reorg_png_captured(app: Any, change: dict) -> None:
"""Write a PNG captured from the live reorg-geometry viewer (M-EXPORT2 EXP2.2).

Same capture/decode/save shape as ``on_orb_png_captured``, fed by its own
inbox (``_reorg_png_inbox`` / ``_REORG_PNG_INBOX_CLASS``) so a capture from
this viewer is never mistaken for an isosurface capture. Filename/location
goes through ``export_destination`` (EXP2.3) since this is a new exporter,
not a retrofit of existing behaviour.

Deliberately skips the isosurface panel's DPI-stamping/custom-name extras
(``_iso_png_dpi`` / ``_iso_png_name``) — those are isosurface-panel
controls, and wiring them in here would make an unrelated panel's PNG
export depend on a setting the user set for a different viewer.
"""
import base64
import binascii

uri = (change or {}).get("new") or ""
if not uri:
return

status = getattr(app, "_reorg_export_status", None)

def _fail(msg: str) -> None:
if status is not None:
status.value = f'<span style="color:#b22">{msg}</span>'
_clear_inbox(app)

def _clear_inbox(a: Any) -> None:
box = getattr(a, "_reorg_png_inbox", None)
if box is not None and box.value:
box.value = ""

if not uri.startswith(_PNG_URI_PREFIX):
logger.warning("reorg PNG capture: unexpected data URI prefix")
_fail("Capture failed (unexpected image format).")
return
if len(uri) > _MAX_PNG_BYTES:
_fail("Capture failed (image too large).")
return

try:
raw = base64.b64decode(uri[len(_PNG_URI_PREFIX) :], validate=True)
except (binascii.Error, ValueError) as exc:
logger.warning("reorg PNG capture: could not decode payload: %s", exc)
_fail("Capture failed (corrupt image data).")
return

mol = getattr(app, "_molecule", None)
formula = mol.get_formula() if mol is not None else "molecule"
method = str(getattr(getattr(app, "method_dd", None), "value", "") or "")
basis = str(getattr(getattr(app, "basis_dd", None), "value", "") or "")

try:
dest = export_destination(
app, "reorg geometry PNG", formula, "geometry", method, basis, suffix=".png"
)
except ValueError as exc:
_fail(str(exc))
return

try:
dest.write_bytes(raw)
except OSError as exc:
logger.warning("reorg PNG capture: could not write %s: %s", dest, exc)
_fail("Could not write the image (see log).")
return

logger.info("Saved reorg geometry PNG: %s (%d bytes)", dest, len(raw))
if status is not None:
status.value = f'<span style="color:#2a7">Saved: {dest.name}</span>'
_clear_inbox(app)


def on_iso_export_cube(app: Any, btn: Any) -> None:
"""Copy the last-generated cube file to the result folder.

Expand Down
Loading