Working on comparison with protoliste
This commit is contained in:
1 parent
fc60ef8c89
commit
b0434d2b0c
8 files changed
+752
-50
No files matched your search
Binary file not shown.
@@ -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
|
||||
+500
-31
@@ -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)
|
||||
+2
-2
@@ -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
|
||||
|
||||
@@ -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')
|
||||
|
||||
+4
-2
@@ -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):
|
||||
|
||||
@@ -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..."))
|
||||
@@ -82,7 +82,17 @@ background-color: rgb(13, 15, 26);</string>
|
||||
<addaction name="separator"/>
|
||||
<addaction name="actionQuit"/>
|
||||
</widget>
|
||||
<widget class="QMenu" name="menuLAYOUT">
|
||||
<property name="title">
|
||||
<string>LAYOUT</string>
|
||||
</property>
|
||||
<addaction name="actionExportOnlineModel"/>
|
||||
<addaction name="actionCompareLayout"/>
|
||||
<addaction name="actionExportMadX"/>
|
||||
<addaction name="actionExportElegant"/>
|
||||
</widget>
|
||||
<addaction name="menuFile"/>
|
||||
<addaction name="menuLAYOUT"/>
|
||||
</widget>
|
||||
<widget class="QStatusBar" name="statusbar"/>
|
||||
<action name="actionNew_Layout">
|
||||
@@ -129,6 +139,26 @@ background-color: rgb(13, 15, 26);</string>
|
||||
<string>Ctrl+Q</string>
|
||||
</property>
|
||||
</action>
|
||||
<action name="actionExportOnlineModel">
|
||||
<property name="text">
|
||||
<string>Export as OnlineModel Layout...</string>
|
||||
</property>
|
||||
</action>
|
||||
<action name="actionCompareLayout">
|
||||
<property name="text">
|
||||
<string>Compare with Protoliste...</string>
|
||||
</property>
|
||||
</action>
|
||||
<action name="actionExportMadX">
|
||||
<property name="text">
|
||||
<string>Export as MadX Lattice...</string>
|
||||
</property>
|
||||
</action>
|
||||
<action name="actionExportElegant">
|
||||
<property name="text">
|
||||
<string>Export as ELegant Lattice...</string>
|
||||
</property>
|
||||
</action>
|
||||
</widget>
|
||||
<resources/>
|
||||
<connections>
|
||||
|
||||
Reference in new issue
Block a user