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 ---