Source code for cosedaUI.ici_window
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from coseda.logging_utils import log_print
import sys
import os
import shlex
import traceback
import subprocess
import re
import time, datetime
from PyQt6.QtCore import Qt, QThread, pyqtSignal, QObject
from PyQt6.QtGui import QTextCursor
from PyQt6.QtWidgets import (
QApplication,
QMainWindow,
QWidget,
QVBoxLayout,
QHBoxLayout,
QFormLayout,
QGroupBox,
QLabel,
QLineEdit,
QPushButton,
QPlainTextEdit,
QSpinBox,
QDoubleSpinBox,
QFileDialog,
QMessageBox,
QScrollArea,
QProgressBar,
)
from coseda.ici import orchestrator as orch
[docs]
class GuiStream:
"""
Simple stream object that forwards writes to a callback.
Used to capture stdout/stderr and send to the GUI.
"""
def __init__(self, callback):
self._callback = callback
[docs]
class OrchestratorWorker(QObject):
"""
Worker object that runs orch.main(argv) inside a QThread
and forwards stdout/stderr and progress to the GUI via signals.
"""
text_ready = pyqtSignal(str)
finished = pyqtSignal(int)
# Progress bar signals (for run_sh.py)
progress_init = pyqtSignal(int) # total events
progress_step = pyqtSignal(int) # increment (usually 1)
progress_done = pyqtSignal() # done / reset
def __init__(self, argv, parent=None):
super().__init__(parent)
self.argv = argv
[docs]
def run(self):
# Redirect stdout/stderr so that orch.OrchestratorRunLogger
# tees to our GuiStream as "real_stream".
orig_out, orig_err = sys.stdout, sys.stderr
gui_stream = GuiStream(self.text_ready.emit)
sys.stdout = gui_stream
sys.stderr = gui_stream
exit_code = 0
try:
# orch.main behaves like a CLI main(argv)
exit_code = orch.main(self.argv)
except SystemExit as e:
# orch may raise SystemExit (from run_py / argparse)
try:
exit_code = int(e.code)
except Exception:
exit_code = 1
except Exception:
# Any unexpected exception: log traceback to GUI
tb = traceback.format_exc()
self.text_ready.emit(tb)
exit_code = 1
finally:
sys.stdout = orig_out
sys.stderr = orig_err
self.finished.emit(exit_code)
[docs]
class OrchestratorWidget(QWidget):
def __init__(self, parent=None):
super().__init__(parent)
self._thread = None
self._worker = None
self._running = False
self._stop_requested = False
self._orig_run_py = None # original orch.run_py
# ---------- Scroll area root ----------
root_layout = QVBoxLayout(self)
scroll = QScrollArea(self)
scroll.setWidgetResizable(True)
root_layout.addWidget(scroll)
container = QWidget(scroll)
scroll.setWidget(container)
main_layout = QVBoxLayout(container)
# ---------- Top group: paths and basic settings ----------
paths_group = QGroupBox("Paths and basic settings", container)
paths_layout = QFormLayout(paths_group)
# Left-align labels and fields for this form layout
paths_layout.setLabelAlignment(Qt.AlignmentFlag.AlignLeft)
paths_layout.setFormAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignTop)
paths_layout.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.AllNonFixedFieldsGrow)
# Run root
self.run_root_edit = QLineEdit(paths_group)
self.run_root_edit.setText(orch.DEFAULT_ROOT)
run_root_btn = QPushButton("Browse…", paths_group)
run_root_btn.clicked.connect(self.browse_run_root)
run_root_row = QHBoxLayout()
run_root_row.addWidget(self.run_root_edit)
run_root_row.addWidget(run_root_btn)
paths_layout.addRow("Run root:", run_root_row)
# Geom
self.geom_edit = QLineEdit(paths_group)
self.geom_edit.setText(orch.DEFAULT_GEOM)
geom_btn = QPushButton("Browse…", paths_group)
geom_btn.clicked.connect(self.browse_geom)
geom_row = QHBoxLayout()
geom_row.addWidget(self.geom_edit)
geom_row.addWidget(geom_btn)
paths_layout.addRow("Geom (.geom):", geom_row)
# Cell
self.cell_edit = QLineEdit(paths_group)
self.cell_edit.setText(orch.DEFAULT_CELL)
cell_btn = QPushButton("Browse…", paths_group)
cell_btn.clicked.connect(self.browse_cell)
cell_row = QHBoxLayout()
cell_row.addWidget(self.cell_edit)
cell_row.addWidget(cell_btn)
paths_layout.addRow("Cell (.cell):", cell_row)
# HDF5 list
self.h5_edit = QPlainTextEdit(paths_group)
# Pre-fill with the default H5 sources, one per line
self.h5_edit.setPlainText("\n".join(orch.DEFAULT_H5))
h5_buttons_layout = QHBoxLayout()
h5_add_btn = QPushButton("Add…", paths_group)
h5_clear_btn = QPushButton("Clear", paths_group)
h5_add_btn.clicked.connect(self.add_h5_files)
h5_clear_btn.clicked.connect(self.clear_h5_files)
h5_buttons_layout.addWidget(h5_add_btn)
h5_buttons_layout.addWidget(h5_clear_btn)
paths_layout.addRow("HDF5 files:", self.h5_edit)
paths_layout.addRow("", h5_buttons_layout)
# Max iterations and Jobs on the same row
self.max_iters_spin = QSpinBox(paths_group)
self.max_iters_spin.setMinimum(1)
self.max_iters_spin.setMaximum(10_000)
self.max_iters_spin.setValue(orch.DEFAULT_MAX_ITERS)
self.jobs_spin = QSpinBox(paths_group)
self.jobs_spin.setMinimum(1)
self.jobs_spin.setMaximum(512)
self.jobs_spin.setValue(int(orch.DEFAULT_NUM_CPU) if orch.DEFAULT_NUM_CPU is not None else os.cpu_count())
iters_jobs_row = QHBoxLayout()
iters_jobs_row.addWidget(QLabel("Max iterations:", paths_group))
iters_jobs_row.addWidget(self.max_iters_spin)
iters_jobs_row.addSpacing(20)
iters_jobs_row.addWidget(QLabel("Jobs:", paths_group))
iters_jobs_row.addWidget(self.jobs_spin)
iters_jobs_row.addStretch(1)
paths_layout.addRow(iters_jobs_row)
main_layout.addWidget(paths_group)
# ---------- Middle group: convergence / propose_next_shifts ----------
conv_group = QGroupBox("Convergence / Hillmap search parameters (Hover over each field for a short description)", container)
conv_layout = QFormLayout(conv_group)
conv_layout.setLabelAlignment(Qt.AlignmentFlag.AlignLeft)
conv_layout.setFormAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignTop)
conv_layout.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.AllNonFixedFieldsGrow)
# --- Widgets (same behavior as before) ---
# radius_mm
self.radius_spin = QDoubleSpinBox(conv_group)
self.radius_spin.setDecimals(3)
self.radius_spin.setRange(0.0, 10.0)
self.radius_spin.setSingleStep(0.001)
self.radius_spin.setValue(float(orch.radius_mm))
# min_spacing_mm
self.min_spacing_spin = QDoubleSpinBox(conv_group)
self.min_spacing_spin.setDecimals(5)
self.min_spacing_spin.setRange(0.0, 1.0)
self.min_spacing_spin.setSingleStep(1e-4)
self.min_spacing_spin.setValue(float(orch.min_spacing_mm))
# N_conv
self.N_conv_spin = QSpinBox(conv_group)
self.N_conv_spin.setMinimum(1)
self.N_conv_spin.setMaximum(1000)
self.N_conv_spin.setValue(int(orch.N_conv))
# recurring_tol
self.recurring_tol_spin = QDoubleSpinBox(conv_group)
self.recurring_tol_spin.setDecimals(2)
self.recurring_tol_spin.setRange(0.0, 1.0)
self.recurring_tol_spin.setSingleStep(0.01)
self.recurring_tol_spin.setValue(float(orch.recurring_tol))
# median_rel_tol
self.median_rel_tol_spin = QDoubleSpinBox(conv_group)
self.median_rel_tol_spin.setDecimals(2)
self.median_rel_tol_spin.setRange(0.0, 1.0)
self.median_rel_tol_spin.setSingleStep(0.01)
self.median_rel_tol_spin.setValue(float(orch.median_rel_tol))
# noimprove_N
self.noimprove_N_spin = QSpinBox(conv_group)
self.noimprove_N_spin.setMinimum(1)
self.noimprove_N_spin.setMaximum(1000)
self.noimprove_N_spin.setValue(int(orch.noimprove_N))
# noimprove_eps
self.noimprove_eps_spin = QDoubleSpinBox(conv_group)
self.noimprove_eps_spin.setDecimals(2)
self.noimprove_eps_spin.setRange(0.0, 1.0)
self.noimprove_eps_spin.setSingleStep(0.01)
self.noimprove_eps_spin.setValue(float(orch.noimprove_eps))
# stability_N
self.stability_N_spin = QSpinBox(conv_group)
self.stability_N_spin.setMinimum(1)
self.stability_N_spin.setMaximum(1000)
self.stability_N_spin.setValue(int(orch.stability_N))
# stability_std
self.stability_std_spin = QDoubleSpinBox(conv_group)
self.stability_std_spin.setDecimals(2)
self.stability_std_spin.setRange(0.0, 1.0)
self.stability_std_spin.setSingleStep(0.01)
self.stability_std_spin.setValue(float(orch.stability_std))
# done_on_streak_successes
self.done_streak_succ_spin = QSpinBox(conv_group)
self.done_streak_succ_spin.setMinimum(1)
self.done_streak_succ_spin.setMaximum(1000)
self.done_streak_succ_spin.setValue(int(orch.done_on_streak_successes))
# done_on_streak_length
self.done_streak_len_spin = QSpinBox(conv_group)
self.done_streak_len_spin.setMinimum(1)
self.done_streak_len_spin.setMaximum(1000)
self.done_streak_len_spin.setValue(int(orch.done_on_streak_length))
# damping factor λ
self.lambda_spin = QDoubleSpinBox(conv_group)
self.lambda_spin.setDecimals(2)
self.lambda_spin.setRange(0.0, 10.0)
self.lambda_spin.setSingleStep(0.1)
self.lambda_spin.setValue(float(orch.λ))
# --- Rows as you requested ---
# Row 1: radius_mm, min_spacing_mm, damping factor (λ)
row1 = QHBoxLayout()
row1.addWidget(QLabel("radius_mm:", conv_group))
row1.addWidget(self.radius_spin)
row1.addSpacing(10)
row1.addWidget(QLabel("min_spacing_mm:", conv_group))
row1.addWidget(self.min_spacing_spin)
row1.addSpacing(10)
row1.addWidget(QLabel("damping factor (λ):", conv_group))
row1.addWidget(self.lambda_spin)
row1.addStretch(1)
conv_layout.addRow(row1)
# Row 2: N_conv, recurring_tol, median_rel_tol
row2 = QHBoxLayout()
row2.addWidget(QLabel("N_conv:", conv_group))
row2.addWidget(self.N_conv_spin)
row2.addSpacing(10)
row2.addWidget(QLabel("recurring_tol:", conv_group))
row2.addWidget(self.recurring_tol_spin)
row2.addSpacing(10)
row2.addWidget(QLabel("median_rel_tol:", conv_group))
row2.addWidget(self.median_rel_tol_spin)
row2.addStretch(1)
conv_layout.addRow(row2)
# Row 3: noimprove_N, noimprove_eps
row3 = QHBoxLayout()
row3.addWidget(QLabel("noimprove_N:", conv_group))
row3.addWidget(self.noimprove_N_spin)
row3.addSpacing(10)
row3.addWidget(QLabel("noimprove_eps:", conv_group))
row3.addWidget(self.noimprove_eps_spin)
row3.addStretch(1)
conv_layout.addRow(row3)
# Row 4: stability_N, stability_std
row4 = QHBoxLayout()
row4.addWidget(QLabel("stability_N:", conv_group))
row4.addWidget(self.stability_N_spin)
row4.addSpacing(10)
row4.addWidget(QLabel("stability_std:", conv_group))
row4.addWidget(self.stability_std_spin)
row4.addStretch(1)
conv_layout.addRow(row4)
# Row 5: done_on_streak_successes, done_on_streak_length
row5 = QHBoxLayout()
row5.addWidget(QLabel("done_on_streak_successes:", conv_group))
row5.addWidget(self.done_streak_succ_spin)
row5.addSpacing(10)
row5.addWidget(QLabel("done_on_streak_length:", conv_group))
row5.addWidget(self.done_streak_len_spin)
row5.addStretch(1)
conv_layout.addRow(row5)
main_layout.addWidget(conv_group)
# ---------- Flags group ----------
flags_group = QGroupBox("Indexamajig / xgandalf / integration flags", container)
flags_layout = QVBoxLayout(flags_group)
self.flags_edit = QPlainTextEdit(flags_group)
# Free text, pre-filled from DEFAULT_FLAGS
self.flags_edit.setPlainText(" ".join(orch.DEFAULT_FLAGS))
flags_layout.addWidget(QLabel("Flags passed to indexamajig as free text. Make sure the command is valid and includes\nmandatory flags like --peaks, --indexing=xgandalf, and --no-half-pixel-shift etc", flags_group))
flags_layout.addWidget(self.flags_edit)
main_layout.addWidget(flags_group)
# ---------- Bottom run + stop buttons ----------
bottom_layout = QHBoxLayout()
self.run_button = QPushButton("Run orchestration", container)
self.run_button.clicked.connect(self.on_run_clicked)
bottom_layout.addWidget(self.run_button)
self.stop_button = QPushButton("Stop", container)
self.stop_button.clicked.connect(self.on_stop_clicked)
self.stop_button.setEnabled(False)
bottom_layout.addWidget(self.stop_button)
bottom_layout.addStretch(1)
main_layout.addLayout(bottom_layout)
# ---------- Progress bar + explanation ----------
self.progress_label = QLabel(
# "Progress (run_sh.py indexing events; only updates while run_sh.py is running):",
"Progress (updates with processed indexing events):",
container,
)
main_layout.addWidget(self.progress_label)
self.progress_bar = QProgressBar(container)
self.progress_bar.setMinimum(0)
self.progress_bar.setMaximum(100)
self.progress_bar.setValue(0)
self.progress_bar.setFormat("%p%") # just percentage inside bar
self.progress_bar.setTextVisible(True)
main_layout.addWidget(self.progress_bar)
self.progress_start_time = None
# ---------- Log output ----------
self.log_edit = QPlainTextEdit(container)
self.log_edit.setReadOnly(True)
self.log_edit.setLineWrapMode(QPlainTextEdit.LineWrapMode.NoWrap)
self.log_edit.setMinimumHeight(500) # make it bigger by default
main_layout.addWidget(self.log_edit, stretch=1)
# ---------- Tooltips for parameters ----------
self.max_iters_spin.setToolTip(
"Maximum number of iterations of the orchestration loop."
)
self.jobs_spin.setToolTip(
"Number of parallel jobs (default is os.cpu_count(), i.e. all available CPUs)."
)
self.radius_spin.setToolTip(
"Search radius in mm for propose-next-shift (default: 0.05 mm)."
)
self.min_spacing_spin.setToolTip(
"Minimum spacing in mm between proposed shifts (default: 5e-4 mm)."
)
self.N_conv_spin.setToolTip(
"Minimum number of events required to consider convergence (default: 3)."
)
self.recurring_tol_spin.setToolTip(
"Tolerance for recurring shifts; 0.1 = 10% relative difference allowed."
)
self.median_rel_tol_spin.setToolTip(
"Median relative tolerance for convergence; 0.1 = 10% relative change threshold."
)
self.noimprove_N_spin.setToolTip(
"Number of iterations with no improvement required to trigger 'no-improve' convergence (default: 2)."
)
self.noimprove_eps_spin.setToolTip(
"Minimum relative improvement considered significant; 0.02 = 2% (below this counts as 'no improvement')."
)
self.stability_N_spin.setToolTip(
"Number of iterations over which stability is evaluated (default: 3)."
)
self.stability_std_spin.setToolTip(
"Standard deviation threshold for stability; 0.05 = 5% spread allowed in shifts."
)
self.done_streak_succ_spin.setToolTip(
"Number of successful iterations within an unindexed streak required to consider the run 'done' (default: 2)."
)
self.done_streak_len_spin.setToolTip(
"Length of the unindexed streak used together with done_on_streak_successes to mark the run as done (default: 5)."
)
self.lambda_spin.setToolTip(
"Damping factor λ for refined shift updates (0 = full damping, 1 = no damping; default: 0.8)."
)
# ---------- Align spin box contents to the left ----------
int_spin_boxes = [
self.max_iters_spin,
self.jobs_spin,
self.N_conv_spin,
self.noimprove_N_spin,
self.stability_N_spin,
self.done_streak_succ_spin,
self.done_streak_len_spin,
]
double_spin_boxes = [
self.radius_spin,
self.min_spacing_spin,
self.recurring_tol_spin,
self.median_rel_tol_spin,
self.noimprove_eps_spin,
self.stability_std_spin,
self.lambda_spin,
]
for sb in int_spin_boxes + double_spin_boxes:
sb.setAlignment(Qt.AlignmentFlag.AlignLeft)
# ---------- Path normalization helper ----------
def _normalize_path(self, path: str) -> str:
"""Expand ~ and make path absolute."""
return os.path.abspath(os.path.expanduser(path))
# ---------- Browsers ----------
[docs]
def browse_run_root(self):
path = QFileDialog.getExistingDirectory(
self,
"Select run root directory",
self.run_root_edit.text() or ".",
)
if path:
self.run_root_edit.setText(path)
[docs]
def browse_geom(self):
path, _ = QFileDialog.getOpenFileName(
self,
"Select .geom file",
self.geom_edit.text() or ".",
"Geom files (*.geom);;All files (*)",
)
if path:
self.geom_edit.setText(path)
[docs]
def browse_cell(self):
path, _ = QFileDialog.getOpenFileName(
self,
"Select .cell file",
self.cell_edit.text() or ".",
"Cell files (*.cell);;All files (*)",
)
if path:
self.cell_edit.setText(path)
[docs]
def add_h5_files(self):
paths, _ = QFileDialog.getOpenFileNames(
self,
"Select HDF5 files",
".",
"HDF5 files (*.h5 *.hdf5);;All files (*)",
)
if not paths:
return
current = self.h5_edit.toPlainText().strip()
lines = [line for line in current.splitlines() if line.strip()]
lines.extend(paths)
self.h5_edit.setPlainText("\n".join(lines))
# ---------- Log handling ----------
[docs]
def append_text(self, text: str):
# Append text and scroll to bottom
self.log_edit.moveCursor(QTextCursor.MoveOperation.End)
self.log_edit.insertPlainText(text)
self.log_edit.moveCursor(QTextCursor.MoveOperation.End)
# ---------- Stop handling (flag + run_py monkeypatch) ----------
def _patch_run_py_for_stop_and_progress(self, worker: OrchestratorWorker):
"""
Monkey-patch orch.run_py so that it:
- Checks self._stop_requested
- Terminates child subprocesses when stop is requested
- Emits progress signals for run_sh.py
"""
if self._orig_run_py is not None:
# already patched
return
self._orig_run_py = orch.run_py
window = self
w = worker
def gui_run_py(script: str, args, check: bool = True) -> int:
"""
GUI-aware replacement for orch.run_py:
- Uses orch.SCRIPT_DIR to locate helper scripts
- Honors window._stop_requested and aborts orchestration via SystemExit
- Emits progress signals for run_sh.py
"""
is_run_sh = (script == "run_sh.py")
# Resolve script path relative to ici_orchestrator.py
script_dir = getattr(orch, "SCRIPT_DIR", os.path.dirname(os.path.abspath(orch.__file__)))
script_path = os.path.join(script_dir, script)
# If stop was requested before starting, abort immediately
if window._stop_requested:
raise SystemExit(0)
if is_run_sh:
cmd = ["python3", "-u", script_path, *args]
else:
cmd = ["python3", script_path, *args]
proc = subprocess.Popen(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
bufsize=1,
)
# ---------- Non–run_sh scripts ----------
if not is_run_sh:
try:
for line in proc.stdout:
# Forward output to stdout (which goes into the GUI via GuiStream)
log_print(line, end="", flush=True)
# Check for stop request while script is running
if window._stop_requested:
proc.terminate()
proc.wait()
# Abort the whole orchestration
raise SystemExit(0)
finally:
if proc.stdout is not None:
proc.stdout.close()
rc = proc.wait()
# If we didn't press stop: propagate non-zero exit normally
if check and rc != 0 and not window._stop_requested:
raise SystemExit(f"[ERR] {script} exited with {rc}")
return rc
# ---------- run_sh.py with progress bar ----------
total_events = None
done_events = 0
event_re = re.compile(r"\brunning\s+(\d+)\s+event\b", re.I)
try:
for line in proc.stdout:
# Event completion markers
if "__EVENT_DONE__" in line:
done_events += 1
if total_events is not None:
w.progress_step.emit(1)
if window._stop_requested:
proc.terminate()
proc.wait()
raise SystemExit(0)
continue
# Normal line output
log_print(line, end="", flush=True)
# Check stop after printing normal line
if window._stop_requested:
proc.terminate()
proc.wait()
raise SystemExit(0)
# Detect total number of events once
m = event_re.search(line)
if m and total_events is None:
total_events = int(m.group(1))
w.progress_init.emit(total_events)
continue
finally:
if proc.stdout is not None:
proc.stdout.close()
# Tell GUI to mark progress as done/reset
w.progress_done.emit()
rc = proc.wait()
if check and rc != 0 and not window._stop_requested:
raise SystemExit(f"[ERR] {script} exited with {rc}")
return rc
orch.run_py = gui_run_py
def _unpatch_run_py(self):
if self._orig_run_py is not None:
orch.run_py = self._orig_run_py
self._orig_run_py = None
# ---------- Running orchestrator ----------
[docs]
def on_run_clicked(self):
if self._running:
QMessageBox.warning(
self,
"Orchestrator running",
"An orchestration run is already in progress.",
)
return
argv = self.build_argv()
if argv is None:
# build_argv already showed an error
return
self.log_edit.clear()
self.append_text(f"[GUI] Starting orchestration with output in: {argv[1]}\n")
self._running = True
self._stop_requested = False
self.run_button.setEnabled(True)
self.stop_button.setEnabled(True)
self._thread = QThread(self)
self._worker = OrchestratorWorker(argv)
self._worker.moveToThread(self._thread)
# Patch run_py so we can stop ongoing subprocesses and get progress
self._patch_run_py_for_stop_and_progress(self._worker)
self._thread.started.connect(self._worker.run)
self._worker.text_ready.connect(self.append_text)
self._worker.finished.connect(self.on_worker_finished)
self._worker.finished.connect(self._thread.quit)
self._thread.finished.connect(self._thread.deleteLater)
# Progress connections
self._worker.progress_init.connect(self.on_progress_init)
self._worker.progress_step.connect(self.on_progress_step)
self._worker.progress_done.connect(self.on_progress_done)
self._thread.start()
[docs]
def on_stop_clicked(self):
if not self._running:
return
self._stop_requested = True
self.stop_button.setEnabled(False)
self.append_text("\n[GUI] Stop requested. Will abort after current step.\n")
[docs]
def on_worker_finished(self, exit_code: int):
self._running = False
self.run_button.setEnabled(True)
self.stop_button.setEnabled(False)
self._unpatch_run_py()
self.append_text(f"\n[GUI] Orchestrator finished with exit code {exit_code}.\n")
if exit_code != 0 and not self._stop_requested:
QMessageBox.warning(
self,
"Orchestrator finished with error",
f"Orchestrator exited with code {exit_code}. "
f"Check the log above and the orchestrator.log file for details.",
)
def _format_eta(self, seconds: float) -> str:
if seconds is None or seconds <= 0 or seconds == float("inf"):
return "estimating..."
total = int(seconds)
m, s = divmod(total, 60)
h, m = divmod(m, 60)
if h > 0:
return f"{h:d}h {m:02d}m {s:02d}s"
else:
return f"{m:02d}m {s:02d}s"
# ---------- Progress bar slots ----------
[docs]
def on_progress_init(self, total: int):
# Called when run_sh.py prints "running N event"
self.progress_start_time = time.time()
self.progress_bar.setMinimum(0)
self.progress_bar.setMaximum(total)
self.progress_bar.setValue(0)
self.progress_bar.setFormat("%p%") # keep bar text simple
self.progress_label.setText(
f"Indexing: 0% (0 / {total} events), ETA estimating..."
)
[docs]
def on_progress_step(self, step: int):
# Called once per "__EVENT_DONE__"
new_val = min(self.progress_bar.value() + step, self.progress_bar.maximum())
self.progress_bar.setValue(new_val)
total = self.progress_bar.maximum()
done = new_val
if total <= 0:
return
percent = 100.0 * done / total
# ETA
if self.progress_start_time is not None and done > 0:
elapsed = time.time() - self.progress_start_time
rate = done / elapsed if elapsed > 0 else 0.0
eta = (total - done) / rate if rate > 0 else None
eta_str = self._format_eta(eta)
else:
eta_str = "estimating..."
self.progress_label.setText(
f"Indexing: {percent:5.1f}% ({done} / {total} events), ETA {eta_str}"
)
[docs]
def on_progress_done(self):
# Called when run_sh.py subprocess exits
total = self.progress_bar.maximum()
if total > 0:
self.progress_bar.setValue(total)
self.progress_label.setText(
f"[run_sh] Indexing finished ({total} / {total} events)"
)
else:
self.progress_label.setText("Progress (last run_sh.py finished)")
self.progress_start_time = None
[docs]
def build_argv(self):
"""
Build a CLI-style argv list for orch.main from the GUI state.
Instead of passing --flags on the CLI (which is awkward because
the flags themselves begin with "-"), we override orch.DEFAULT_FLAGS
directly based on the free-text field.
"""
run_root = self._normalize_path(self.run_root_edit.text().strip())
geom = self._normalize_path(self.geom_edit.text().strip())
cell = self._normalize_path(self.cell_edit.text().strip())
if not run_root:
QMessageBox.critical(self, "Missing run root", "Please specify a run root directory.")
return None
if not geom:
QMessageBox.critical(self, "Missing geom", "Please specify a .geom file.")
return None
if not cell:
QMessageBox.critical(self, "Missing cell", "Please specify a .cell file.")
return None
# HDF5 sources: one per non-empty line
raw_h5_lines = [line.strip() for line in self.h5_edit.toPlainText().splitlines() if line.strip()]
if not raw_h5_lines:
raw_h5_lines = list(orch.DEFAULT_H5)
h5_lines = [self._normalize_path(p) for p in raw_h5_lines]
max_iters = self.max_iters_spin.value()
jobs = self.jobs_spin.value()
# Convergence parameters
radius_mm = self.radius_spin.value()
min_spacing_mm = self.min_spacing_spin.value()
N_conv = self.N_conv_spin.value()
recurring_tol = self.recurring_tol_spin.value()
median_rel_tol = self.median_rel_tol_spin.value()
noimprove_N = self.noimprove_N_spin.value()
noimprove_eps = self.noimprove_eps_spin.value()
stability_N = self.stability_N_spin.value()
stability_std = self.stability_std_spin.value()
done_streak_succ = self.done_streak_succ_spin.value()
done_streak_len = self.done_streak_len_spin.value()
lambda_val = self.lambda_spin.value()
# Flags: free text → override orch.DEFAULT_FLAGS directly
flags_text = self.flags_edit.toPlainText().strip()
if flags_text:
try:
flag_list = shlex.split(flags_text)
except ValueError as e:
QMessageBox.critical(
self,
"Invalid flags",
f"Could not parse flags text:\n{e}",
)
return None
# Override the module-level default flags used by ici_orchestrator
orch.DEFAULT_FLAGS = flag_list
# Now build argv WITHOUT any explicit --flags, so argparse
# just uses orch.DEFAULT_FLAGS (which we've possibly overridden).
argv = []
argv.extend(["--run-root", run_root])
argv.extend(["--geom", geom])
argv.extend(["--cell", cell])
# HDF5 sources
argv.append("--h5")
argv.extend(h5_lines)
# Max iterations & jobs
argv.extend(["--max-iters", str(max_iters)])
argv.extend(["--jobs", str(jobs)])
# Convergence arguments (mirror ici_orchestrator.main)
argv.extend(["--radius-mm", str(radius_mm)])
argv.extend(["--min-spacing-mm", str(min_spacing_mm)])
argv.extend(["--N-conv", str(N_conv)])
argv.extend(["--recurring-tol", str(recurring_tol)])
argv.extend(["--median-rel-tol", str(median_rel_tol)])
argv.extend(["--noimprove-N", str(noimprove_N)])
argv.extend(["--noimprove-eps", str(noimprove_eps)])
argv.extend(["--stability-N", str(stability_N)])
argv.extend(["--stability-std", str(stability_std)])
argv.extend(["--done-on-streak-successes", str(done_streak_succ)])
argv.extend(["--done-on-streak-length", str(done_streak_len)])
argv.extend(["--damping-factor", str(lambda_val)])
return argv
# ---------- Close handling ----------
# def closeEvent(self, event):
# # On close, request stop if something is running.
# if self._running:
# self._stop_requested = True
# self.append_text("\n[GUI] Window closed: stop requested.\n")
# super().closeEvent(event)
[docs]
def request_stop_on_close(self):
if self._running:
self._stop_requested = True
self.append_text("\n[GUI] Window closed: stop requested.\n")
[docs]
class OrchestratorMainWindow(QMainWindow):
def __init__(self, parent=None):
super().__init__(parent)
self.setWindowTitle("ICI Orchestrator GUI")
self.resize(1000, 800)
self.widget = OrchestratorWidget(self)
self.setCentralWidget(self.widget)
[docs]
def closeEvent(self, event):
if self.widget is not None:
self.widget.request_stop_on_close()
super().closeEvent(event)
[docs]
def main():
app = QApplication(sys.argv)
win = OrchestratorMainWindow()
win.show()
sys.exit(app.exec())
if __name__ == "__main__":
main()