diff --git a/Protolist/HolyList-Latest-Phase-Current.xlsx b/Protolist/HolyList-Latest-Phase-Current.xlsx
new file mode 100644
index 0000000..b8bebaf
Binary files /dev/null and b/Protolist/HolyList-Latest-Phase-Current.xlsx differ
diff --git a/beamline_editor/excel_utils.py b/beamline_editor/excel_utils.py
new file mode 100644
index 0000000..5f075f2
--- /dev/null
+++ b/beamline_editor/excel_utils.py
@@ -0,0 +1,136 @@
+"""
+excel_utils.py — Utility for reading an Excel sheet into a list of dicts.
+
+read_excel_sheet(file_path, sheet_name)
+ Read one tab of an Excel workbook and return its rows as a list of
+ dictionaries, one dict per row, keyed by the column headers in row 1.
+
+ Parameters
+ ----------
+ file_path : str | Path
+ Path to the .xlsx (or .xlsm / .xls) file.
+ sheet_name : str
+ Exact name of the tab to read.
+
+ Returns
+ -------
+ list[dict]
+ Each dict maps column header → cell value for one data row.
+ Empty cells are returned as None (not NaN).
+ The header row itself is not included in the output.
+
+ Raises
+ ------
+ FileNotFoundError
+ If file_path does not exist.
+ ValueError
+ If sheet_name is not found in the workbook, listing the
+ available sheet names for convenience.
+ ImportError
+ If pandas or openpyxl are not installed.
+
+ Notes
+ -----
+ - The first row of the sheet is always treated as the header.
+ - Numeric types (int, float) are preserved as Python numbers.
+ - datetime / date cells are returned as Python datetime objects.
+ - Boolean cells are returned as Python bool.
+ - Completely empty rows (all cells None) are dropped.
+ - Duplicate column names get a suffix _2, _3, … to keep them unique.
+
+Example
+-------
+ from excel_utils import read_excel_sheet
+
+ rows = read_excel_sheet("devices.xlsx", "SLS2")
+ for row in rows:
+ print(row["Domain"], row["Group"], row["s (m)"])
+"""
+
+from __future__ import annotations
+from pathlib import Path
+from typing import Any
+import math
+
+
+def read_excel_sheet(
+ file_path: str | Path,
+ sheet_name: str,
+) -> list[dict[str, Any]]:
+ """
+ Read *sheet_name* from *file_path* and return its rows as dicts.
+
+ See module docstring for full details.
+ """
+ try:
+ import pandas as pd
+ except ImportError:
+ raise ImportError(
+ "pandas is required: pip install pandas openpyxl"
+ ) from None
+
+ path = Path(file_path)
+ if not path.exists():
+ raise FileNotFoundError(f"Excel file not found: {path}")
+
+ # Read with openpyxl engine; keep_default_na=False so empty cells
+ # come back as "" rather than NaN — we normalise them to None below.
+ try:
+ df = pd.read_excel(
+ path,
+ sheet_name = sheet_name,
+ engine = "openpyxl",
+ header = 0, # first row = column names
+ dtype = object, # preserve native cell types
+ keep_default_na = False, # don't convert "" → NaN
+ )
+ except Exception as exc:
+ # Provide a helpful message when the sheet name is wrong
+ try:
+ import openpyxl
+ wb = openpyxl.load_workbook(path, read_only=True, data_only=True)
+ names = wb.sheetnames
+ wb.close()
+ except Exception:
+ names = []
+ if names:
+ raise ValueError(
+ f"Sheet '{sheet_name}' not found in '{path.name}'. "
+ f"Available sheets: {names}"
+ ) from exc
+ raise
+
+ # ── de-duplicate column names ──────────────────────────────────────────────
+ seen: dict[str, int] = {}
+ columns: list[str] = []
+ for col in df.columns:
+ name = str(col) if col is not None else ""
+ if name in seen:
+ seen[name] += 1
+ name = f"{name}_{seen[name]}"
+ else:
+ seen[name] = 1
+ columns.append(name)
+ df.columns = columns
+
+ # ── convert to list of dicts, normalise values ─────────────────────────────
+ records: list[dict[str, Any]] = []
+ for _, row in df.iterrows():
+ record: dict[str, Any] = {}
+ all_none = True
+ for col in columns:
+ val = row[col]
+ # Normalise: empty string and NaN → None
+ if val == "" or (isinstance(val, float) and math.isnan(val)):
+ val = None
+ # Convert numpy scalar types to plain Python
+ if hasattr(val, "item"):
+ val = val.item()
+ record[col] = val
+ if val is not None:
+ all_none = False
+ # Drop completely empty rows
+ if not all_none:
+ records.append(record)
+
+ return records
diff --git a/beamline_editor/table_utils.py b/beamline_editor/table_utils.py
index fa8515b..cab3b88 100644
--- a/beamline_editor/table_utils.py
+++ b/beamline_editor/table_utils.py
@@ -2,35 +2,47 @@
table_utils.py — Utility helpers for populating Qt table widgets.
populate_table(table, records)
- Fill a QTableWidget from a list of dicts.
+ Fill a QTableWidget from a list of dicts, with Excel-style column
+ filters on: Domain, Prefix, Suffix, Group, Baugruppe.
Columns (fixed, in this order):
Domain, Prefix, Suffix, Index, s (m), z (m), x (m), y (m), L (m),
Reference, Group, Baugruppe, Variante, Roll, PS-Ch.
"Index" is always rendered as a zero-padded 3-digit integer (e.g. 7 → "007").
+ Position columns always render with 4 decimal places.
Row background colour is determined by the "Group" value:
- "Section" → light grey (#D0D0D0)
- "Magnet" / "Magnets" → orange (#FFAA55)
- "RF" → teal (#66BBAA)
+ "Section" / "Start" → light grey (#D0D0D0)
+ "Magnet" / "Magnets" → orange (#FFAA55)
+ "RF" → cyan (#00FFFF)
"Diagnostics" → yellow (#FFEE88)
- "Vacuum" → light green (#90EE90)
+ "Vacuum" / "Safety" → light green (#90EE90)
"ID" → light purple (#CC99FF)
anything else → white (#FFFFFF)
Font colour is always black.
- Any key absent from a dict produces a blank cell; existing table
- content is replaced.
+Filter behaviour
+----------------
+ Filterable columns show a small ▼ button in their header cell.
+ Clicking it opens a checklist popup:
+ • "All" checkbox — when toggled, sets every entry to its new state.
+ • One checkbox per unique value in that column.
+ Rows whose cell value is unchecked are hidden; others are shown.
+ Filters are independent per column and cumulative across columns.
"""
from __future__ import annotations
from typing import Any
-from PyQt5.QtWidgets import QTableWidget, QTableWidgetItem, QHeaderView
-from PyQt5.QtGui import QColor, QBrush
-from PyQt5.QtCore import Qt
+from PyQt5.QtWidgets import (
+ QTableWidget, QTableWidgetItem, QHeaderView,
+ QWidget, QVBoxLayout, QCheckBox, QFrame,
+ QScrollArea, QPushButton, QHBoxLayout, QApplication,
+)
+from PyQt5.QtGui import QColor, QBrush, QFont
+from PyQt5.QtCore import Qt, QPoint
# ── Column definition ─────────────────────────────────────────────────────────
@@ -51,22 +63,27 @@ COLUMNS: list[str] = [
"Variante",
"Roll",
"PS-Ch.",
+ "Comment",
]
-# ── Row colour map (Group value → background hex) ─────────────────────────────
+# Columns that get a filter drop-down
+FILTER_COLS: set[str] = {"Domain", "Prefix", "Suffix", "Group", "Baugruppe"}
+
+# ── Row colour map ────────────────────────────────────────────────────────────
_GROUP_COLORS: dict[str, str] = {
- "Section": "#D0D0D0", # light grey
- "Magnet": "#FFAA55", # orange
- "Magnets": "#FFAA55", # orange (alternate spelling)
- "RF": "#66BBAA", # teal
- "Diagnostics": "#FFEE88", # yellow
- "Vacuum": "#90EE90", # light green
- "ID": "#CC99FF", # light purple
+ "Section": "#D0D0D0",
+ "Start": "#D0D0D0",
+ "Magnet": "#FFAA55",
+ "Magnets": "#FFAA55",
+ "RF": "#00FFFF",
+ "Diagnostics": "#FFEE88",
+ "Vacuum": "#90EE90",
+ "Safety": "#90EE90",
+ "ID": "#CC99FF",
}
-_DEFAULT_COLOR = "#FFFFFF" # white
-_FONT_COLOR = QColor("#000000") # black text for all rows
-
+_DEFAULT_COLOR = "#FFFFFF"
+_FONT_COLOR = QColor("#000000")
# ── Formatting helpers ────────────────────────────────────────────────────────
@@ -74,7 +91,6 @@ _POSITION_COLS = {"s (m)", "z (m)", "x (m)", "y (m)", "L (m)"}
def _format_value(key: str, value: Any) -> str:
- """Return the display string for a single cell value."""
if key == "Index":
if value is None or value == "":
return ""
@@ -92,6 +108,316 @@ def _format_value(key: str, value: Any) -> str:
return "" if value is None else str(value)
+# ── Filter popup ──────────────────────────────────────────────────────────────
+
+class _FilterPopup(QFrame):
+ """
+ A floating checklist that appears below a header cell.
+
+ Parameters
+ ----------
+ parent : widget to use as Qt parent (keeps it on screen)
+ col_index : logical column index in the table
+ values : ordered list of unique string values in that column
+ active : set of values currently shown (all = no filter)
+ on_apply : callable(col_index, active_set) — called on OK / any change
+ """
+
+ _SS = """
+ QFrame {
+ background: #2A2D3E;
+ border: 1px solid #555880;
+ border-radius: 4px;
+ }
+ QCheckBox {
+ font-family: 'Segoe UI';
+ font-size: 11px;
+ color: #E8EAF6;
+ padding: 2px 4px;
+ }
+ QCheckBox::indicator {
+ width: 13px;
+ height: 13px;
+ border: 1px solid #7880BB;
+ border-radius: 2px;
+ background: #1E2035;
+ }
+ QCheckBox::indicator:checked {
+ background: #4F8EF7;
+ border-color: #4F8EF7;
+ }
+ QCheckBox::indicator:indeterminate {
+ background: #7880BB;
+ border-color: #7880BB;
+ }
+ QPushButton {
+ font-family: 'Segoe UI';
+ font-size: 11px;
+ color: #E8EAF6;
+ background: #3A3D55;
+ border: 1px solid #555880;
+ border-radius: 3px;
+ padding: 3px 10px;
+ }
+ QPushButton:hover { background: #4F5280; }
+ QPushButton:pressed{ background: #4F8EF7; }
+ QScrollArea { background: #2A2D3E; border: none; }
+ QWidget { background: #2A2D3E; }
+ """
+
+ def __init__(self, parent, col_index, values, active, on_apply):
+ super().__init__(parent, Qt.Popup | Qt.FramelessWindowHint)
+ self.setStyleSheet(self._SS)
+ self._col = col_index
+ self._values = values
+ self._active = set(active)
+ self._on_apply = on_apply
+ self._build()
+
+ def _build(self):
+ outer = QVBoxLayout(self)
+ outer.setContentsMargins(6, 6, 6, 6)
+ outer.setSpacing(4)
+
+ # ── scrollable checklist ──────────────────────────────────────────────
+ scroll = QScrollArea()
+ scroll.setWidgetResizable(True)
+ scroll.setFrameShape(QFrame.NoFrame)
+ scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
+
+ inner_widget = QWidget()
+ inner_layout = QVBoxLayout(inner_widget)
+ inner_layout.setContentsMargins(2, 2, 2, 2)
+ inner_layout.setSpacing(1)
+
+ # "All" toggle
+ self._all_cb = QCheckBox("All")
+ all_checked = len(self._active) == len(self._values)
+ self._all_cb.setCheckState(
+ Qt.Checked if all_checked
+ else Qt.PartiallyChecked if self._active
+ else Qt.Unchecked
+ )
+ self._all_cb.setTristate(True)
+ font = self._all_cb.font()
+ font.setBold(True)
+ self._all_cb.setFont(font)
+ self._all_cb.stateChanged.connect(self._all_toggled)
+ inner_layout.addWidget(self._all_cb)
+
+ # separator line
+ line = QFrame()
+ line.setFrameShape(QFrame.HLine)
+ line.setStyleSheet("color: #444466;")
+ inner_layout.addWidget(line)
+
+ # individual value checkboxes
+ self._cbs: list[QCheckBox] = []
+ for val in self._values:
+ cb = QCheckBox(val if val != "" else "(blank)")
+ cb.setChecked(val in self._active)
+ cb.stateChanged.connect(self._item_toggled)
+ inner_layout.addWidget(cb)
+ self._cbs.append(cb)
+
+ inner_layout.addStretch()
+ scroll.setWidget(inner_widget)
+
+ # cap height so it doesn't grow taller than the screen
+ max_h = min(300, 40 + len(self._values) * 22)
+ scroll.setFixedHeight(max_h)
+ scroll.setMinimumWidth(160)
+ outer.addWidget(scroll)
+
+ # ── OK / Cancel buttons ───────────────────────────────────────────────
+ btn_row = QHBoxLayout()
+ btn_row.setSpacing(6)
+ ok_btn = QPushButton("OK")
+ cl_btn = QPushButton("Cancel")
+ ok_btn.clicked.connect(self._apply)
+ cl_btn.clicked.connect(self.close)
+ btn_row.addStretch()
+ btn_row.addWidget(ok_btn)
+ btn_row.addWidget(cl_btn)
+ outer.addLayout(btn_row)
+
+ self.adjustSize()
+
+ # ── slot: "All" toggled ───────────────────────────────────────────────────
+ def _all_toggled(self, state):
+ # Tristate cycles Unchecked→PartiallyChecked→Checked;
+ # we skip PartiallyChecked and treat it as Checked.
+ target = state != Qt.Unchecked
+ # block item signals while we update them
+ for cb in self._cbs:
+ cb.blockSignals(True)
+ cb.setChecked(target)
+ cb.blockSignals(False)
+ self._active = set(self._values) if target else set()
+ # keep All in a clean binary state
+ self._all_cb.blockSignals(True)
+ self._all_cb.setTristate(False)
+ self._all_cb.setChecked(target)
+ self._all_cb.setTristate(True)
+ self._all_cb.blockSignals(False)
+
+ # ── slot: individual item toggled ─────────────────────────────────────────
+ def _item_toggled(self, _state):
+ self._active = {
+ self._values[i]
+ for i, cb in enumerate(self._cbs)
+ if cb.isChecked()
+ }
+ # update All checkbox appearance
+ n = len(self._active)
+ self._all_cb.blockSignals(True)
+ self._all_cb.setTristate(True)
+ if n == 0:
+ self._all_cb.setCheckState(Qt.Unchecked)
+ elif n == len(self._values):
+ self._all_cb.setCheckState(Qt.Checked)
+ else:
+ self._all_cb.setCheckState(Qt.PartiallyChecked)
+ self._all_cb.blockSignals(False)
+
+ def _apply(self):
+ self._on_apply(self._col, self._active)
+ self.close()
+
+
+# ── Filter-aware header ───────────────────────────────────────────────────────
+
+class _FilterHeader(QHeaderView):
+ """
+ A horizontal header that draws a small ▼ button on filterable columns
+ and opens a _FilterPopup when clicked.
+ """
+
+ _BTN_W = 16 # width of the filter button in pixels
+
+ def __init__(self, table: QTableWidget):
+ super().__init__(Qt.Horizontal, table)
+ self._table = table
+ # col_index → set of currently shown values (None = no filter applied)
+ self._filters: dict[int, set[str] | None] = {}
+ # col_index → sorted list of all unique values
+ self._col_values: dict[int, list[str]] = {}
+
+ self.sectionClicked.connect(self._on_section_clicked)
+ self.setMouseTracking(True)
+
+ def set_column_values(self, col: int, values: list[str]):
+ self._col_values[col] = values
+ self._filters.setdefault(col, None) # None = all shown
+
+ def paintSection(self, painter, rect, logical_index):
+ super().paintSection(painter, rect, logical_index)
+ if logical_index not in self._col_values:
+ return
+
+ active = self._filters.get(logical_index)
+ is_filtered = active is not None and len(active) < len(
+ self._col_values.get(logical_index, [])
+ )
+
+ # Draw filter button in top-right of header cell
+ btn_rect = self._btn_rect(rect)
+ painter.save()
+ painter.setBrush(QColor("#4F8EF7" if is_filtered else "#FFFFFF"))
+ painter.setPen(QColor("#AAAAAA"))
+ painter.drawRect(btn_rect)
+ painter.setPen(QColor("#FFFFFF" if is_filtered else "#333333"))
+ f = painter.font()
+ f.setPointSize(7)
+ f.setBold(False)
+ painter.setFont(f)
+ painter.drawText(btn_rect, Qt.AlignCenter, "▼")
+ painter.restore()
+
+ def _set_section_bold(self, col: int, bold: bool):
+ """Set the header label font to bold/normal via the model's FontRole."""
+ model = self._table.horizontalHeader().model()
+ from PyQt5.QtGui import QFont as _QFont
+ f = _QFont()
+ f.setBold(bold)
+ model.setHeaderData(col, Qt.Horizontal, f, Qt.FontRole)
+
+ def _btn_rect(self, section_rect):
+ from PyQt5.QtCore import QRect
+ bw = self._BTN_W
+ return QRect(
+ section_rect.right() - bw - 1,
+ section_rect.top() + 2,
+ bw,
+ section_rect.height() - 4,
+ )
+
+ def mousePressEvent(self, event):
+ logical = self.logicalIndexAt(event.pos())
+ if logical not in self._col_values:
+ super().mousePressEvent(event)
+ return
+ # check if click is on the button
+ vis = self.visualIndex(logical)
+ pos = self.sectionPosition(logical)
+ width = self.sectionSize(logical)
+ from PyQt5.QtCore import QRect
+ sect_rect = QRect(pos, 0, width, self.height())
+ btn = self._btn_rect(sect_rect)
+ if btn.contains(event.pos()):
+ self._open_popup(logical, event.globalPos())
+ else:
+ super().mousePressEvent(event)
+
+ def _open_popup(self, col: int, global_pos: QPoint):
+ values = self._col_values.get(col, [])
+ active = self._filters.get(col) or set(values)
+ popup = _FilterPopup(
+ self._table, col, values, active,
+ on_apply=self._apply_filter,
+ )
+ # position below the header cell
+ popup.move(global_pos.x(), global_pos.y() + 4)
+ popup.show()
+
+ def _apply_filter(self, col: int, active: set[str]):
+ self._filters[col] = active
+ is_filtered = len(active) < len(self._col_values.get(col, []))
+ self._set_section_bold(col, is_filtered)
+ self._reapply_all()
+ self.viewport().update()
+
+ def _reapply_all(self):
+ """Show/hide rows based on every active column filter."""
+ table = self._table
+ for row in range(table.rowCount()):
+ hidden = False
+ for col, active in self._filters.items():
+ if active is None:
+ continue
+ item = table.item(row, col)
+ cell_val = item.text() if item else ""
+ # match "(blank)" entries
+ if cell_val == "" and "(blank)" in active:
+ continue
+ if cell_val not in active:
+ hidden = True
+ break
+ table.setRowHidden(row, hidden)
+
+ def reset_filters(self):
+ """Clear all filters and show every row."""
+ self._filters = {col: None for col in self._col_values}
+ for col in self._col_values:
+ self._set_section_bold(col, False)
+ for row in range(self._table.rowCount()):
+ self._table.setRowHidden(row, False)
+ self.viewport().update()
+
+ def _on_section_clicked(self, logical):
+ pass # prevent default sort on header click
+
+
# ── Public function ────────────────────────────────────────────────────────────
def populate_table(
@@ -99,41 +425,184 @@ def populate_table(
records: list[dict[str, Any]],
) -> None:
"""
- Populate *table* from *records*.
+ Populate *table* from *records* and install Excel-style column filters
+ on the filterable columns.
Parameters
----------
table : QTableWidget
- The widget to fill. Column count and headers are set
- automatically; any previous content is cleared.
+ The widget to fill. Column count, headers, and a custom
+ FilterHeader are installed automatically.
records : list[dict]
Each dict may contain any subset of the known column keys.
Missing keys produce an empty cell. Extra keys are ignored.
"""
- # ── setup ─────────────────────────────────────────────────────────────────
table.setUpdatesEnabled(False)
table.clearContents()
table.setRowCount(len(records))
table.setColumnCount(len(COLUMNS))
- table.setHorizontalHeaderLabels(COLUMNS)
- header = table.horizontalHeader()
+ # Install (or reuse) the filter-aware header
+ existing = table.horizontalHeader()
+ if not isinstance(existing, _FilterHeader):
+ header = _FilterHeader(table)
+ table.setHorizontalHeader(header)
+ else:
+ header = existing
+
+ table.setHorizontalHeaderLabels(COLUMNS)
header.setSectionResizeMode(QHeaderView.ResizeToContents)
header.setStretchLastSection(True)
+ header.setMinimumSectionSize(60) # floor so narrow columns still show button
+
+ # Extra right-side padding: after ResizeToContents has run, widen each
+ # filterable column by the button width + a small margin so the ▼ never
+ # overlaps the label text.
+ table.setUpdatesEnabled(True) # force a layout pass so sizes are computed
+ table.setUpdatesEnabled(False)
+ BTN_EXTRA = _FilterHeader._BTN_W + 8
+ for col, key in enumerate(COLUMNS):
+ if key in FILTER_COLS:
+ current = header.sectionSize(col)
+ header.resizeSection(col, current + BTN_EXTRA)
+ header.setSectionResizeMode(col, QHeaderView.Interactive)
# ── fill rows ─────────────────────────────────────────────────────────────
+ # Also collect unique values per filterable column
+ col_value_sets: dict[int, set[str]] = {}
+ for col, key in enumerate(COLUMNS):
+ if key in FILTER_COLS:
+ col_value_sets[col] = set()
+
for row, record in enumerate(records):
group_value = record.get("Group", "")
bg_hex = _GROUP_COLORS.get(group_value, _DEFAULT_COLOR)
bg_brush = QBrush(QColor(bg_hex))
for col, key in enumerate(COLUMNS):
- raw = record.get(key) # None when key absent
- text = _format_value(key, raw)
- cell = QTableWidgetItem(text)
+ raw = record.get(key)
+ text = _format_value(key, raw)
+ cell = QTableWidgetItem(text)
cell.setFlags(Qt.ItemIsEnabled | Qt.ItemIsSelectable)
cell.setBackground(bg_brush)
cell.setForeground(QBrush(_FONT_COLOR))
table.setItem(row, col, cell)
+ if col in col_value_sets:
+ col_value_sets[col].add(text)
+
+ # ── register unique values with the header ────────────────────────────────
+ for col, values_set in col_value_sets.items():
+ sorted_vals = sorted(values_set, key=lambda v: (v == "", v.lower()))
+ header.set_column_values(col, sorted_vals)
+
+ # reset any previous filters so all rows are visible after repopulate
+ header.reset_filters()
table.setUpdatesEnabled(True)
+
+
+def populate_table_dynamic(
+ table: QTableWidget,
+ records: list[dict[str, Any]],
+ filter_cols: set[str] | None = None,
+) -> None:
+ """
+ Like populate_table() but derives column headers from the keys of
+ *records* rather than the fixed COLUMNS list. Use this when the
+ data comes from an external source (e.g. read_excel_sheet()) whose
+ column names are not known in advance.
+
+ Parameters
+ ----------
+ table : QTableWidget
+ The widget to fill.
+ records : list[dict]
+ Rows as dicts. Column order follows the insertion order of keys
+ in the first dict; subsequent dicts may have additional or missing
+ keys (missing → blank cell).
+ filter_cols : set[str] | None
+ Column names that should get a ▼ filter button. If None,
+ defaults to the same set as populate_table():
+ {"Domain", "Prefix", "Suffix", "Group", "Baugruppe"}.
+
+ Notes
+ -----
+ - Row background colouring still uses the "Group" column value and
+ the same _GROUP_COLORS palette as populate_table().
+ - The "Index" zero-padding and position-column 4-decimal formatting
+ from _format_value() are applied as usual.
+ - Completely absent "Group" key → white background.
+ """
+ if not records:
+ table.setRowCount(0)
+ table.setColumnCount(0)
+ return
+
+ if filter_cols is None:
+ filter_cols = FILTER_COLS
+
+ # Derive ordered column list from all keys across all records
+ seen_keys: dict[str, None] = {}
+ for rec in records:
+ for k in rec:
+ seen_keys[k] = None
+ columns = list(seen_keys.keys())
+
+ table.setUpdatesEnabled(False)
+ table.clearContents()
+ table.setRowCount(len(records))
+ table.setColumnCount(len(columns))
+
+ # Install (or reuse) the filter-aware header
+ existing = table.horizontalHeader()
+ if not isinstance(existing, _FilterHeader):
+ header = _FilterHeader(table)
+ table.setHorizontalHeader(header)
+ else:
+ header = existing
+
+ table.setHorizontalHeaderLabels(columns)
+ header.setSectionResizeMode(QHeaderView.ResizeToContents)
+ header.setStretchLastSection(True)
+ header.setMinimumSectionSize(60)
+
+ # Collect unique values per filterable column
+ col_value_sets: dict[int, set[str]] = {}
+ for col, key in enumerate(columns):
+ if key in filter_cols:
+ col_value_sets[col] = set()
+
+ for row, record in enumerate(records):
+ group_value = record.get("Group", "")
+ if group_value is None:
+ group_value = ""
+ bg_hex = _GROUP_COLORS.get(str(group_value), _DEFAULT_COLOR)
+ bg_brush = QBrush(QColor(bg_hex))
+
+ for col, key in enumerate(columns):
+ raw = record.get(key)
+ text = _format_value(key, raw)
+ cell = QTableWidgetItem(text)
+ cell.setFlags(Qt.ItemIsEnabled | Qt.ItemIsSelectable)
+ cell.setBackground(bg_brush)
+ cell.setForeground(QBrush(_FONT_COLOR))
+ table.setItem(row, col, cell)
+ if col in col_value_sets:
+ col_value_sets[col].add(text)
+
+ # Extra width for filterable columns so ▼ button is visible
+ table.setUpdatesEnabled(True)
+ table.setUpdatesEnabled(False)
+ BTN_EXTRA = _FilterHeader._BTN_W + 8
+ for col, key in enumerate(columns):
+ if key in filter_cols:
+ current = header.sectionSize(col)
+ header.resizeSection(col, current + BTN_EXTRA)
+ header.setSectionResizeMode(col, QHeaderView.Interactive)
+
+ for col, values_set in col_value_sets.items():
+ sorted_vals = sorted(values_set, key=lambda v: (v == "", v.lower()))
+ header.set_column_values(col, sorted_vals)
+
+ header.reset_filters()
+ table.setUpdatesEnabled(True)
diff --git a/containers.py b/containers.py
index beb5426..d0eaca5 100644
--- a/containers.py
+++ b/containers.py
@@ -6,7 +6,6 @@ class LineContainer:
def __init__(self, namein='', Lin=0.):
self.Name = namein
self.LengthRes = Lin
- self.LengthEnding = Lin
self.Ref = {}
self.Element = []
self.sRef = 0.
@@ -183,7 +182,8 @@ class LineContainer:
if name != 0:
seq.append(ele)
#print('Extend Cell',self.getResLength(), Last)
- app.writeDrift(self.getResLength() - Last)
+ if not self.hasBranchPoint:
+ app.writeDrift(self.getResLength() - Last)
app.writeLine(self,seq) # indicate the app that a sequence is done
return seq
diff --git a/main.py b/main.py
index 539875b..657b34e 100644
--- a/main.py
+++ b/main.py
@@ -1,3 +1,4 @@
+import copy
import sys
import json
import pickle
@@ -7,7 +8,8 @@ from PyQt5 import QtWidgets
from ui.BeamlineGUI import Ui_BeamlineGUI
from beamline_editor import BeamlineEditorWidget
-from beamline_editor.table_utils import populate_table
+from beamline_editor.table_utils import populate_table, populate_table_dynamic
+from beamline_editor.excel_utils import read_excel_sheet
from elements import *
from containers import *
@@ -42,15 +44,21 @@ class BeamlineEditor(QtWidgets.QMainWindow, Ui_BeamlineGUI):
self.actionSave.triggered.connect(self.save)
self.actionLoad.triggered.connect(self.load)
+ self.actionCompareLayout.triggered.connect(self.compareLayout)
+
+
# elements, lines and branch points
self.elementDB={}
self.savefile=''
self.lines={}
self.branchingID={}
+ self.branchingTemp={}
self.PL=ProtoListe(0)
self.loadFile('Layouts/SFTest2.json')
self.loadTemplates()
+ self.verbose = False
+ self.plrecs=[]
def saveas(self):
@@ -91,6 +99,21 @@ class BeamlineEditor(QtWidgets.QMainWindow, Ui_BeamlineGUI):
self.savefile=fileName
self.editor.loadLayout(self.savefile.replace('.json','_gui.json'))
+ def compareLayout(self):
+ if len(self.plrecs) == 0:
+ return
+ options = QtWidgets.QFileDialog.Options()
+ options |= QtWidgets.QFileDialog.DontUseNativeDialog
+ fileName, _ = QtWidgets.QFileDialog.getOpenFileName(self, "Open Proto-list",
+ "Protolist/HolyList-Latest-Phase-Current.xlsx",
+ "Excel File (*.xlsx)", options=options)
+ if not fileName:
+ return
+
+ rows = read_excel_sheet(fileName, "Proto-HL")
+ populate_table(self.UIProtoList,rows)
+ print(rows[0])
+
def saveTemplates(self):
template=self.editor.get_templates()
with (open('Settings/templates.json','w', encoding = "utf-8") as file):
@@ -217,37 +240,55 @@ class BeamlineEditor(QtWidgets.QMainWindow, Ui_BeamlineGUI):
for key in self.branchingID.keys():
print('ID:',key)
- def flatten(self, node_id, case_num=1 ):
- print('Flatten beamline for case:', case_num)
- self.findBranchingDipoles()
+ def flattenSingleLine(self,node_id,branch=0,case_num=1):
- self.lines.clear()
+ for key in self.branchingID.keys():
+ self.branchingID[key]=(branch == 1)
type = self.editor.getElementType(node_id)
- name='XXX'
+ name = 'XXX'
if type == 'Line':
line = self.elementDB[node_id]
else:
if type == 'Start':
name = self.elementDB[node_id].Name
- line=LineContainer(namein=name,Lin=0)
+ line = LineContainer(namein=name, Lin=0)
line.clear()
print('\nUnwrapping beamline...\n')
- self.unwrap(name,node_id,line,case_num)
- self.PL.generateLayout(line)
- plrecs = [self.PL.info[ele] for ele in self.PL.order]
- populate_table(self.UIProtoList,plrecs)
+ self.branchingTemp.clear()
+ self.unwrap(name, node_id, line, case_num)
+ for key in self.branchingTemp.keys():
+ self.elementDB[key].LengthRes=self.branchingTemp[key]
+ return line
+ def flatten(self, node_id, case_num=1 ):
+ print('Flatten beamline for case:', case_num)
+ self.findBranchingDipoles()
+ nbranch = len(self.branchingID.keys())
+ print('Possible Number of Branches:', 2**nbranch)
+ self.lines.clear()
+
+ # first write the baseline (all branching off)
+
+ self.lines['1'] = copy.deepcopy(self.flattenSingleLine(node_id,branch=0,case_num=case_num))
+ self.lines['2'] = copy.deepcopy(self.flattenSingleLine(node_id,branch=1,case_num=case_num))
+
+ self.PL.generateLayout(self.lines)
+ self.plrecs = [self.PL.info[ele] for ele in self.PL.order]
+ populate_table(self.UIProtoList,self.plrecs)
+ print(self.plrecs[0])
def unwrap(self, name, node_id,line,case_num):
- print('Unwrapping Line: %s' % name)
+ if self.verbose:
+ print('Unwrapping Line: %s' % name)
tree=self.editor.flatten(node_id,case_num)
secondBranch = False
for ele in tree.path:
self.report(ele, name)
type = self.editor.getElementType(ele)
if type == 'Line':
- print('Unwrapping- Adding a Line')
+ if self.verbose:
+ print('Unwrapping- Adding a Line')
line.append(self.elementDB[ele],sRef=0,Ref='relative')
subline = self.elementDB[ele]
subline.clear()
@@ -256,7 +297,8 @@ class BeamlineEditor(QtWidgets.QMainWindow, Ui_BeamlineGUI):
if ref in self.elementDB.keys():
secondBranch = self.unwrap(name + name1, ref, subline,case_num)
else:
- print('Undefined reference in Line Container:', name + name1)
+ if self.verbose:
+ print('Undefined reference in Line Container:', name + name1)
elif type == 'Variable Line':
id = self.elementDB[ele].ID
if id in self.elementDB.keys():
@@ -276,10 +318,11 @@ class BeamlineEditor(QtWidgets.QMainWindow, Ui_BeamlineGUI):
if ele in self.branchingID.keys():
if self.branchingID[ele] is True:
self.elementDB[ele].angle=self.elementDB[ele].design_angle
- line.LengthRes=0 # needs to shorten the line container
+ line.hasBranchPoint=True
return True
else:
self.elementDB[ele].angle=0
+ line.hasBranchPoint= False
if secondBranch:
if tree.secondary:
self.unwrap(name, tree.secondary.node_id, line, case_num)
@@ -292,6 +335,8 @@ class BeamlineEditor(QtWidgets.QMainWindow, Ui_BeamlineGUI):
def report(self,node_id,prefix=''):
+ if not self.verbose:
+ return
type = self.editor.getElementType(node_id)
if type == 'Variable Line':
print('FLATTEN: Adding Variable Line')
diff --git a/protoliste.py b/protoliste.py
index 89a0e10..eadfe7b 100644
--- a/protoliste.py
+++ b/protoliste.py
@@ -17,14 +17,16 @@ class ProtoListe(ApplicationTemplate):
- def generateLayout(self,line=None):
+ def generateLayout(self,lines=None):
print('--------\nGenerating Layout for Phase: %s\n' % self.phaseName)
self.order.clear()
self.info.clear()
self.dipole.clear()
self.setRef(-0.1, -0.1)
#self.iterateLine(line)
- line.writeLattice(self)
+ for key in lines.keys():
+ self.setRef(-0.1, -0.1)
+ lines[key].writeLattice(self)
#self.SF.writeFacility(self)
def setRef(self,sin=0,zin=0,xin=0,yin=1.2,exin=0,eyin=0,ezin=1):
diff --git a/ui/BeamlineGUI.py b/ui/BeamlineGUI.py
index 3f1be12..a3d233f 100644
--- a/ui/BeamlineGUI.py
+++ b/ui/BeamlineGUI.py
@@ -57,6 +57,8 @@ class Ui_BeamlineGUI(object):
font.setWeight(50)
self.menuFile.setFont(font)
self.menuFile.setObjectName("menuFile")
+ self.menuLAYOUT = QtWidgets.QMenu(self.menubar)
+ self.menuLAYOUT.setObjectName("menuLAYOUT")
BeamlineGUI.setMenuBar(self.menubar)
self.statusbar = QtWidgets.QStatusBar(BeamlineGUI)
self.statusbar.setObjectName("statusbar")
@@ -75,6 +77,14 @@ class Ui_BeamlineGUI(object):
self.actionLoad.setObjectName("actionLoad")
self.actionQuit = QtWidgets.QAction(BeamlineGUI)
self.actionQuit.setObjectName("actionQuit")
+ self.actionExportOnlineModel = QtWidgets.QAction(BeamlineGUI)
+ self.actionExportOnlineModel.setObjectName("actionExportOnlineModel")
+ self.actionCompareLayout = QtWidgets.QAction(BeamlineGUI)
+ self.actionCompareLayout.setObjectName("actionCompareLayout")
+ self.actionExportMadX = QtWidgets.QAction(BeamlineGUI)
+ self.actionExportMadX.setObjectName("actionExportMadX")
+ self.actionExportElegant = QtWidgets.QAction(BeamlineGUI)
+ self.actionExportElegant.setObjectName("actionExportElegant")
self.menuFile.addAction(self.actionNew_Layout)
self.menuFile.addAction(self.actionLoad)
self.menuFile.addAction(self.actionSave)
@@ -84,7 +94,12 @@ class Ui_BeamlineGUI(object):
self.menuFile.addAction(self.actionSave_Templates)
self.menuFile.addSeparator()
self.menuFile.addAction(self.actionQuit)
+ self.menuLAYOUT.addAction(self.actionExportOnlineModel)
+ self.menuLAYOUT.addAction(self.actionCompareLayout)
+ self.menuLAYOUT.addAction(self.actionExportMadX)
+ self.menuLAYOUT.addAction(self.actionExportElegant)
self.menubar.addAction(self.menuFile.menuAction())
+ self.menubar.addAction(self.menuLAYOUT.menuAction())
self.retranslateUi(BeamlineGUI)
self.tabWidget.setCurrentIndex(0)
@@ -97,6 +112,7 @@ class Ui_BeamlineGUI(object):
self.tabWidget.setTabText(self.tabWidget.indexOf(self.tab), _translate("BeamlineGUI", "Editor"))
self.tabWidget.setTabText(self.tabWidget.indexOf(self.tab_2), _translate("BeamlineGUI", "Proto List"))
self.menuFile.setTitle(_translate("BeamlineGUI", "FILE"))
+ self.menuLAYOUT.setTitle(_translate("BeamlineGUI", "LAYOUT"))
self.actionNew_Layout.setText(_translate("BeamlineGUI", "New Layout"))
self.actionSave.setText(_translate("BeamlineGUI", "Save"))
self.actionSave.setShortcut(_translate("BeamlineGUI", "Ctrl+S"))
@@ -107,3 +123,7 @@ class Ui_BeamlineGUI(object):
self.actionLoad.setShortcut(_translate("BeamlineGUI", "Ctrl+L"))
self.actionQuit.setText(_translate("BeamlineGUI", "Quit"))
self.actionQuit.setShortcut(_translate("BeamlineGUI", "Ctrl+Q"))
+ self.actionExportOnlineModel.setText(_translate("BeamlineGUI", "Export as OnlineModel Layout..."))
+ self.actionCompareLayout.setText(_translate("BeamlineGUI", "Compare with Protoliste..."))
+ self.actionExportMadX.setText(_translate("BeamlineGUI", "Export as MadX Lattice..."))
+ self.actionExportElegant.setText(_translate("BeamlineGUI", "Export as ELegant Lattice..."))
diff --git a/ui/BeamlineGUI.ui b/ui/BeamlineGUI.ui
index b7a6fcf..793d71c 100644
--- a/ui/BeamlineGUI.ui
+++ b/ui/BeamlineGUI.ui
@@ -82,7 +82,17 @@ background-color: rgb(13, 15, 26);
+
+
@@ -129,6 +139,26 @@ background-color: rgb(13, 15, 26);
Ctrl+Q
+
+
+ Export as OnlineModel Layout...
+
+
+
+
+ Compare with Protoliste...
+
+
+
+
+ Export as MadX Lattice...
+
+
+
+
+ Export as ELegant Lattice...
+
+