Source code for cosedaUI.indexintegrate_sections.cellfile

import os
import re

from PyQt6.QtCore import Qt, QTimer
from PyQt6.QtWidgets import (
    QFileDialog,
    QFormLayout,
    QHBoxLayout,
    QLabel,
    QLineEdit,
    QMessageBox,
    QPlainTextEdit,
    QPushButton,
    QComboBox,
    QVBoxLayout,
    QWidget,
)

from coseda.logging_utils import log_print
from coseda.crystfel_cell import (
    CRYSTFEL_LATTICE_TYPES,
    crystfel_unique_axis_allowed,
    normalize_crystfel_lattice_type,
)


[docs] class CellfileMixin: def _init_cellfile_tab(self): """Build the cellfile tab for editing/writing CrystFEL cell parameters.""" self.cell_tab = QWidget() layout = QVBoxLayout() form_layout = QFormLayout() form_layout.setLabelAlignment(Qt.AlignmentFlag.AlignLeft) form_layout.setFormAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignTop) form_layout.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.AllNonFixedFieldsGrow) # Lattice type combobox self.lattice_type_combo = QComboBox() self.lattice_type_combo.addItems(CRYSTFEL_LATTICE_TYPES) # Centering combobox self.centering_combo = QComboBox() self.centering_combo.addItems([ "P", "A", "B", "C", "I", "F", "R" ]) # Unique axis combobox self.unique_axis_combo = QComboBox() self.unique_axis_combo.addItems([ "a", "b", "c" ]) # --- lattice type/unique axis logic --- def on_lattice_type_changed(text): t = normalize_crystfel_lattice_type(text) # CrystFEL accepts unique_axis for lattices with a unique axis. if crystfel_unique_axis_allowed(t): self.unique_axis_combo.setEnabled(True) if t in ("tetragonal", "hexagonal") and self.unique_axis_combo.currentIndex() == -1: default_idx = self.unique_axis_combo.findText("c") if default_idx >= 0: self.unique_axis_combo.setCurrentIndex(default_idx) else: self.unique_axis_combo.setCurrentIndex(-1) self.unique_axis_combo.setEnabled(False) # Auto-set angles to 90 for cubic, tetragonal, orthorhombic if t in ("cubic", "tetragonal", "orthorhombic"): self.al_edit.setText("90") self.be_edit.setText("90") self.ga_edit.setText("90") self.lattice_type_combo.currentTextChanged.connect(on_lattice_type_changed) # Initialize state based on default lattice type on_lattice_type_changed(self.lattice_type_combo.currentText()) self.a_edit = QLineEdit() self.b_edit = QLineEdit() self.c_edit = QLineEdit() self.al_edit = QLineEdit() self.be_edit = QLineEdit() self.ga_edit = QLineEdit() form_layout.addRow("Lattice type:", self.lattice_type_combo) form_layout.addRow("Centering:", self.centering_combo) form_layout.addRow("Unique axis:", self.unique_axis_combo) form_layout.addRow("a:", self.a_edit) form_layout.addRow("b:", self.b_edit) form_layout.addRow("c:", self.c_edit) form_layout.addRow("α:", self.al_edit) form_layout.addRow("β:", self.be_edit) form_layout.addRow("γ:", self.ga_edit) layout.addLayout(form_layout) # Workspace integration buttons buttons_row = QHBoxLayout() self.save_cell_to_workspace_btn = QPushButton("Save Cell to Workspace") self.save_cell_to_workspace_btn.clicked.connect(self._save_cell_to_workspace) buttons_row.addWidget(self.save_cell_to_workspace_btn) self.load_cell_from_workspace_btn = QPushButton("Load from Workspace") self.load_cell_from_workspace_btn.clicked.connect(self._load_cell_from_workspace_and_notify) buttons_row.addWidget(self.load_cell_from_workspace_btn) layout.addLayout(buttons_row) # Timer for debounced saving self.cell_timer = QTimer(self) self.cell_timer.setSingleShot(True) self.cell_timer.timeout.connect(self._save_cell_file) # Connect change signals to start timer self.lattice_type_combo.currentTextChanged.connect(lambda: self.cell_timer.start(500)) self.centering_combo.currentTextChanged.connect(lambda: self.cell_timer.start(500)) self.unique_axis_combo.currentTextChanged.connect(lambda: self.cell_timer.start(500)) self.a_edit.textChanged.connect(lambda: self.cell_timer.start(500)) self.b_edit.textChanged.connect(lambda: self.cell_timer.start(500)) self.c_edit.textChanged.connect(lambda: self.cell_timer.start(500)) self.al_edit.textChanged.connect(lambda: self.cell_timer.start(500)) self.be_edit.textChanged.connect(lambda: self.cell_timer.start(500)) self.ga_edit.textChanged.connect(lambda: self.cell_timer.start(500)) self.cell_tab.setLayout(layout) self.tabs.addTab(self.cell_tab, "Cell Editor") def _parse_workspace_kv(self): """Parse key=value pairs from the workspace file header (commented lines starting with '#').""" import os kv = {} path = getattr(self, 'workspace_path', None) if not path or not isinstance(path, str) or not os.path.exists(path): return kv try: with open(path, 'r') as f: for line in f: s = line.strip() if not s.startswith('#'): # stop at first non-comment – the rest are INI paths # but allow commented settings anywhere; do not break early pass if s.startswith('#') and '=' in s: try: k, v = s[1:].split('=', 1) kv[k.strip()] = v.strip() except Exception: continue except Exception: return {} return kv def _load_cell_from_workspace(self) -> bool: """Prefill cell tab fields from workspace settings if available. Returns True if applied.""" kv = self._parse_workspace_kv() if not kv: return False try: # Lattice/meta lt = kv.get('Cell.lattice_type') or kv.get('lattice_type') cent = kv.get('Cell.centering') or kv.get('centering') if cent: self.centering_combo.setCurrentText(cent) if lt: self.lattice_type_combo.setCurrentText(normalize_crystfel_lattice_type(lt, cent)) ua = kv.get('Cell.unique_axis') or kv.get('unique_axis') if ua and self.unique_axis_combo.isEnabled(): self.unique_axis_combo.setCurrentText(ua) # Parameters def _set(edit, key): val = kv.get(f'Cell.{key}') or kv.get(key) if val: edit.setText(val.split()[0]) _set(self.a_edit, 'a') _set(self.b_edit, 'b') _set(self.c_edit, 'c') _set(self.al_edit, 'al') _set(self.be_edit, 'be') _set(self.ga_edit, 'ga') return True except Exception: return False def _load_cell_from_workspace_and_notify(self): applied = self._load_cell_from_workspace() if applied: QMessageBox.information(self, "Workspace", "Cell settings loaded from workspace.") self._update_command_label() else: QMessageBox.warning(self, "Workspace", "No cell settings found in workspace or workspace not available.") def _save_cell_to_workspace(self): """Write current cell fields into the workspace file header as commented key=value lines.""" import os path = getattr(self, 'workspace_path', None) if not path or not isinstance(path, str): QMessageBox.critical(self, "Error", "Workspace file path not available.") return # Gather and validate lt = normalize_crystfel_lattice_type( self.lattice_type_combo.currentText(), self.centering_combo.currentText(), ) cent = self.centering_combo.currentText().strip() ua = self.unique_axis_combo.currentText().strip() if crystfel_unique_axis_allowed(lt) else '' a = self.a_edit.text().strip() b = self.b_edit.text().strip() c = self.c_edit.text().strip() al = self.al_edit.text().strip() be = self.be_edit.text().strip() ga = self.ga_edit.text().strip() required = [lt, cent, a, b, c, al, be, ga] if not all(required): QMessageBox.critical(self, "Error", "Fill all required cell fields before saving to workspace.") return try: float(a); float(b); float(c); float(al); float(be); float(ga) except ValueError: QMessageBox.critical(self, "Error", "Non-numeric value in cell parameters.") return # Read existing lines try: if os.path.exists(path): with open(path, 'r') as f: lines = f.readlines() else: lines = [] except Exception as e: QMessageBox.critical(self, "Error", f"Failed to read workspace file: {e}") return # Build/replace header key-values header_keys = { 'Cell.lattice_type': lt, 'Cell.centering': cent, # unique_axis only when applicable } if ua: header_keys['Cell.unique_axis'] = ua header_keys.update({ 'Cell.a': a, 'Cell.b': b, 'Cell.c': c, 'Cell.al': al, 'Cell.be': be, 'Cell.ga': ga, }) # Create a dict of existing commented KV indices to update in place kv_indices = {} for idx, line in enumerate(lines): s = line.strip() if s.startswith('#') and '=' in s: try: k, _ = s[1:].split('=', 1) k = k.strip() if k in header_keys and k not in kv_indices: kv_indices[k] = idx except Exception: continue # If file has no lines yet, start with an empty header list if not lines: lines = [] # Ensure there is an empty header block at the top: insert/update keys at the beginning before any non-comment line # Find the insertion point: before the first non-comment, non-empty line insert_at = 0 for i, line in enumerate(lines): if line.strip() and not line.strip().startswith('#'): insert_at = i break insert_at = i + 1 # Update existing keys for k, v in header_keys.items(): new_line = f"# {k}={v}\n" if k in kv_indices: lines[kv_indices[k]] = new_line else: lines.insert(insert_at, new_line) insert_at += 1 # Write back try: with open(path, 'w') as f: f.writelines(lines) QMessageBox.information(self, "Workspace", "Cell settings saved to workspace.") except Exception as e: QMessageBox.critical(self, "Error", f"Failed to write workspace file: {e}") def _load_cell_file(self): import os # Determine which cell file to load: previous run or new run path = self.prev_cell_file if getattr(self, "prev_cell_file", None) and os.path.exists(self.prev_cell_file) else self.cell_filepath if path and os.path.exists(path): try: # Read and strip lines with open(path, "r") as f: raw_lines = [ln.strip() for ln in f if ln.strip() and not ln.startswith("#")] # Skip header if present if raw_lines and "version" in raw_lines[0].lower(): raw_lines = raw_lines[1:] # Parse key/value lines (supports '=' and ':' delimiters) cell_vals = {} for ln in raw_lines: if "=" in ln: k, v = ln.split("=", 1) elif ":" in ln: k, v = ln.split(":", 1) else: continue cell_vals[k.strip()] = v.strip() # Populate UI fields centering = cell_vals.get("centering", "") if "lattice_type" in cell_vals: self.lattice_type_combo.setCurrentText( normalize_crystfel_lattice_type(cell_vals["lattice_type"], centering) ) if "centering" in cell_vals: self.centering_combo.setCurrentText(cell_vals["centering"]) if "unique_axis" in cell_vals and self.unique_axis_combo.isEnabled(): self.unique_axis_combo.setCurrentText(cell_vals["unique_axis"]) if "a" in cell_vals: self.a_edit.setText(cell_vals["a"].split()[0]) if "b" in cell_vals: self.b_edit.setText(cell_vals["b"].split()[0]) if "c" in cell_vals: self.c_edit.setText(cell_vals["c"].split()[0]) if "al" in cell_vals: self.al_edit.setText(cell_vals["al"].split()[0]) if "be" in cell_vals: self.be_edit.setText(cell_vals["be"].split()[0]) if "ga" in cell_vals: self.ga_edit.setText(cell_vals["ga"].split()[0]) except Exception as e: log_print(f"Failed to parse cell file {path}: {e}") # Update command label in case path changed self._update_command_label() def _ensure_run_selected_or_latest(self) -> bool: if getattr(self, 'run_dir', None) and os.path.isdir(self.run_dir): return True # Try to find latest run for current INI without relying on INI Paths if not self.ini_path or not os.path.exists(self.ini_path): return False import glob, os as _os run_base = self._resolve_run_root(self.ini_path) # Also look directly under the INI folder as a fallback base_dir = _os.path.dirname(self.ini_path) prev_runs = sorted( set(glob.glob(_os.path.join(run_base, 'indexingintegration_*'))) | set(glob.glob(_os.path.join(base_dir, 'indexingintegration_*'))) ) latest_run = prev_runs[-1] if prev_runs else None if latest_run and os.path.isdir(latest_run): self._apply_run_dir(latest_run) return True return False def _write_cell_file_to_path(self, path: str, *, show_errors: bool, label: str) -> bool: cent = self.centering_combo.currentText().strip() lt = normalize_crystfel_lattice_type(self.lattice_type_combo.currentText(), cent) ua = self.unique_axis_combo.currentText().strip() if crystfel_unique_axis_allowed(lt) else "" a = self.a_edit.text().strip() b = self.b_edit.text().strip() c = self.c_edit.text().strip() al = self.al_edit.text().strip() be = self.be_edit.text().strip() ga = self.ga_edit.text().strip() if not all([lt, cent, a, b, c, al, be, ga]): if show_errors: QMessageBox.critical(self, "Error", "Fill all required cell fields before writing.") return False if self.unique_axis_combo.isEnabled() and not ua: if show_errors: QMessageBox.critical(self, "Error", "Select a unique axis before writing the cell file.") return False try: float(a) float(b) float(c) float(al) float(be) float(ga) except ValueError: if show_errors: QMessageBox.critical(self, "Error", "Please enter valid numeric values for all cell parameters.") return False try: with open(path, "w") as f: f.write("CrystFEL unit cell file version 1.0\n\n") f.write(f"lattice_type = {lt}\n\n") f.write(f"centering = {cent}\n") if ua: f.write(f"unique_axis = {ua}\n\n") else: f.write("\n") f.write(f"a = {a} A\n") f.write(f"b = {b} A\n") f.write(f"c = {c} A\n") f.write(f"al = {al} deg\n") f.write(f"be = {be} deg\n") f.write(f"ga = {ga} deg\n") return True except Exception as e: if show_errors: QMessageBox.critical(self, "Error", f"Failed to write {label} file:\n{e}") return False def _save_cell_file(self, *, show_errors: bool = False) -> bool: if not self.cell_filepath: # Try to select the latest run automatically if not self._ensure_run_selected_or_latest(): # No run available; silently abort return False saved = self._write_cell_file_to_path( self.cell_filepath, show_errors=show_errors, label="cell", ) if saved and hasattr(self, "command_edit"): self._update_command_label() return saved
# --- Geometry File Editor Tab ---