"""UI to build and validate a CrystFEL geometry file from an INI workspace."""
from PyQt6.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit, QPushButton, QFileDialog, QMessageBox
)
from PyQt6.QtCore import Qt
import configparser
import os
from coseda.geomfile import build_geomfile_from_ini
import math
[docs]
def calculate_electron_wavelength(acceleration_voltage_V: float):
"""Relativistic electron wavelength (m) from acceleration voltage (V)."""
E = acceleration_voltage_V
wavelength_m = 1.23e-9 / math.sqrt(E * (1 + 9.78e-7 * E))
return wavelength_m
[docs]
class GeomfileWindow(QWidget):
"""Form-driven editor that emits a CrystFEL geometry file and updates the INI."""
def __init__(self, ini_path: str):
"""Load defaults from INI, derive sensible fallbacks, and build the form."""
super().__init__()
self.ini_path = ini_path
self.ini_config = configparser.ConfigParser()
self.ini_config.read(self.ini_path)
self.project_dir = os.path.dirname(self.ini_path)
if "Paths" in self.ini_config and "outputfolder" in self.ini_config["Paths"]:
self.project_dir = os.path.join(self.project_dir, self.ini_config["Paths"]["outputfolder"])
self.setWindowTitle("Geometry File Editor")
self.layout = QVBoxLayout()
self.setLayout(self.layout)
self.params, self.missing = build_geomfile_from_ini(self.ini_path)
# Set default values for missing keys
defaults = {
"data": "/entry/data/images",
"dim0": "%",
"dim1": "ss",
"dim2": "fs",
"peak_list": "/entry/data/",
"peak_list_type": "cxi",
"detector_shift_x": "/entry/data/det_shift_x_mm mm",
"detector_shift_y": "/entry/data/det_shift_y_mm mm",
"min_ss": "0",
"min_fs": "0",
"fs_axis": "x",
"ss_axis": "y",
"mask": "/mask",
"mask_good": "0x01",
"mask_bad": "0x00"
}
for key, value in defaults.items():
self.params.setdefault(key, value)
self.params.setdefault("adu_per_photon", "")
self.params.setdefault("min_ss", "0")
self.params.setdefault("min_fs", "0")
self.params["min_ss"] = "0"
self.params["min_fs"] = "0"
acquisition = self.ini_config["Acquisition"] if "Acquisition" in self.ini_config else {}
if 'acceleration_voltage' in acquisition and not self.params.get('wavelength'):
voltage_V = float(acquisition['acceleration_voltage'])
self.params['wavelength'] = f"{calculate_electron_wavelength(voltage_V):.8e}"
self._build_ui()
self._update_missing_labels()
def _build_ui(self):
"""Create form rows for geometry parameters and save action."""
# For each parameter, create label, line edit, and missing label
self.entries = {}
self.missing_labels = {}
def add_row(name, label_text):
hbox = QHBoxLayout()
label = QLabel(label_text)
label.setFixedWidth(150)
line_edit = QLineEdit()
line_edit.setText(str(self.params.get(name) or ""))
missing_label = QLabel("")
missing_label.setStyleSheet("color: red")
missing_label.setFixedWidth(120)
hbox.addWidget(label)
hbox.addWidget(line_edit)
hbox.addWidget(missing_label)
self.layout.addLayout(hbox)
self.entries[name] = line_edit
self.missing_labels[name] = missing_label
add_row("wavelength", "Wavelength (m):")
add_row("adu_per_photon", "ADU per Photon:")
add_row("clen", "Camera Length (m):")
add_row("res", "Resolution (m):")
add_row("data", "Data Path:")
add_row("dim0", "Dimension 0:")
add_row("dim1", "Dimension 1:")
add_row("dim2", "Dimension 2:")
add_row("peak_list", "Peak List Path:")
add_row("peak_list_type", "Peak List Type:")
add_row("detector_shift_x", "Detector Shift X (m):")
add_row("detector_shift_y", "Detector Shift Y (m):")
add_row("min_ss", "Min SS:")
add_row("max_ss", "Max SS:")
add_row("min_fs", "Min FS:")
add_row("max_fs", "Max FS:")
add_row("corner_x", "Corner X:")
add_row("corner_y", "Corner Y:")
add_row("fs_axis", "FS Axis:")
add_row("ss_axis", "SS Axis:")
add_row("mask", "Mask Path:")
add_row("mask_file", "Mask File:")
add_row("mask_good", "Mask Good:")
add_row("mask_bad", "Mask Bad:")
# Save button
self.save_button = QPushButton("Save Geometry File")
self.save_button.clicked.connect(self._save_geom_file)
self.layout.addWidget(self.save_button, alignment=Qt.AlignmentFlag.AlignRight)
def _update_missing_labels(self):
"""Highlight fields that are missing or invalid."""
# Check which parameters are missing or invalid
for name, line_edit in self.entries.items():
text = line_edit.text().strip()
if name in ("wavelength", "clen", "res") and not self._is_float(text):
self.missing_labels[name].setText("Missing or invalid")
elif name == "adu_per_photon" and text and not self._is_float(text):
self.missing_labels[name].setText("Invalid")
elif name in ("min_ss", "max_ss", "min_fs", "max_fs", "corner_x", "corner_y") and text and not self._is_float(text):
self.missing_labels[name].setText("Invalid")
# Skip validation for detector_shift_x and detector_shift_y
elif name in ("detector_shift_x", "detector_shift_y"):
self.missing_labels[name].setText("")
else:
self.missing_labels[name].setText("")
def _is_float(self, value: str):
"""Return True if value can be parsed as float."""
try:
float(value)
return True
except ValueError:
return False
def _save_geom_file(self):
"""Persist geometry to disk and update the INI Paths.geomfile reference."""
# Update params from UI
for name, line_edit in self.entries.items():
self.params[name] = line_edit.text().strip()
# Validate all fields are filled (non-empty)
missing_fields = [name for name, line_edit in self.entries.items() if not line_edit.text().strip()]
if missing_fields:
QMessageBox.warning(self, "Missing Fields",
f"Please fill in all fields. Missing: {', '.join(missing_fields)}")
self._update_missing_labels()
return
# Validate required parameters (must be valid floats)
missing_required = []
for key in ("wavelength", "clen", "res"):
if not self._is_float(self.params[key]):
missing_required.append(key)
if missing_required:
QMessageBox.warning(self, "Missing Parameters",
f"Please provide valid values for: {', '.join(missing_required)}")
self._update_missing_labels()
return
# Compose geometry file content
geom_lines = [
f"wavelength={self.params.get('wavelength', '')}",
f"adu_per_photon={self.params.get('adu_per_photon', '')}",
f"clen={self.params.get('clen', '')}",
f"res={self.params.get('res', '')}",
f"data={self.params.get('data', '')}",
f"dim0={self.params.get('dim0', '')}",
f"dim1={self.params.get('dim1', '')}",
f"dim2={self.params.get('dim2', '')}",
f"peak_list={self.params.get('peak_list', '')}",
f"peak_list_type={self.params.get('peak_list_type', '')}",
f"detector_shift_x={self.params.get('detector_shift_x', '0.0')} mm",
f"detector_shift_y={self.params.get('detector_shift_y', '0.0')} mm",
f"p0/min_ss={self.params.get('min_ss', '')}",
f"p0/max_ss={self.params.get('max_ss', '')}",
f"p0/min_fs={self.params.get('min_fs', '')}",
f"p0/max_fs={self.params.get('max_fs', '')}",
f"p0/corner_x={self.params.get('corner_x', '')}",
f"p0/corner_y={self.params.get('corner_y', '')}",
f"p0/fs_axis={self.params.get('fs_axis', '')}",
f"p0/ss_axis={self.params.get('ss_axis', '')}",
f"p0/mask={self.params.get('mask', '')}",
f"p0/mask_file={self.params.get('mask_file', '')}",
f"p0/mask_good={self.params.get('mask_good', '')}",
f"p0/mask_bad={self.params.get('mask_bad', '')}"
]
geom_content = "\n".join(geom_lines)
file_path = os.path.join(self.project_dir, "geometry.geom")
try:
with open(file_path, "w") as f:
f.write(geom_content)
# Update ini file with new geomfile path
self.ini_config.set("Paths", "geomfile", os.path.basename(file_path))
with open(self.ini_path, "w") as configfile:
self.ini_config.write(configfile)
QMessageBox.information(self, "Saved", f"Geometry file saved to:\n{file_path}")
self.close()
except Exception as e:
QMessageBox.critical(self, "Error", f"Failed to save file:\n{str(e)}")
self._update_missing_labels()