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] def write(self, text): if text: self._callback(text)
[docs] def flush(self): # Nothing special required; provided for compatibility. pass
[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))
[docs] def clear_h5_files(self): self.h5_edit.clear()
# ---------- 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()