new_gui: redesigned PySide6 GUI (manual cockpit + automation, Phase 1)

Self-contained new package under src/aare/gui/new_gui reusing the existing
DAQWorker/PredictionSubscriber/auth without modifying them. Manual cockpit,
automation queue builder, baton controls + guest overlay, reference tools,
state LED, energy control, XRF spectrum, raster heatmap, rotation screening,
live automation progress + pause, alert banner.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
appleb_m
2026-06-24 15:36:52 +02:00
co-authored by Claude Opus 4.8
parent bba2f8a4c4
commit 235559249a
26 changed files with 5896 additions and 0 deletions
+120
View File
@@ -0,0 +1,120 @@
# new_gui — redesigned AareGUI (PySide6)
A from-scratch PySide6 implementation of the AareGUI redesign (Manual cockpit +
Automation queue builder), translated from the HTML design handoff. It **reuses
the existing backend** (`DAQWorker`, `PredictionSubscriber`, `auth`, `cfg_get`)
without modifying any existing files — everything here is new.
## Run
```bash
uv run python -m aare.gui.new_gui.app
# point at a specific server / camera:
uv run python -m aare.gui.new_gui.app -u https://host -c /path/to.crt -p tcp://host:9091
```
`BEAMLINE` (X06DA / X10SA / X06SA / unset→SIMULATED) drives all config via
`cfg_get`, exactly like the existing GUI.
## Layout
| File | Responsibility |
|------|----------------|
| `app.py` | Entry point: auth + per-beamline config, launches `MainWindow`. |
| `main_window.py` | Assembles chrome + body; **the only module that imports the backend**. Wires views ⇄ `DAQWorker`. |
| `state.py` | `AppState` — the view-model/state machine (mode, mount phase, pipeline, queue). Pure UI state + intent signals; backend-free and unit-testable. |
| `theme.py` | Design tokens (light + dark palettes), QSS, fixed dimensions. |
| `fonts.py` | IBM Plex registration (falls back to system fonts if TTFs absent). |
| `requests.py` | Builders: GUI params + live status → backend request models. |
| `top_bar.py` / `status_bar.py` | Shared chrome. Status bar reads `DAQStatusModel`; baton chip reads `BatonStatus`. |
| `manual_view.py` | Manual cockpit (changer · camera · motors) + mount-flow orchestration. |
| `automation_view.py` | Queue builder (library · queue · start bar). |
| `widgets/` | `camera`, `pipeline` (tracker), `pipeline_panel`, `sample_changer`, `motors_panel`, `common` (chips, flow layout, cards). |
Backend contract is documented in the `new-gui-backend-paths` memory.
## What is wired to the backend
- **Live status** → status bar (flux/ring/λ/cryo/shutter/state), motors readouts
(omega/chi/phi), zoom + light sliders.
- **Baton** chip ← `baton_status_changed`.
- **Sample list** ← `spreadsheet` → Sample Changer + Library.
- **Camera** ← `PredictionSubscriber` (live SAMCAM image, fps, target point).
- **Mount / Unmount** → `mount(SampleShortInfo)` / `unmount()`.
- **Motors** → `set_omega_rel`, `move_smargon`, `zoom`, `front_light`, `back_light`,
and a staff-only **Energy** control → `change_energy` (keV).
- **Center** (Loop) → `center_loop()`. **Click-to-centre** is implicit: clicking
the live camera moves the goniometer (`picture_to_sample``beamline_to_smargon`
`move_smargon`), mirroring the existing GUI.
- **Raster** → `raster_scan(RasterGridRequest)` (non-auto) from a grid drawn on the
camera; `smargon_top_left` is computed from the drawn top-left pixel via the live
geometry. (`requests.build_raster_request(auto=True)` keeps the auto-centre path
available.)
- **Collect** → `standard_scan(RotationScanRequest)`.
- **XRF** → `fluorimeter_spectrum(FluorescenceSpectrumParameterModel)`.
- **Automation Start** → iterates `automated_scan(sample)` over the local queue,
advancing on `automated_scan_done`.
- **Baton**: top-bar chip shows holder (staff see the username; non-staff see only
"Staff has the baton" / "Another user has the baton"). Action button does
Grab / Request control / Release / Cancel. Incoming requests pop an accept/refuse
dialog (`respond_to_baton_request`); your pending request shows a countdown dialog
polling `check_baton_timeout`.
- **Guest mode**: when you don't hold the baton the sample camera is greyed with a
GUEST MODE / VACANT overlay and click/grid interaction is blocked.
- **Reference tools** (staff): a Samples / Reference-tools toggle in the changer
header (from `reference_tools`); selecting one mounts with `reference=True`.
- **Beamline state**: the status-bar State item is a clickable LED (group-coloured)
that opens a transitions menu → the per-state DAQWorker methods
(`sample_exchange`, `data_collection`, …), available with no sample mounted.
Disabled while `status.busy`.
- **Mounting**: left-click a sample to *select* it (no immediate mount); then
press **⊕ Mount selected** or right-click → Mount. Reference tools mount with
`reference=True`.
- **Pre-mount settings**: the pipeline stages are clickable with no sample
mounted so parameters can be pre-set; the "▶ Run … now" buttons are disabled
until a sample is mounted.
- **XRF spectrum** (QtCharts): the XRF stage / camera "XRF spectrum" tab shows the
fluorescence spectrum (energy keV vs counts, peak + dead-time) from
`fluorimeter_spectrum_update` / live `fluorimeter_update`; Start/Stop/Snapshot →
`fluorimeter_start` / `fluorimeter_stop` / `fluorimeter_request_snapshot`.
- **Rotation / Screening**: the Collect stage has a **Rotation / Screening** mode
toggle; Screening exposes the presets (1/2/4 images @ 90°/60°/45°) + image-angle
+ count-time and builds the screening `RotationScanRequest` (wedge/steps).
- **Alert banner**: a dismissible strip below the top bar surfaces errors
(`http_error`, `auth_error`, `sample_missing`, device/detector errors,
automation/manual critical failures, PSS alarm) and completion notices
(`standard_scan_completed`, `raster_scan_completed`); all `_note()` messages
also route here. Errors persist until dismissed; info/success auto-clear.
- **Raster results**: `raster_scan_completed (CompletedRasterGrid)` renders a
viridis metric heatmap (widgets/heatmap.py) over the drawn grid on the camera;
the Raster settings expose a metric selector + opacity slider + a "Grids: N"
table (widgets/raster_grids_dialog.py) whose Goto → `move_smargon` to that grid.
- **Live automation progress**: the Automation view's RUN PROGRESS panel renders
the `automation_progress` SSE stream (Mount/Center/Raster/Collect/Final steps +
current sample, samples-left, avg time, ETA). Start swaps to **Pause / Stop**;
Pause is GUI-side (pauses after the current sample), Stop ends the local loop.
- **Data-collection toggles** (Raster + Collect): a **Database / User values**
toggle — Database makes the fields read-only and fills them from the mounted
sample's `aaredb_params`; User makes them editable. Collect additionally has an
**Automate parameters** toggle (the simple strategy): it hides the manual cards
and routes the run through `smart_params(SimpleScanParameters)` + `standard_scan`.
Scan defaults (exp_time_s / transmission / dtz / osc width) come from the
beamline YAML (`data_collection_settings.*`).
## Known TODOs / assumptions to confirm
1. **Smargon jog signs** — arrow deltas are treated as a beamline-frame offset and
rotated via `beamline_to_smargon` (rotation-aware at any omega/phi/chi). Current
mapping: ←/→ = -x/+x, ↑/↓ = -y/+y (camera y down). Flip in `MotorsPanel._jog`
if the beamline convention differs.
2. **XRF energy**: the XRF request carries only count time + transmission; beam
energy is set via the staff-only Energy control in the Motors panel
(`change_energy`).
3. **Per-sample automation protocol chips** are local/cosmetic for now —
`automated_scan` currently decides the protocol server-side. A pausable,
protocol-driven automation is planned next.
4. **Save as template** is a stub.
5. **Fonts**: drop IBM Plex TTFs into `assets/fonts/` to match the design exactly;
otherwise system fonts are used.
6. **Dark theme** palette exists in `theme.py` but no toggle is exposed yet.
+99
View File
@@ -0,0 +1,99 @@
# new_gui — feature gap & build plan
Old-GUI features cross-referenced against the new GUI. Updated 2026-06-24.
## Already in the new GUI ✅
Mode toggle · top bar (logo/baton/avatar) · status bar (flux/ring/λ/cryo/shutter +
clickable state LED w/ transitions menu) · baton request/accept/decline/release +
dialogs (staff-gated identity) · sample changer (select→Mount button/right-click,
staff reference-tools toggle) · live SAMCAM (crosshair, single target-point,
raster grid-DRAW, guest-mode overlay) · motors (omega, smargon jog, zoom, light,
staff energy/`change_energy`) · interactive pipeline (Center/Raster/XRF/Collect,
breakpoints, transport, pre-mount editable settings, Database/User + Automate
toggles) · automation queue builder (library, per-sample chips, reorder, ETA,
Start→`automated_scan`).
---
## Gap list (grouped) — with DAQWorker hooks & rough effort
### A. Camera richness (partial today) — MED
- ML detection boxes/polygons (Crystal/Loop/Pin/Ice/Needle colours) ← `prediction_subscriber.prediction`
- Beam-centre crosshair marker ← `status.geom.beam_location_pxl`
- Camera exposure/gain controls + screenshot-to-DB(+metadata) ← `samcam_settings`, `send_screenshot_db`
- Focus-measure readout ← `prediction_subscriber.focus_measure`
- Smargon bookmarks (Ctrl+click position markers) ← `models/bookmark.py`
- Helical start/end markers (only if helical is in scope)
### B. Multi-camera (Gonio / Beamline) — MED
- Wire the inert Gonio/Beamline tabs to axis IP video ← `threads/axis_video_thread.py` (VideoThread)
### C. Raster results & metrics — HIGH
- Completed-grid heatmap (viridis by metric: spots/index/ice/res/B-factor…) ← `raster_scan_completed (CompletedRasterGrid)`, `raster_grid_manager`
- Grid-point table (goto/visibility/copy) ← `widgets/raster_grid_table.py`
- ML auto-box → raster ← `ml_bounding_box`, `raster_generated_by_ml`
### D. Rotation / data-collection depth — MED/HIGH
- Screening modes (1/2/3/4 images), dose calc, temperature, wavelength scaling
- Completed-scan feedback ← `standard_scan_completed`
- Full smart/dose model behind "Automate parameters" (today we forward seeds only)
### E. Fluorescence (XRF) results — MED
- Live spectrum plot (energy vs counts), peak/dead-time, marker ← `fluorimeter_spectrum_update`, `fluorimeter_update`, `fluorimeter_start/stop/request_snapshot`
### F. Face detection — LOW/MED
- Panel (steps/step-size) + result ← `face_detection`, `face_detection_result`
### G. Beam/detector physics (staff) — MED
- Beam centre/size/mark ← `beam_center`, `beam_size_mm`, `beam_mark_add/clear`
- ABR tweak (GMX/GMY/GMZ, save/goto) ← `abr_tweak`, `abr_save`, `abr_goto_meas`
- Monochromator pitch scan ← `mono_pitch_scan` (energy already done)
### H. Recovery + Local-contact admin (staff) — HIGH
- Recovery: free/takeover/recover/unmount (confirmation codes), anneal, park&dry, TELL dry/blower ← `free_beamline`, `take_over_beamline`, `recover_beamline`, `recovery_unmount_sample`, `anneal`, `park_and_dry`, `tell_dry`, `tell_toggle_blower`
- Hardware init: smargon/aerotech/detector/BEC, pedestal ← `initialise_*`, `detector_take_pedestal`
- Local-contact 6-tab (sim toggles, device state, links, BEC macros, metadata resync) ← `load_local_contact_*`, `set_local_contact_simulation`, `restart_local_contact_device`, `bec_*`, `resync_sample`, `resync_local_contact_detector_metadata`
### I. Alerts, errors, preconditions — MED
- Alert banner (samcam down, automation failure, PSS/hutch alarm) ← `polled_devices_status`, `detector_error`, `automation_critical_failure`, `pss_alarm_changed`
- Modal precondition checks (ring/shutter/hutch) before risky actions
- Sample-missing dialog ← `sample_missing`
- Developer help: error-code registry + last error payloads ← `error_codes_loaded`, `last_error_payloads_changed`
### J. Sessions / pgroups (staff/admin) — MED
- P-group selector ← `set_pgroup`, `get_all_pgroups`, `staff_pgroups_loaded`
- Multi-session admin: list/force-close/remove ← `load_gui_sessions`, `request_gui_close`, `force_remove_gui_session`, `report_gui_interaction`
- Force/end session ← `force_session`, `end_session`
### K. Live automation progress — MED
- Progress widget driven by `automation_progress` (SSE) + step-through/pause; `automated_scan_done` already handled. Pairs with the planned pausable protocol queue.
### L. File path / metadata — LOW
- Filename/directory/prefix/run-number builder (+ `run_number_incremented`)
### M. Analysis panels (nice-to-have) — MED
- Target-stability chart, prediction-metrics, smargon-trace (CSV/charts)
### N. UX / framework — MED
- Keyboard shortcuts (~18), layout persistence (QSettings), restore-default-layout
- Tutorials/guided help (F1), splash screen
- Portrait / compact automation layouts
---
## Proposed phased plan
**Phase 1 — core operator parity (HIGH value):** D (completed-scan feedback +
screening), C (raster results heatmap+table), E (XRF spectrum), K (live
automation progress) + the pausable protocol queue. Makes day-to-day collection
fully usable.
**Phase 2 — camera & feedback:** A (ML overlays, beam centre, screenshot,
focus), B (gonio/beamline video), I (alert banner + precondition dialogs +
sample-missing).
**Phase 3 — staff tooling:** G (beam/detector physics), J (pgroups + sessions),
H (recovery + local-contact admin), developer help.
**Phase 4 — polish:** L (file builder), F (face detection), M (analysis panels),
N (shortcuts, layout persistence, tutorials, portrait/compact).
+1
View File
@@ -0,0 +1 @@
"""Redesigned AareGUI (PySide6). See app.py for the entry point."""
+123
View File
@@ -0,0 +1,123 @@
"""Entry point for the redesigned AareGUI.
Run with::
uv run python -m aare.gui.new_gui.app
Reuses the existing auth flow and per-beamline config (cfg_get) from the current
GUI, then launches the redesigned :class:`MainWindow`. CLI flags mirror the old
GUI's URL / cert / camera options so it can point at any server.
"""
from __future__ import annotations
import os
import sys
import traceback
from PySide6 import QtGui
from PySide6.QtCore import QCommandLineOption, QCommandLineParser
from PySide6.QtWidgets import QApplication, QMessageBox
from aare.common.beamline import MXBeamline, cfg_get, mx_beamline
from aare.common.logger_config import setup_logger
from aare.gui.auth import auth
from aare.gui.new_gui.fonts import load_fonts
from aare.gui.new_gui.main_window import MainWindow
logger = setup_logger("aareGUI.new")
def _beamline_defaults():
"""Resolve (daq_url, cert_path, pred_zmq) defaults for the active beamline."""
match mx_beamline():
case MXBeamline.X06DA:
return (
cfg_get("gui.daq.daq_url", "https://mx-x06da-queue-01.psi.ch"),
cfg_get("gui.daq.cert_path", "/sls/x06da/misc/.cert/6d.crt"),
cfg_get("gui.cameras.prediction_zmq_url", "tcp://mx-ml:9091"),
)
case MXBeamline.X10SA:
return (
cfg_get("gui.daq.daq_url", "https://mx-x10sa-queue-01.psi.ch"),
cfg_get("gui.daq.cert_path", "/sls/x10sa/misc/.cert/10s.crt"),
cfg_get("gui.cameras.prediction_zmq_url", "tcp://x10sa-spark-01:9091"),
)
case MXBeamline.X06SA:
return (
cfg_get("gui.daq.daq_url", "https://mx-x06sa-queue-01.psi.ch"),
cfg_get("gui.daq.cert_path", "/sls/x06sa/misc/.cert/6s.crt"),
cfg_get("gui.cameras.prediction_zmq_url", ""),
)
case _:
return (
cfg_get("gui.daq.daq_url", ""),
cfg_get("gui.daq.cert_path", ""),
cfg_get("gui.cameras.prediction_zmq_url", ""),
)
def _clean(value: str | None) -> str | None:
if value is None:
return None
if value == "" or value.lower() == "none":
return None
return value
def main() -> None:
app = QApplication(sys.argv)
app.setApplicationName("AareGUI")
app.setApplicationVersion("0.4.0-new")
app.setOrganizationName("PSI")
basedir = os.path.dirname(os.path.dirname(__file__))
icon_path = os.path.join(basedir, "graphics", "aaregui_logo.svg")
if os.path.isfile(icon_path):
app.setWindowIcon(QtGui.QIcon(icon_path))
load_fonts()
default_url, default_cert, default_pred = _beamline_defaults()
parser = QCommandLineParser()
parser.setApplicationDescription("PSI AareGUI (redesign)")
parser.addHelpOption()
parser.addVersionOption()
url_opt = QCommandLineOption(["u", "aaredaq-url"], "Base AareDAQ URL", "url",
default_url)
cert_opt = QCommandLineOption(["c", "aaredaq-cert-path"],
"Server certificate path", "cert", default_cert)
pred_opt = QCommandLineOption(["p", "pred-zmq"],
"Prediction/SAMCAM ZeroMQ URL", "pred-zmq",
default_pred)
for opt in (url_opt, cert_opt, pred_opt):
parser.addOption(opt)
parser.process(app)
base_url = _clean(parser.value(url_opt))
cert_path = _clean(parser.value(cert_opt))
pred_zmq = _clean(parser.value(pred_opt))
try:
token = auth(base_url, cert_path)
if not token or token.count(".") != 2:
raise RuntimeError("Authentication did not return a valid token.")
logger.info("Authentication successful")
except Exception as exc:
logger.error("Cannot connect to AareDAQ server: %s", exc)
logger.error(traceback.format_exc())
QMessageBox.critical(
None, "Authentication Error",
f"Cannot connect to AareDAQ server:\n{exc}\n\n"
"Please check the server is running and your network connection.",
)
sys.exit(1)
win = MainWindow(base_url=base_url, token=token, pred_zmq_addr=pred_zmq)
win.show()
sys.exit(app.exec())
if __name__ == "__main__":
main()
+530
View File
@@ -0,0 +1,530 @@
"""Automation mode — the queue builder (library · queue · start bar).
The queue is GUI-local state held in :class:`AppState`. ``Start`` iterates
``automated_scan`` per sample (wired in the main window). Per-sample protocol
chips are local for now; a pausable protocol-driven automation comes later.
"""
from __future__ import annotations
from PySide6.QtCore import Qt, Signal
from PySide6.QtWidgets import (
QHBoxLayout,
QLabel,
QLineEdit,
QPushButton,
QScrollArea,
QVBoxLayout,
QWidget,
)
from aare.gui.new_gui.state import PROTOCOL_KEYS, AppState
from aare.gui.new_gui.theme import (
FONT_MONO_FALLBACK,
LIBRARY_W,
START_BAR_H,
Palette,
)
from aare.gui.new_gui.widgets.automation_progress import AutomationProgressPanel
from aare.gui.new_gui.widgets.common import Chip, micro_label, section_label
from aare.gui.new_gui.widgets.sample_changer import (
sample_id,
sample_label,
sample_pos,
sample_puck,
)
# Per-sample cost model (minutes), from the prototype; replace with real estimator.
COST_MIN = {"base": 1, "center": 1, "raster": 3, "xrf": 1, "collect": 8}
def _thumb(palette: Palette, size: int = 34) -> QLabel:
t = QLabel()
t.setFixedSize(size, size)
t.setStyleSheet(
"border-radius:8px; background: qradialgradient(cx:0.7, cy:0.75,"
" radius:1.2, fx:0.7, fy:0.75, stop:0 #3a342d, stop:1 #121113);"
)
return t
def estimate_minutes(queue) -> int:
total = 0
for item in queue:
total += COST_MIN["base"]
for k in PROTOCOL_KEYS:
if item.protocol.get(k):
total += COST_MIN[k]
return total
def fmt_eta(minutes: int) -> str:
h, m = divmod(minutes, 60)
return (f"{h}h " if h else "") + f"{m}m"
class LibraryRow(QWidget):
add_clicked = Signal(object)
def __init__(self, sample, palette: Palette, added: bool, parent=None):
super().__init__(parent)
self._sample = sample
lay = QHBoxLayout(self)
lay.setContentsMargins(8, 8, 8, 8)
lay.setSpacing(10)
lay.addWidget(_thumb(palette))
col = QVBoxLayout()
col.setSpacing(0)
name = QLabel(sample_label(sample))
name.setStyleSheet(
f"font-family:{FONT_MONO_FALLBACK}; font-size:12px; font-weight:600;"
)
meta = QLabel(sample_pos(sample))
meta.setStyleSheet(
f"font-family:{FONT_MONO_FALLBACK}; font-size:10.5px;"
f" color:{palette.text_faint};"
)
col.addWidget(name)
col.addWidget(meta)
lay.addLayout(col, 1)
self._add = QPushButton("" if added else "+")
self._add.setFixedSize(28, 28)
self._add.setEnabled(not added)
self._add.setCursor(Qt.PointingHandCursor if not added else Qt.ArrowCursor)
if added:
css = f"background:{palette.success_tint}; color:{palette.success}; font-size:13px;"
else:
css = f"background:{palette.accent}; color:#fff; font-size:17px;"
self._add.setStyleSheet(
f"QPushButton {{ {css} border:none; border-radius:8px; font-weight:600; }}"
)
self._add.clicked.connect(lambda: self.add_clicked.emit(self._sample))
lay.addWidget(self._add)
class LibraryPanel(QWidget):
add_sample = Signal(object)
add_puck = Signal(str)
def __init__(self, state: AppState, palette: Palette, parent=None):
super().__init__(parent)
self.setObjectName("SidePanel")
self.setFixedWidth(LIBRARY_W)
self._state = state
self._p = palette
self._samples: list = []
outer = QVBoxLayout(self)
outer.setContentsMargins(0, 0, 0, 0)
outer.setSpacing(0)
header = QWidget()
header.setStyleSheet(f"border-bottom:1px solid {palette.border_panel};")
hl = QVBoxLayout(header)
hl.setContentsMargins(14, 14, 14, 10)
hl.setSpacing(10)
hl.addWidget(section_label("LIBRARY"))
self._search = QLineEdit()
self._search.setPlaceholderText("Search samples…")
self._search.textChanged.connect(self._rebuild)
hl.addWidget(self._search)
outer.addWidget(header)
self._scroll = QScrollArea()
self._scroll.setWidgetResizable(True)
self._scroll.setFrameShape(QScrollArea.NoFrame)
self._list = QWidget()
self._list_lay = QVBoxLayout(self._list)
self._list_lay.setContentsMargins(8, 8, 8, 8)
self._list_lay.setSpacing(2)
self._list_lay.addStretch(1)
self._scroll.setWidget(self._list)
outer.addWidget(self._scroll, 1)
# ADD WHOLE PUCK footer
self._footer = QWidget()
self._footer.setStyleSheet(f"border-top:1px solid {palette.border_panel};")
self._footer_lay = QVBoxLayout(self._footer)
self._footer_lay.setContentsMargins(12, 12, 12, 12)
self._footer_lay.setSpacing(6)
self._footer_lay.addWidget(micro_label("ADD WHOLE PUCK"))
outer.addWidget(self._footer)
def set_samples(self, samples) -> None:
self._samples = list(samples)
self._rebuild()
self._rebuild_pucks()
def refresh_added(self) -> None:
self._rebuild()
def _queued_ids(self) -> set:
return {item.sample_id for item in self._state.queue}
def _rebuild(self) -> None:
while self._list_lay.count() > 1:
w = self._list_lay.takeAt(0).widget()
if w:
w.setParent(None)
w.deleteLater()
query = self._search.text().strip().lower()
queued = self._queued_ids()
for s in self._samples:
if query and query not in sample_label(s).lower():
continue
row = LibraryRow(s, self._p, added=sample_id(s) in queued)
row.add_clicked.connect(self.add_sample)
self._list_lay.insertWidget(self._list_lay.count() - 1, row)
def _rebuild_pucks(self) -> None:
# clear all but the label
while self._footer_lay.count() > 1:
w = self._footer_lay.takeAt(1).widget()
if w:
w.setParent(None)
w.deleteLater()
pucks: dict[str, int] = {}
for s in self._samples:
pucks[sample_puck(s)] = pucks.get(sample_puck(s), 0) + 1
for puck, n in pucks.items():
btn = QPushButton()
btn.setCursor(Qt.PointingHandCursor)
btn.setText(f" {puck} {n}")
btn.setStyleSheet(
f"QPushButton {{ text-align:left; background:{self._p.surface};"
f" border:1px solid {self._p.border_control}; border-radius:8px;"
f" padding:9px 12px; font-size:12.5px; color:{self._p.text_secondary}; }}"
)
btn.clicked.connect(lambda _=False, pk=puck: self.add_puck.emit(pk))
self._footer_lay.addWidget(btn)
class QueueRow(QWidget):
toggled = Signal(int, str) # index, protocol key
move = Signal(int, int) # index, direction
remove = Signal(int)
def __init__(self, index: int, item, palette: Palette, parent=None):
super().__init__(parent)
self._index = index
p = palette
self.setAttribute(Qt.WA_StyledBackground, True)
self.setStyleSheet(
f"QueueRow {{ background:{p.surface}; border:1px solid {p.border_panel};"
f" border-radius:11px; }}"
)
lay = QHBoxLayout(self)
lay.setContentsMargins(14, 11, 14, 11)
lay.setSpacing(14)
handle = QLabel("")
handle.setStyleSheet(f"color:{p.pending_node}; font-size:16px;")
handle.setCursor(Qt.OpenHandCursor)
lay.addWidget(handle)
idx = QLabel(str(index + 1))
idx.setFixedWidth(20)
idx.setAlignment(Qt.AlignRight | Qt.AlignVCenter)
idx.setStyleSheet(
f"font-family:{FONT_MONO_FALLBACK}; color:{p.text_faint};"
)
lay.addWidget(idx)
lay.addWidget(_thumb(palette, 38))
col = QVBoxLayout()
col.setSpacing(0)
name = QLabel(sample_label(item.sample))
name.setStyleSheet(
f"font-family:{FONT_MONO_FALLBACK}; font-size:13px; font-weight:600;"
)
meta = QLabel(f"{sample_puck(item.sample)} · {sample_pos(item.sample)}")
meta.setStyleSheet(
f"font-family:{FONT_MONO_FALLBACK}; font-size:10.5px; color:{p.text_faint};"
)
col.addWidget(name)
col.addWidget(meta)
wrap = QWidget()
wrap.setLayout(col)
wrap.setMinimumWidth(140)
lay.addWidget(wrap)
lay.addStretch(1)
chips = QHBoxLayout()
chips.setSpacing(5)
for key in PROTOCOL_KEYS:
chip = Chip(key.capitalize(), palette, checked=item.protocol.get(key))
chip.clicked.connect(lambda _=False, k=key: self.toggled.emit(self._index, k))
chips.addWidget(chip)
lay.addLayout(chips)
reorder = QVBoxLayout()
reorder.setSpacing(2)
up = self._reorder_btn("", palette)
down = self._reorder_btn("", palette)
up.clicked.connect(lambda: self.move.emit(self._index, -1))
down.clicked.connect(lambda: self.move.emit(self._index, 1))
reorder.addWidget(up)
reorder.addWidget(down)
lay.addLayout(reorder)
rm = QPushButton("")
rm.setFixedSize(28, 28)
rm.setCursor(Qt.PointingHandCursor)
rm.setStyleSheet(
f"QPushButton {{ border:none; background:transparent;"
f" color:{p.danger}; font-size:14px; }}"
)
rm.clicked.connect(lambda: self.remove.emit(self._index))
lay.addWidget(rm)
@staticmethod
def _reorder_btn(glyph: str, p: Palette) -> QPushButton:
b = QPushButton(glyph)
b.setFixedSize(24, 17)
b.setCursor(Qt.PointingHandCursor)
b.setStyleSheet(
f"QPushButton {{ background:{p.app_bg}; border:1px solid {p.border_panel};"
f" border-radius:5px; font-size:9px; color:{p.text_muted}; }}"
)
return b
class AutomationView(QWidget):
start_requested = Signal()
pause_requested = Signal()
stop_requested = Signal()
save_template_requested = Signal() # TODO: wire to template persistence later
def __init__(self, state: AppState, palette: Palette, parent=None):
super().__init__(parent)
self._state = state
self._p = palette
root = QHBoxLayout(self)
root.setContentsMargins(0, 0, 0, 0)
root.setSpacing(0)
# left: library
self.library = LibraryPanel(state, palette)
self.library.add_sample.connect(self._on_add_sample)
self.library.add_puck.connect(self._on_add_puck)
root.addWidget(self.library)
# centre: queue
center = QWidget()
cl = QVBoxLayout(center)
cl.setContentsMargins(0, 0, 0, 0)
cl.setSpacing(0)
cl.addWidget(self._build_header())
cl.addWidget(self._build_queue_area(), 1)
cl.addWidget(self._build_start_bar())
root.addWidget(center, 1)
# right: live run progress
self.progress = AutomationProgressPanel(palette)
root.addWidget(self.progress)
state.queue_changed.connect(self._refresh)
self._refresh()
def set_running(self, running: bool, paused: bool = False) -> None:
"""Toggle Start/Pause/Stop affordances while automation runs."""
self._start.setVisible(not running)
self._pause.setVisible(running)
self._stop.setVisible(running)
self._pause.setText("▶ Resume" if paused else "❚❚ Pause")
if not running:
self.progress.set_idle()
# ---- header ----
def _build_header(self) -> QWidget:
p = self._p
head = QWidget()
head.setObjectName("HeaderBlock")
lay = QVBoxLayout(head)
lay.setContentsMargins(24, 18, 24, 14)
lay.setSpacing(14)
title_row = QHBoxLayout()
title_row.setSpacing(12)
title = QLabel("Automation queue")
title.setStyleSheet("font-size:19px; font-weight:700;")
title_row.addWidget(title)
self._count_lbl = QLabel("")
self._count_lbl.setStyleSheet(
f"font-family:{FONT_MONO_FALLBACK}; font-size:13px; color:{p.text_muted};"
)
title_row.addWidget(self._count_lbl)
title_row.addStretch(1)
clear = QPushButton("Clear all")
clear.setCursor(Qt.PointingHandCursor)
clear.setFlat(True)
clear.setStyleSheet(
f"QPushButton {{ border:none; background:transparent;"
f" color:{p.text_faint}; font-size:12px; }}"
)
clear.clicked.connect(self._state.queue_clear)
title_row.addWidget(clear)
lay.addLayout(title_row)
proto_row = QHBoxLayout()
proto_row.setSpacing(10)
proto_row.addWidget(micro_label("DEFAULT PROTOCOL FOR NEW ADDS"))
self._default_chips: dict[str, Chip] = {}
for key in PROTOCOL_KEYS:
chip = Chip(key.capitalize(), p, checked=self._state.defaults.get(key))
chip.clicked.connect(lambda _=False, k=key: self._state.toggle_default(k))
self._default_chips[key] = chip
proto_row.addWidget(chip)
proto_row.addStretch(1)
lay.addLayout(proto_row)
return head
# ---- queue area ----
def _build_queue_area(self) -> QWidget:
self._scroll = QScrollArea()
self._scroll.setWidgetResizable(True)
self._scroll.setFrameShape(QScrollArea.NoFrame)
self._queue_host = QWidget()
self._queue_lay = QVBoxLayout(self._queue_host)
self._queue_lay.setContentsMargins(24, 14, 24, 14)
self._queue_lay.setSpacing(8)
self._queue_lay.addStretch(1)
self._scroll.setWidget(self._queue_host)
# empty-state overlay label kept simple as a member
self._empty = QWidget()
el = QVBoxLayout(self._empty)
el.setAlignment(Qt.AlignCenter)
plus = QLabel("")
plus.setAlignment(Qt.AlignCenter)
plus.setStyleSheet(f"font-size:30px; color:{self._p.text_faint};")
t1 = QLabel("Queue is empty")
t1.setAlignment(Qt.AlignCenter)
t1.setStyleSheet(f"font-size:14px; font-weight:600; color:{self._p.text_faint};")
t2 = QLabel("Add samples from the library, or drop in a whole puck.")
t2.setAlignment(Qt.AlignCenter)
t2.setStyleSheet(f"font-size:12.5px; color:{self._p.text_faint};")
el.addWidget(plus)
el.addWidget(t1)
el.addWidget(t2)
host = QWidget()
hl = QVBoxLayout(host)
hl.setContentsMargins(0, 0, 0, 0)
hl.setSpacing(0)
hl.addWidget(self._empty)
hl.addWidget(self._scroll)
return host
# ---- start bar ----
def _build_start_bar(self) -> QWidget:
p = self._p
bar = QWidget()
bar.setObjectName("BottomPanel")
bar.setFixedHeight(START_BAR_H)
lay = QHBoxLayout(bar)
lay.setContentsMargins(24, 0, 24, 0)
lay.setSpacing(16)
col = QVBoxLayout()
col.setSpacing(1)
self._start_count = QLabel("")
self._start_count.setStyleSheet(
f"font-family:{FONT_MONO_FALLBACK}; font-size:13px; font-weight:600;"
)
self._start_sub = QLabel("")
self._start_sub.setStyleSheet(f"font-size:11.5px; color:{p.text_muted};")
col.addWidget(self._start_count)
col.addWidget(self._start_sub)
lay.addLayout(col)
lay.addStretch(1)
save = QPushButton("Save as template")
save.setCursor(Qt.PointingHandCursor)
save.setStyleSheet(
f"QPushButton {{ background:{p.surface}; border:1px solid {p.border_control};"
f" border-radius:9px; padding:10px 16px; font-size:12.5px;"
f" color:{p.text_secondary}; }}"
)
save.clicked.connect(self.save_template_requested)
lay.addWidget(save)
self._start = QPushButton("▶ Start automation")
self._start.setCursor(Qt.PointingHandCursor)
self._start.setStyleSheet(
f"QPushButton {{ background:{p.accent}; color:#fff; border:none;"
f" border-radius:10px; padding:12px 26px; font-size:14px; font-weight:700; }}"
)
self._start.clicked.connect(self.start_requested)
lay.addWidget(self._start)
self._pause = QPushButton("❚❚ Pause")
self._pause.setCursor(Qt.PointingHandCursor)
self._pause.setStyleSheet(
f"QPushButton {{ background:{p.surface}; border:1px solid {p.border_control};"
f" border-radius:10px; padding:12px 22px; font-size:13px; color:{p.text_secondary}; }}"
)
self._pause.clicked.connect(self.pause_requested)
self._pause.setVisible(False)
lay.addWidget(self._pause)
self._stop = QPushButton("⏹ Stop")
self._stop.setCursor(Qt.PointingHandCursor)
self._stop.setStyleSheet(
f"QPushButton {{ background:{p.surface}; border:1px solid {p.border_control};"
f" border-radius:10px; padding:12px 22px; font-size:13px; color:{p.danger}; }}"
)
self._stop.clicked.connect(self.stop_requested)
self._stop.setVisible(False)
lay.addWidget(self._stop)
return bar
# ---- handlers ----
def _on_add_sample(self, sample) -> None:
self._state.queue_add(sample)
def _on_add_puck(self, puck: str) -> None:
members = [s for s in self.library._samples if sample_puck(s) == puck]
self._state.queue_add_many(members)
def set_samples(self, samples) -> None:
self.library.set_samples(samples)
def _refresh(self) -> None:
# sync default chips
for key, chip in self._default_chips.items():
want = bool(self._state.defaults.get(key))
if chip.isChecked() != want:
chip.blockSignals(True)
chip.setChecked(want)
chip.blockSignals(False)
# rebuild queue rows
while self._queue_lay.count() > 1:
w = self._queue_lay.takeAt(0).widget()
if w:
w.setParent(None)
w.deleteLater()
queue = self._state.queue
for i, item in enumerate(queue):
row = QueueRow(i, item, self._p)
row.toggled.connect(self._state.toggle_row_protocol)
row.move.connect(self._state.queue_move)
row.remove.connect(self._state.queue_remove)
self._queue_lay.insertWidget(self._queue_lay.count() - 1, row)
empty = len(queue) == 0
self._empty.setVisible(empty)
self._scroll.setVisible(not empty)
mins = estimate_minutes(queue)
eta = fmt_eta(mins)
self._count_lbl.setText(f"{len(queue)} samples · est {eta}")
self._start_count.setText(f"{len(queue)} samples queued")
self._start_sub.setText(f"estimated runtime {eta} · auto-advance on")
self._start.setEnabled(not empty)
self.library.refresh_added()
+59
View File
@@ -0,0 +1,59 @@
"""Font registration for the redesigned AareGUI.
The design calls for IBM Plex Sans (UI) + IBM Plex Mono (numeric readouts,
sample names, status bar). If the TTFs are bundled under ``assets/fonts`` they
are registered with the application; otherwise the QSS fallbacks in
:mod:`theme` quietly take over so the GUI still runs everywhere.
"""
from __future__ import annotations
import os
from PySide6.QtGui import QFontDatabase
from aare.common.logger_config import setup_logger
logger = setup_logger("aareGUI.new")
_FONT_DIR = os.path.join(os.path.dirname(__file__), "assets", "fonts")
# File names we look for (Google Fonts / SIL OFL distribution names).
_FONT_FILES = (
"IBMPlexSans-Regular.ttf",
"IBMPlexSans-Medium.ttf",
"IBMPlexSans-SemiBold.ttf",
"IBMPlexSans-Bold.ttf",
"IBMPlexMono-Regular.ttf",
"IBMPlexMono-Medium.ttf",
"IBMPlexMono-SemiBold.ttf",
)
def load_fonts() -> list[str]:
"""Register bundled IBM Plex fonts. Returns the loaded family names.
Safe to call once at startup. Missing files are skipped without error so a
developer checkout without the TTFs still launches (falling back to system
fonts declared in the QSS).
"""
families: list[str] = []
if not os.path.isdir(_FONT_DIR):
logger.info(
"Bundled fonts not found at %s; using system fallbacks.", _FONT_DIR
)
return families
for name in _FONT_FILES:
path = os.path.join(_FONT_DIR, name)
if not os.path.isfile(path):
continue
font_id = QFontDatabase.addApplicationFont(path)
if font_id == -1:
logger.warning("Failed to register font %s", path)
continue
families.extend(QFontDatabase.applicationFontFamilies(font_id))
if families:
logger.info("Registered fonts: %s", sorted(set(families)))
return families
+561
View File
@@ -0,0 +1,561 @@
"""Main window for the redesigned AareGUI.
Assembles the shared chrome (top bar / status bar) and a QStackedWidget body
(Manual / Automation), then wires everything to the existing DAQWorker and the
SAMCAM PredictionSubscriber. This is the only module that knows about the
backend; the views speak in intent signals and request objects.
"""
from __future__ import annotations
from PySide6.QtCore import QTimer, Slot
from PySide6.QtWidgets import (
QStackedWidget,
QVBoxLayout,
QWidget,
)
from aare.common.beamline import cfg_get, mx_beamline
from aare.common.logger_config import setup_logger
from aare.gui.new_gui.automation_view import AutomationView
from aare.gui.new_gui.manual_view import ManualView
from aare.gui.new_gui.state import AppState
from aare.gui.new_gui.status_bar import StatusBar
from aare.gui.new_gui.theme import LIGHT, build_qss
from aare.gui.new_gui.top_bar import TopBar
from aare.gui.new_gui.widgets.alert_banner import AlertBanner
logger = setup_logger("aareGUI.new")
_BEAMLINE_SUBTITLE = {"X06DA": "PXIII", "X10SA": "PXII", "X06SA": "PXI"}
def _collect_defaults() -> dict:
"""Scan defaults from the beamline YAML (with safe fall-backs)."""
rot = "data_collection_settings.default_rotation_settings"
ras = "data_collection_settings.default_raster_scan_settings"
return {
"exp_time_s": cfg_get(f"{rot}.exp_time_s", 0.02),
"transmission": cfg_get(f"{rot}.transmission", 1.0),
"dtz": cfg_get(f"{rot}.dtz", 200.0),
"start_omega_deg": cfg_get(f"{rot}.start_omega_deg", 0.0),
"increment_omega_deg": cfg_get(f"{rot}.increment_omega_deg", 0.1),
"steps": cfg_get(f"{rot}.steps", 1800),
"rotation_exp_time_s": cfg_get(f"{rot}.exp_time_s", 0.02),
"rotation_dtz": cfg_get(f"{rot}.dtz", 200.0),
"raster_exp_time_s": cfg_get(f"{ras}.exp_time_s", 0.02),
"raster_dtz": cfg_get(f"{ras}.dtz", 200.0),
}
class MainWindow(QWidget):
def __init__(self, base_url, token, pred_zmq_addr=None, parent=None):
super().__init__(parent)
self._base_url = base_url
self._token = token
self._palette = LIGHT
self._defaults = _collect_defaults()
# automation run bookkeeping
self._auto_running: list = []
self._auto_index = 0
self._auto_paused = False
# baton / session bookkeeping
self._staff = False
self._baton = None
self._pending_dialog = None
self._incoming_dialog = None
self._baton_poll = QTimer(self)
self._baton_poll.setInterval(1000)
self._baton_poll.timeout.connect(self._poll_baton_timeout)
self.setObjectName("AppRoot")
self.setStyleSheet(build_qss(self._palette))
self.setWindowTitle("AareGUI")
self.resize(1400, 860)
beamline = str(mx_beamline().name if hasattr(mx_beamline(), "name")
else mx_beamline())
subtitle = _BEAMLINE_SUBTITLE.get(beamline, "")
bl_label = f"{beamline} · {subtitle}" if subtitle else beamline
self.state = AppState(self)
# chrome + body
self.top_bar = TopBar(self._palette, bl_label)
self.alert = AlertBanner(self._palette)
self.status_bar = StatusBar(self._palette)
self.body = QStackedWidget()
self.manual = ManualView(self.state, self._palette, self._defaults)
self.automation = AutomationView(self.state, self._palette)
self.body.addWidget(self.manual)
self.body.addWidget(self.automation)
layout = QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(0)
layout.addWidget(self.top_bar)
layout.addWidget(self.alert)
layout.addWidget(self.body, 1)
layout.addWidget(self.status_bar)
# backend
self._build_backend(pred_zmq_addr)
self._wire()
# ------------------------------------------------------------- backend
def _build_backend(self, pred_zmq_addr) -> None:
from aare.gui.threads.daq_worker import DAQWorker
self.daq = DAQWorker(base_url=self._base_url, token=self._token)
self.camera_thread = None
if pred_zmq_addr:
try:
from aare.gui.threads.prediction_subscriber import PredictionSubscriber
self.camera_thread = PredictionSubscriber(
pred_zmq_url=pred_zmq_addr, topic=b""
)
self.camera_thread.start()
except Exception as exc: # pragma: no cover - hardware/runtime path
logger.warning("Could not start camera stream: %s", exc)
self.camera_thread = None
# -------------------------------------------------------------- wiring
def _wire(self) -> None:
s = self.state
# mode toggle <-> state <-> body
self.top_bar.mode_changed.connect(s.set_mode)
s.mode_changed.connect(self._on_mode_changed)
# live status fan-out
self.daq.update.connect(self.status_bar.update_daq_status)
self.daq.update.connect(self.manual.update_daq_status)
self.daq.update.connect(self._on_status)
self.daq.baton_status_changed.connect(self.top_bar.update_baton_status)
self.daq.baton_status_changed.connect(self._on_baton_status)
# sample list -> changer + library
self.daq.spreadsheet.connect(self._on_spreadsheet)
self.daq.reference_tools.connect(self._on_reference_tools)
# baton controls
self.top_bar.request_baton.connect(self.daq.request_baton)
self.top_bar.release_baton.connect(self.daq.release_baton)
self.top_bar.cancel_baton_request.connect(self._on_cancel_baton)
self.daq.baton_request_result.connect(self._on_baton_request_result)
self.daq.baton_response_result.connect(self._on_baton_response_result)
if hasattr(self.daq, "baton_incoming_request"):
self.daq.baton_incoming_request.connect(self._on_baton_incoming)
if hasattr(self.daq, "baton_timeout_checked"):
self.daq.baton_timeout_checked.connect(self._on_baton_timeout)
# beamline state switching (clickable LED)
self.status_bar.state_change_requested.connect(self._on_state_change)
# manual intents -> DAQ
self.manual.center_loop_requested.connect(self.daq.center_loop)
self.manual.center_at_point_requested.connect(self._on_center_at_point)
# Non-auto raster: smargon_top_left is computed from the drawn grid.
self.manual.raster_scan_requested.connect(self.daq.raster_scan)
self.manual.rotation_scan_requested.connect(self.daq.standard_scan)
self.manual.smart_params_requested.connect(self._on_smart_params)
self.manual.xrf_scan_requested.connect(self.daq.fluorimeter_spectrum)
self.manual.raster_goto_requested.connect(self.daq.move_smargon)
self.manual.status_message.connect(self._note)
if hasattr(self.daq, "raster_scan_completed"):
self.daq.raster_scan_completed.connect(self.manual.on_raster_completed)
# XRF spectrum view <-> fluorimeter
spec = self.manual.spectrum
spec.start_clicked.connect(self.daq.fluorimeter_start)
spec.stop_clicked.connect(self.daq.fluorimeter_stop)
spec.snapshot_clicked.connect(self.daq.fluorimeter_request_snapshot)
if hasattr(self.daq, "fluorimeter_spectrum_update"):
self.daq.fluorimeter_spectrum_update.connect(self._on_spectrum)
if hasattr(self.daq, "fluorimeter_update"):
self.daq.fluorimeter_update.connect(spec.update_live)
# state mount intents -> DAQ
s.mount_requested.connect(self._on_mount_requested)
s.unmount_requested.connect(self.daq.unmount)
# motors -> DAQ
self.manual.motors.omega_rel.connect(self.daq.set_omega_rel)
self.manual.motors.zoom_changed.connect(self.daq.zoom)
self.manual.motors.front_light_changed.connect(self.daq.front_light)
self.manual.motors.back_light_changed.connect(self.daq.back_light)
self.manual.motors.smargon_jog.connect(self._on_smargon_jog)
self.manual.motors.energy_changed.connect(self.daq.change_energy)
# automation
self.automation.start_requested.connect(self._start_automation)
self.automation.pause_requested.connect(self._on_auto_pause)
self.automation.stop_requested.connect(self._on_auto_stop)
self.automation.save_template_requested.connect(
lambda: self._note("Save as template is not wired yet.")
)
self.daq.automated_scan_done.connect(self._on_auto_done)
if hasattr(self.daq, "automation_progress"):
self.daq.automation_progress.connect(
self.automation.progress.update_progress
)
# errors / alerts -> banner
if hasattr(self.daq, "http_error"):
self.daq.http_error.connect(lambda msg: self._note(msg, error=True))
if hasattr(self.daq, "auth_error"):
self.daq.auth_error.connect(self._on_auth_error)
if hasattr(self.daq, "sample_missing"):
self.daq.sample_missing.connect(lambda msg: self._note(msg, error=True))
if hasattr(self.daq, "polled_devices_status"):
self.daq.polled_devices_status.connect(self._on_device_status)
if hasattr(self.daq, "detector_error"):
self.daq.detector_error.connect(self._on_device_status)
if hasattr(self.daq, "automation_critical_failure"):
self.daq.automation_critical_failure.connect(
lambda msg: self._note(msg, error=True))
if hasattr(self.daq, "manual_collection_critical_failure"):
self.daq.manual_collection_critical_failure.connect(
lambda msg: self._note(msg, error=True))
if hasattr(self.daq, "pss_alarm_changed"):
self.daq.pss_alarm_changed.connect(
lambda *a: self.alert.show_message(
"PSS alarm — check hutch door / interlocks.", "warning"))
# completion notices
if hasattr(self.daq, "standard_scan_completed"):
self.daq.standard_scan_completed.connect(self._on_rotation_completed)
if hasattr(self.daq, "raster_scan_completed"):
self.daq.raster_scan_completed.connect(self._on_raster_banner)
# camera stream
if self.camera_thread is not None:
cam = self.manual.camera
self.camera_thread.image.connect(cam.update_pixmap)
if hasattr(self.camera_thread, "fps_measure"):
self.camera_thread.fps_measure.connect(cam.update_fps)
if hasattr(self.camera_thread, "target_point"):
self.camera_thread.target_point.connect(cam.update_target_point)
if hasattr(self.camera_thread, "update_daq_status"):
self.daq.update.connect(self.camera_thread.update_daq_status)
# ------------------------------------------------------------ handlers
@Slot(str)
def _on_mode_changed(self, mode: str) -> None:
self.body.setCurrentWidget(
self.automation if mode == "automation" else self.manual
)
self.top_bar.set_mode(mode)
@Slot(object)
def _on_spreadsheet(self, payload) -> None:
samples = getattr(payload, "s", payload)
try:
samples = list(samples)
except TypeError:
samples = []
self.manual.changer.set_samples(samples)
self.automation.set_samples(samples)
@Slot(object, bool)
def _on_mount_requested(self, sample, reference: bool = False) -> None:
self.daq.mount(sample, reference)
@Slot(object)
def _on_reference_tools(self, payload) -> None:
tools = getattr(payload, "s", payload)
try:
tools = list(tools)
except TypeError:
tools = []
self.manual.set_reference_tools(tools)
# ---- staff + control (guest) state ----
@Slot(object)
def _on_status(self, s) -> None:
session = getattr(s, "session", None)
staff = bool(getattr(session, "staff", False)) if session else False
if staff != self._staff:
self._staff = staff
self.top_bar.set_staff(staff)
self.manual.set_staff(staff)
self._update_control()
def _on_baton_status(self, status) -> None:
self._baton = status
self._update_control()
# surface an incoming request even without the dedicated signal
if getattr(status, "incoming_request", False) and self._incoming_dialog is None:
req = getattr(status, "pending_request", None)
who = getattr(req, "requester_username", "Another user") if req else "Another user"
timeout = getattr(req, "timeout_seconds", 30) if req else 30
self._show_incoming_dialog(who, timeout)
def _update_control(self) -> None:
"""Grey the camera + show Guest/Vacant overlay unless we hold the baton."""
b = self._baton
if b is None:
self.manual.set_control_state(True)
return
if getattr(b, "you_are_holder", False):
self.manual.set_control_state(True)
return
holder = getattr(b, "holder", None)
if holder is None:
self.manual.set_control_state(
False, "VACANT", "Grab the baton to take control of the beamline.")
return
username = getattr(holder, "username", None)
holder_staff = bool(getattr(holder, "is_staff", False))
if self._staff and username:
sub = f"{username} has control of the beamline."
elif holder_staff:
sub = "Staff has control of the beamline."
else:
sub = "Another user has control of the beamline."
self.manual.set_control_state(False, "GUEST MODE", sub)
# ---- baton request / response flow ----
@Slot(dict)
def _on_baton_request_result(self, result: dict) -> None:
if result.get("granted") or result.get("already_holder"):
self._close_pending_dialog()
self._note("Baton acquired.")
elif result.get("error"):
self._close_pending_dialog()
self._note(result.get("message", "Baton request failed."), error=True)
elif result.get("pending") or result.get("queued"):
timeout = int(result.get("timeout_seconds", 30) or 30)
holder = ""
if self._baton is not None and getattr(self._baton, "holder", None):
if self._staff:
holder = getattr(self._baton.holder, "username", "") or ""
self._show_pending_dialog(holder, timeout)
if result.get("queued"):
self._pending_dialog.set_queued()
self._baton_poll.start()
@Slot(dict)
def _on_baton_response_result(self, result: dict) -> None:
if result.get("accepted"):
self._note("Control transferred.")
elif result.get("refused"):
self._note("Request declined.")
self._close_incoming_dialog()
def _on_baton_incoming(self, payload: dict) -> None:
if self._incoming_dialog is not None:
return
who = payload.get("requester", "Another user")
timeout = int(payload.get("timeout", 30) or 30)
self._show_incoming_dialog(who, timeout)
@Slot(dict)
def _on_baton_timeout(self, payload: dict) -> None:
if self._pending_dialog is None:
return
if payload.get("granted"):
self._close_pending_dialog()
self._note("Baton acquired.")
elif payload.get("refused"):
self._close_pending_dialog()
self._note("Request refused.", error=True)
elif payload.get("queued"):
self._pending_dialog.set_queued()
else:
self._pending_dialog.update_remaining(payload.get("remaining_seconds", 0))
def _poll_baton_timeout(self) -> None:
if self._pending_dialog is None:
self._baton_poll.stop()
return
if hasattr(self.daq, "check_baton_timeout"):
self.daq.check_baton_timeout()
def _on_cancel_baton(self) -> None:
self._close_pending_dialog()
if hasattr(self.daq, "cancel_baton_request"):
self.daq.cancel_baton_request()
def _show_pending_dialog(self, holder: str, timeout: int) -> None:
from aare.gui.new_gui.widgets.baton_dialogs import BatonPendingDialog
self._close_pending_dialog()
dlg = BatonPendingDialog(holder, timeout, self._palette, self)
dlg.cancelled_signal.connect(self._on_cancel_baton)
self._pending_dialog = dlg
dlg.show()
def _close_pending_dialog(self) -> None:
self._baton_poll.stop()
if self._pending_dialog is not None:
self._pending_dialog.close()
self._pending_dialog = None
def _show_incoming_dialog(self, requester: str, timeout: int) -> None:
from aare.gui.new_gui.widgets.baton_dialogs import BatonIncomingDialog
dlg = BatonIncomingDialog(requester, timeout, self._palette, self)
dlg.accepted_signal.connect(lambda: self.daq.respond_to_baton_request(True))
dlg.refused_signal.connect(lambda: self.daq.respond_to_baton_request(False))
dlg.finished.connect(self._close_incoming_dialog)
self._incoming_dialog = dlg
dlg.show()
def _close_incoming_dialog(self, *args) -> None:
if self._incoming_dialog is not None:
self._incoming_dialog = None
# ---- beamline state switching ----
@Slot(str)
def _on_state_change(self, key: str) -> None:
method = getattr(self.daq, key, None)
if callable(method):
self._note(f"Switching beamline state: {key}")
method()
else:
self._note(f"No DAQ method for state '{key}'.", error=True)
def _on_smargon_jog(self, dx_mm: float, dy_mm: float) -> None:
"""Relative sample-holder jog in beamline (screen) space.
The delta is a beamline-frame offset; ``beamline_to_smargon`` rotates it
through the current omega/phi/chi into an absolute smargon sh_mm target,
so the jog stays screen-intuitive at any goniometer angle. Arrow signs:
←/→ = -x/+x, ↑/↓ = -y/+y (camera y points down). Flip in MotorsPanel if
the beamline convention differs.
"""
from aare.common.coordinate import Coordinate, SmargonCoordinate
geom = getattr(self.manual.last_status, "geom", None)
if geom is None or not hasattr(geom, "beamline_to_smargon"):
self._note("No geometry yet; cannot jog.", error=True)
return
try:
sh = geom.beamline_to_smargon(Coordinate(x=dx_mm, y=dy_mm, z=0.0))
except Exception as exc:
self._note(f"Could not compute jog target: {exc}", error=True)
return
self.daq.move_smargon(SmargonCoordinate(sh_mm=sh))
def _on_spectrum(self, model) -> None:
self.manual.spectrum.update_spectrum(model)
self.manual.show_spectrum()
def _on_smart_params(self, sp) -> None:
"""Automate-parameters (simple strategy): forward seeds, then collect."""
from aare.gui.new_gui import requests
if hasattr(self.daq, "smart_params"):
self.daq.smart_params(sp)
self.daq.standard_scan(requests.rotation_from_simple(sp))
def _on_center_at_point(self, img_x: float, img_y: float) -> None:
"""Click-to-centre: move the clicked camera pixel onto the beam.
Mirrors the existing GUI: picture -> sample (beamline) -> smargon.
"""
from aare.common.coordinate import Coordinate, SmargonCoordinate
geom = getattr(self.manual.last_status, "geom", None)
if geom is None or not hasattr(geom, "picture_to_sample"):
self._note("No geometry yet; cannot centre.", error=True)
return
try:
sample = geom.picture_to_sample(Coordinate(x=img_x, y=img_y))
sh = geom.beamline_to_smargon(sample)
except Exception as exc:
self._note(f"Could not compute centre target: {exc}", error=True)
return
self.daq.move_smargon(SmargonCoordinate(sh_mm=sh))
# ---- automation loop (GUI-local; iterates automated_scan, pausable) ----
def _start_automation(self) -> None:
queue = list(self.state.queue)
if not queue:
return
self._auto_running = queue
self._auto_index = 0
self._auto_paused = False
self.automation.set_running(True, paused=False)
if hasattr(self.daq, "start_automation_progress_stream"):
self.daq.start_automation_progress_stream()
self._note(f"Starting automation: {len(queue)} samples queued.")
self._run_next_auto()
def _run_next_auto(self) -> None:
if self._auto_paused:
return
if self._auto_index >= len(self._auto_running):
self._note("Automation queue complete.")
self._auto_running = []
self.automation.set_running(False)
return
item = self._auto_running[self._auto_index]
self.daq.automated_scan(item.sample)
@Slot(int, bool, str)
def _on_auto_done(self, sample_id: int, success: bool, error: str) -> None:
if not self._auto_running:
return
if not success:
self._note(f"Automation stopped: {error}", error=True)
self._auto_running = []
self.automation.set_running(False)
return
self._auto_index += 1
self._run_next_auto() # no-op while paused; resumes on _on_auto_pause
def _on_auto_pause(self) -> None:
if not self._auto_running:
return
self._auto_paused = not self._auto_paused
self.automation.set_running(True, paused=self._auto_paused)
if self._auto_paused:
self._note("Automation will pause after the current sample.")
else:
self._note("Resuming automation.")
self._run_next_auto()
def _on_auto_stop(self) -> None:
self._auto_running = []
self._auto_paused = False
self.automation.set_running(False)
self._note("Automation stopped.")
def _on_auth_error(self) -> None:
self._note("Lost the baton / authentication error.", error=True)
def _note(self, message: str, error: bool = False, level: str | None = None) -> None:
if error:
logger.error(message)
else:
logger.info(message)
self.alert.show_message(message, level or ("error" if error else "info"))
def _on_device_status(self, message: str, is_error: bool) -> None:
if is_error:
self.alert.show_message(message, "error")
def _on_rotation_completed(self, _completed) -> None:
self.alert.show_message("Rotation collection complete.", "success")
def _on_raster_banner(self, _completed) -> None:
self.alert.show_message("Raster scan complete.", "success")
# --------------------------------------------------------------- close
def closeEvent(self, event): # noqa: N802
try:
if hasattr(self.daq, "cleanup"):
self.daq.cleanup()
except Exception:
pass
try:
if self.camera_thread is not None and hasattr(self.camera_thread, "stop"):
self.camera_thread.stop()
except Exception:
pass
super().closeEvent(event)
+339
View File
@@ -0,0 +1,339 @@
"""Manual mode — the Mount-first cockpit (changer · camera · motors)."""
from __future__ import annotations
from PySide6.QtCore import Qt, Signal
from PySide6.QtWidgets import (
QButtonGroup,
QHBoxLayout,
QPushButton,
QStackedWidget,
QVBoxLayout,
QWidget,
)
from aare.gui.new_gui import requests
from aare.gui.new_gui.state import AppState
from aare.gui.new_gui.theme import CAMERA_TABS_H, Palette
from aare.gui.new_gui.widgets.camera import CameraViewport
from aare.gui.new_gui.widgets.motors_panel import MotorsPanel
from aare.gui.new_gui.widgets.pipeline_panel import PipelinePanel
from aare.gui.new_gui.widgets.heatmap import build_heatmap_qimage, metric_value
from aare.gui.new_gui.widgets.raster_grids_dialog import RasterGridsDialog
from aare.gui.new_gui.widgets.sample_changer import SampleChangerPanel, sample_id
from aare.gui.new_gui.widgets.spectrum_view import SpectrumView
class ManualView(QWidget):
"""Three-column cockpit. Emits parametrized backend requests for wiring."""
# backend intents (connected to DAQWorker by the main window)
center_loop_requested = Signal()
center_at_point_requested = Signal(float, float) # camera image pixel (x, y)
raster_scan_requested = Signal(object) # RasterGridRequest
rotation_scan_requested = Signal(object) # RotationScanRequest
smart_params_requested = Signal(object) # SimpleScanParameters (automate mode)
xrf_scan_requested = Signal(object) # FluorescenceSpectrumParameterModel
raster_goto_requested = Signal(object) # SmargonCoordinate (goto a grid)
status_message = Signal(str) # transient note to surface in UI
def __init__(self, state: AppState, palette: Palette, defaults: dict, parent=None):
super().__init__(parent)
self._state = state
self._p = palette
self._defaults = defaults or {}
self._last_status = None
self._grid_rect_img: tuple[float, float, float, float] | None = None
self._raster_grids: list = []
self._grids_dialog = None
root = QHBoxLayout(self)
root.setContentsMargins(0, 0, 0, 0)
root.setSpacing(0)
# --- left: sample changer ---
self.changer = SampleChangerPanel(palette)
root.addWidget(self.changer)
# --- centre column ---
center = QWidget()
cl = QVBoxLayout(center)
cl.setContentsMargins(0, 0, 0, 0)
cl.setSpacing(0)
self.camera = CameraViewport(palette)
self.spectrum = SpectrumView(palette)
self.center_stack = QStackedWidget()
self.center_stack.addWidget(self.camera) # index 0
self.center_stack.addWidget(self.spectrum) # index 1
cl.addWidget(self._build_camera_tabs())
cl.addWidget(self.center_stack, 1)
# single pipeline panel, usable mounted and not-mounted (pre-set params)
self.pipeline = PipelinePanel(palette, self._defaults)
cl.addWidget(self.pipeline)
root.addWidget(center, 1)
# --- right: motors ---
self.motors = MotorsPanel(palette)
root.addWidget(self.motors)
self._connect()
self._refresh_mount()
self._refresh_pipe()
# ------------------------------------------------------------- build
def _build_camera_tabs(self) -> QWidget:
bar = QWidget()
bar.setObjectName("CameraTabs")
bar.setFixedHeight(CAMERA_TABS_H)
lay = QHBoxLayout(bar)
lay.setContentsMargins(14, 0, 14, 0)
lay.setSpacing(2)
lay.setAlignment(Qt.AlignBottom)
self._tab_group = QButtonGroup(self)
self._tab_group.setExclusive(True)
self._tabs: dict[str, QPushButton] = {}
# (label, center-stack index) — Gonio/Beamline still show the camera (Phase 2)
for i, (name, idx) in enumerate(
(("Sample camera", 0), ("Gonio", 0), ("Beamline", 0), ("XRF spectrum", 1))
):
tab = QPushButton(name)
tab.setObjectName("CamTab")
tab.setCheckable(True)
tab.setCursor(Qt.PointingHandCursor)
if i == 0:
tab.setChecked(True)
tab.clicked.connect(lambda _=False, ix=idx: self.center_stack.setCurrentIndex(ix))
self._tab_group.addButton(tab)
self._tabs[name] = tab
lay.addWidget(tab)
lay.addStretch(1)
return bar
def show_spectrum(self) -> None:
"""Switch the centre view to the XRF spectrum and check its tab."""
self.center_stack.setCurrentIndex(1)
self._tabs["XRF spectrum"].setChecked(True)
# ----------------------------------------------------------- wiring
def _connect(self) -> None:
s = self._state
# state -> view
s.mount_changed.connect(self._refresh_mount)
s.pipe_changed.connect(self._refresh_pipe)
# changer / mount CTAs
self.changer.sample_selected.connect(s.mount_sample_obj)
self.camera.mount_clicked.connect(s.open_picker)
# pipeline interactions
self.pipeline.stage_clicked.connect(s.pick_stage)
self.pipeline.breakpoint_toggled.connect(s.toggle_breakpoint)
# contextual run actions
cs = self.pipeline.settings
cs.run_center.connect(self._on_run_center)
cs.run_raster.connect(self._on_run_raster)
cs.run_xrf.connect(self._on_run_xrf)
cs.run_collect.connect(self._on_run_collect)
cs.raster_draw_toggled.connect(self.camera.set_grid_draw_enabled)
cs.raster_metric_changed.connect(lambda _k: self._render_heatmap())
cs.raster_alpha_changed.connect(self.camera.set_heatmap_alpha)
cs.raster_grids_clicked.connect(self._open_grids_dialog)
self.camera.grid_drawn.connect(self._on_grid_drawn)
self.camera.clicked_point.connect(self._on_camera_click)
# transport
t = self.pipeline.transport
t.play.connect(s.play)
t.pause.connect(s.pause)
t.skip.connect(s.skip)
t.end.connect(s.end)
t.unmount.connect(s.unmount)
# ------------------------------------------------------- run handlers
def _on_run_center(self, method: str) -> None:
# Only the automatic loop-centering method remains a button; click-to-
# centre is implicit (clicking the camera).
self._state.run_now("center")
self.center_loop_requested.emit()
def _on_camera_click(self, img_x: float, img_y: float) -> None:
self.center_at_point_requested.emit(img_x, img_y)
def _on_run_raster(self) -> None:
if self._grid_rect_img is None:
self.status_message.emit(
"Draw a grid on the camera first (✏ Draw grid), then Run raster."
)
return
cs = self.pipeline.settings
req = requests.build_raster_request(
cell_mm=cs.raster_cell_mm(),
transmission=cs.raster_transmission(),
grid_rect_img=self._grid_rect_img,
status=self._last_status,
exp_time_s=float(self._defaults.get("raster_exp_time_s",
self._defaults.get("exp_time_s", 0.02))),
dtz=self._defaults.get("raster_dtz", self._defaults.get("dtz")),
)
self._state.run_now("raster")
self.raster_scan_requested.emit(req)
def _on_run_xrf(self) -> None:
cs = self.pipeline.settings
acq, trans = cs.xrf_params()
req = requests.build_xrf_request(acq, trans)
self._state.run_now("xrf")
self.show_spectrum()
self.xrf_scan_requested.emit(req)
def _on_run_collect(self) -> None:
cs = self.pipeline.settings
start_omega = 0.0
geom = getattr(self._last_status, "geom", None)
if geom is not None and getattr(geom, "omega_deg", None) is not None:
start_omega = geom.omega_deg
self._state.run_now("collect")
if cs.collect_mode() == "screening":
req = requests.build_screening_request(
preset=cs.screening_params()["preset"],
image_angle_deg=cs.screening_params()["image_angle"],
count_time_s=cs.screening_params()["count_time"],
dtz=cs.collect_dtz(),
transmission=cs.collect_transmission(),
start_omega_deg=start_omega,
)
self.rotation_scan_requested.emit(req)
return
if cs.is_automated():
# Simple/automate strategy: forward seeds to the server, which refines.
sp = requests.build_simple_params(
self._defaults, cs.collect_transmission(), start_omega)
self.smart_params_requested.emit(sp)
return
req = requests.build_rotation_request(
collect_params=cs.collect_params(),
status=self._last_status,
exp_time_s=float(self._defaults.get("rotation_exp_time_s",
self._defaults.get("exp_time_s", 0.02))),
start_omega_deg=start_omega,
)
self.rotation_scan_requested.emit(req)
def _on_grid_drawn(self, x: float, y: float, w: float, h: float) -> None:
self._grid_rect_img = (x, y, w, h)
self.camera.set_heatmap(None) # clear any prior result heatmap
nx, ny = requests.grid_cells(
self.pipeline.settings.raster_cell_mm(), self._grid_rect_img,
self._last_status,
)
self.camera.set_grid_cells(nx, ny)
self.pipeline.settings.set_draw_active(False)
self.camera.set_grid_draw_enabled(False)
# ----------------------------------------------------- raster results
def on_raster_completed(self, completed) -> None:
self._raster_grids = list(getattr(completed, "r", []) or [])
self.pipeline.settings.set_raster_results_count(len(self._raster_grids))
self._render_heatmap()
def _render_heatmap(self) -> None:
if not self._raster_grids:
self.camera.set_heatmap(None)
return
elem = self._raster_grids[-1]
req = getattr(elem, "request", None)
result = getattr(elem, "result", None)
images = getattr(result, "images", None)
if req is None or not images:
return
key = self.pipeline.settings.raster_metric()
values = [metric_value(img, key) for img in images]
image = build_heatmap_qimage(values, req.n_x, req.n_y)
if image is None:
return
rect = self._grid_rect_for(req)
if rect is not None:
self.camera.set_grid_rect(*rect)
self.camera.set_heatmap(image)
def _grid_rect_for(self, req):
"""Pixel rect for a completed grid: prefer the drawn rect, else derive."""
if self._grid_rect_img is not None:
return self._grid_rect_img
geom = getattr(self._last_status, "geom", None)
if geom is None or not hasattr(geom, "sample_to_picture"):
return None
try:
tl = geom.sample_to_picture(req.start_pxl(geom))
cell = req.grid_size_pxl(geom)
return (tl.x, tl.y, cell.x * req.n_x, cell.y * req.n_y)
except Exception:
return None
def _open_grids_dialog(self) -> None:
self._grids_dialog = RasterGridsDialog(self._raster_grids, self._p, self)
self._grids_dialog.goto_grid.connect(self._goto_grid)
self._grids_dialog.show()
def _goto_grid(self, index: int) -> None:
if 0 <= index < len(self._raster_grids):
req = getattr(self._raster_grids[index], "request", None)
stl = getattr(req, "smargon_top_left", None)
if stl is not None and getattr(stl, "sh_mm", None) is not None:
self.raster_goto_requested.emit(stl)
else:
self.status_message.emit("Grid has no stored position to go to.")
# ------------------------------------------------------- refreshers
def _refresh_mount(self) -> None:
phase = self._state.mount_phase
# Sample changer is always visible in Manual mode so the available
# samples are shown on first open; the "pick to mount" hint appears
# whenever nothing is mounted.
self.changer.setVisible(True)
self.changer.set_picking(phase != "mounted")
sample = self._state.mount_sample
self.changer.set_mounted_id(sample_id(sample) if sample is not None else None)
self.camera.set_mode(phase)
self.pipeline.set_mounted(phase == "mounted")
if phase == "mounted" and sample is not None:
params = getattr(sample, "aaredb_params", None)
if params is not None:
self.pipeline.settings.set_db_params(params)
if phase != "mounted":
self.camera.clear_grid()
self._grid_rect_img = None
self.pipeline.update_from_state(self._state)
def _refresh_pipe(self) -> None:
self.pipeline.update_from_state(self._state)
# enable grid-draw affordance only while the raster stage is selected
if self._state.stage != "raster":
self.pipeline.settings.set_draw_active(False)
self.camera.set_grid_draw_enabled(False)
# ----------------------------------------------------- external setters
def set_staff(self, is_staff: bool) -> None:
self.changer.set_staff(is_staff)
self.motors.set_staff(is_staff)
def set_reference_tools(self, tools) -> None:
self.changer.set_reference_tools(tools)
def set_control_state(self, in_control: bool, title: str = "GUEST MODE",
sub: str = "") -> None:
self.camera.set_control_state(in_control, title, sub)
# ------------------------------------------------------- live status
@property
def last_status(self):
return self._last_status
def update_daq_status(self, s) -> None:
self._last_status = s
self.motors.update_daq_status(s)
bl = getattr(s, "bl", None)
if bl is not None and getattr(bl, "zoom", None) is not None:
self.camera.set_zoom(bl.zoom)
+198
View File
@@ -0,0 +1,198 @@
"""Builders that turn GUI parameters + live status into backend request models.
Centralising model construction here keeps the views free of backend imports and
gives one place to adjust defaults/units. Each builder returns the pydantic model
the matching DAQWorker slot expects.
NOTE (raster): the drawn grid gives us n_x / n_y / grid_size_mm and omega, but
the absolute ``smargon_top_left`` requires mapping camera pixels -> smargon mm
via the beam centre, which is beamline-calibration-sensitive. Until that mapping
is confirmed we leave ``smargon_top_left=None`` (the auto-centre path the backend
already supports via ``raster_scan_auto``). See new-gui-redesign memory.
"""
from __future__ import annotations
import math
from aare.common.coordinate import Coordinate, SmargonCoordinate
from aare.common.models import (
FluorescenceSpectrumParameterModel,
SimpleScanParameters,
)
from aare.common.raster_grid import RasterGridRequest
from aare.common.rotation_scan import RotationScanRequest
def _pixel_in_mm(status) -> float | None:
geom = getattr(status, "geom", None)
val = getattr(geom, "pixel_in_mm", None) if geom else None
return val if val else None
def _smargon_top_left(status, top_left_px: tuple[float, float]
) -> SmargonCoordinate | None:
"""Convert the grid's top-left camera pixel to a SmargonCoordinate.
Mirrors RasterGridManager.update_active_grid: picture -> beamline (sample)
-> smargon, carrying the current phi/chi. Returns None (auto-centre) if the
geometry transforms aren't available.
"""
geom = getattr(status, "geom", None)
if geom is None or not hasattr(geom, "picture_to_sample"):
return None
try:
x, y = top_left_px
sample = geom.picture_to_sample(Coordinate(x=x, y=y))
sh = geom.beamline_to_smargon(sample)
return SmargonCoordinate(
sh_mm=sh,
phi_deg=getattr(geom.smargon, "phi_deg", None),
chi_deg=getattr(geom.smargon, "chi_deg", None),
)
except Exception:
return None
def build_raster_request(
cell_mm: tuple[float, float],
transmission: float,
grid_rect_img: tuple[float, float, float, float],
status,
exp_time_s: float,
dtz: float | None,
auto: bool = False,
) -> RasterGridRequest:
"""grid_rect_img = (x, y, w, h) in image pixels (from the drawn grid).
``auto=True`` leaves smargon_top_left=None (backend auto-centre). Otherwise
the top-left is computed from the drawn pixel via the live geometry.
"""
# NB: grid_size_mm is the size of ONE grid element (cell); the number of
# cells (n_x/n_y) is the drawn extent divided by the cell size.
cx_mm, cy_mm = cell_mm
x0, y0, w_px, h_px = grid_rect_img
px_mm = _pixel_in_mm(status) or 0.0
extent_w_mm = w_px * px_mm if px_mm else cx_mm
extent_h_mm = h_px * px_mm if px_mm else cy_mm
n_x = max(1, round(extent_w_mm / cx_mm)) if cx_mm > 0 else 1
n_y = max(1, round(extent_h_mm / cy_mm)) if cy_mm > 0 else 1
omega = getattr(getattr(status, "geom", None), "omega_deg", 0.0) or 0.0
top_left = None if auto else _smargon_top_left(status, (x0, y0))
return RasterGridRequest(
exp_time_s=exp_time_s,
n_x=int(n_x),
n_y=int(n_y),
grid_size_mm=Coordinate(x=max(cx_mm, 1e-4), y=max(cy_mm, 1e-4), z=0.0),
smargon_top_left=top_left,
omega_deg=float(omega),
dtz=dtz,
transmission=transmission,
visible=True,
)
def grid_cells(cell_mm: tuple[float, float],
grid_rect_img: tuple[float, float, float, float],
status) -> tuple[int, int]:
"""Compute (n_x, n_y) for the camera overlay from a drawn rectangle."""
cx_mm, cy_mm = cell_mm
_, _, w_px, h_px = grid_rect_img
px_mm = _pixel_in_mm(status)
if not px_mm or cx_mm <= 0 or cy_mm <= 0:
return 0, 0
n_x = max(1, math.floor((w_px * px_mm) / cx_mm))
n_y = max(1, math.floor((h_px * px_mm) / cy_mm))
return n_x, n_y
def build_rotation_request(
collect_params: dict,
status,
exp_time_s: float,
start_omega_deg: float,
file_prefix: str | None = None,
) -> RotationScanRequest:
return RotationScanRequest(
exp_time_s=exp_time_s,
start_omega_deg=float(start_omega_deg),
incr_omega_deg=float(collect_params["incr_omega_deg"]),
steps=int(collect_params["steps"]),
dtz=collect_params.get("dtz"),
transmission=collect_params.get("transmission"),
start=None,
end=None,
screening=False,
file_prefix=file_prefix,
)
def build_xrf_request(acq_time_s: float, transmission: float
) -> FluorescenceSpectrumParameterModel:
return FluorescenceSpectrumParameterModel(
acq_time_s=float(acq_time_s),
transmission=float(transmission),
erase=True,
)
def build_simple_params(defaults: dict, transmission: float,
start_omega_deg: float = 0.0) -> SimpleScanParameters:
"""Seed SimpleScanParameters for the automate-parameters (simple) strategy.
The server refines these into the final strategy; we forward sensible seeds
from the beamline YAML defaults so it has flux/dose context to work with.
"""
return SimpleScanParameters(
dtz=float(defaults.get("dtz", 150.0)),
exp_time_s=float(defaults.get("rotation_exp_time_s",
defaults.get("exp_time_s", 0.02))),
start_omega_deg=float(start_omega_deg),
incr_omega_deg=float(defaults.get("increment_omega_deg", 0.2)),
steps=int(defaults.get("steps", 1800)),
transmission=float(transmission),
)
def build_screening_request(
preset: dict, image_angle_deg: float, count_time_s: float,
dtz: float | None, transmission: float, start_omega_deg: float = 0.0,
file_prefix: str | None = None,
) -> RotationScanRequest:
"""Screening preset -> RotationScanRequest.
preset = {"steps": int, "omega_step_deg": float}. A single image (steps==1,
step==0) is a plain 1-image rotation; multi-image sets wedge_omega_deg to the
per-image span and incr_omega_deg to the inter-image step (matches the old GUI).
"""
steps = int(preset.get("steps", 1))
step = float(preset.get("omega_step_deg", 0))
if steps <= 1 and step == 0:
return RotationScanRequest(
exp_time_s=count_time_s, start_omega_deg=start_omega_deg,
incr_omega_deg=image_angle_deg, steps=1, screening=False,
dtz=dtz, transmission=transmission, file_prefix=file_prefix,
)
return RotationScanRequest(
exp_time_s=count_time_s, start_omega_deg=start_omega_deg,
wedge_omega_deg=image_angle_deg, incr_omega_deg=step, steps=steps,
screening=True, dtz=dtz, transmission=transmission, file_prefix=file_prefix,
)
def rotation_from_simple(sp: SimpleScanParameters) -> RotationScanRequest:
"""Build the actual RotationScanRequest the server runs for the simple path."""
return RotationScanRequest(
exp_time_s=sp.exp_time_s,
start_omega_deg=sp.start_omega_deg,
incr_omega_deg=sp.incr_omega_deg,
steps=sp.steps,
dtz=sp.dtz,
transmission=sp.transmission,
start=None,
end=None,
screening=False,
)
+282
View File
@@ -0,0 +1,282 @@
"""Window-level view-model for the redesigned AareGUI.
This is a direct port of the state machine described in the design handoff
("State Management" section). It holds *only* UI state and emits change signals
so views can refresh, plus high-level *intent* signals (``*_requested``) that the
main window connects to the real :class:`DAQWorker`. Keeping backend calls out of
this object makes the state machine unit-testable in isolation.
State shape (mirrors the prototype):
- ``mode`` : 'manual' | 'automation'
- ``mount.phase`` : 'empty' | 'picking' | 'mounted'
- ``mount.sample`` : the selected sample object (SampleShortInfo) or None
- ``pipe`` : {stage, breaks: {stage: bool}, running: bool, seq_idx}
- ``qb`` : {queue: [{sample, protocol}], defaults: {...}}
"""
from __future__ import annotations
from dataclasses import dataclass, field
from PySide6.QtCore import QObject, Signal
# Interactive pipeline stages, in order. ``optional`` drives the dashed-node
# styling; ``interactive`` marks the stages the operator can select/run.
@dataclass(frozen=True)
class Stage:
key: str
label: str # short label under the node
action_label: str # human label used by "NEXT ACTION"
interactive: bool = False
optional: bool = False
STAGES: tuple[Stage, ...] = (
Stage("mount", "Mount", "Mount"),
Stage("center", "Center", "Center", interactive=True),
Stage("raster", "Raster", "Raster scan", interactive=True, optional=True),
Stage("xrf", "XRF", "XRF spectrum", interactive=True, optional=True),
Stage("collect", "Collect", "Collect (Rotation)", interactive=True),
Stage("process", "Process", "Process"),
Stage("unmount", "Unmount", "Unmount"),
)
# Stages that make up an automation "protocol" (queue builder chips).
PROTOCOL_KEYS = ("center", "raster", "xrf", "collect")
def default_protocol() -> dict[str, bool]:
"""The prototype's initial default protocol for new queue adds."""
return {"center": True, "raster": False, "xrf": False, "collect": True}
@dataclass
class QueueItem:
"""One queued sample with its per-sample protocol."""
sample: object # SampleShortInfo (or test stub)
protocol: dict[str, bool] = field(default_factory=default_protocol)
@property
def sample_id(self):
return getattr(self.sample, "id", id(self.sample))
class AppState(QObject):
"""Single source of truth for window-level UI state."""
# --- view-refresh signals ---
mode_changed = Signal(str) # 'manual' | 'automation'
mount_changed = Signal() # phase and/or sample changed
pipe_changed = Signal() # any pipeline field changed
queue_changed = Signal() # queue or defaults changed
# --- backend intent signals (wired to DAQWorker by the main window) ---
mount_requested = Signal(object, bool) # SampleShortInfo, reference
unmount_requested = Signal()
center_requested = Signal()
raster_requested = Signal()
xrf_requested = Signal()
collect_requested = Signal()
automation_start_requested = Signal(list) # list[QueueItem]
def __init__(self, parent=None):
super().__init__(parent)
self._mode = "manual"
self._mount_phase = "empty"
self._mount_sample: object | None = None
self._stage = "center"
self._breaks: dict[str, bool] = {}
self._running = False
self._seq_idx = 0
self._queue: list[QueueItem] = []
self._defaults = default_protocol()
# ------------------------------------------------------------------ mode
@property
def mode(self) -> str:
return self._mode
def set_mode(self, mode: str) -> None:
if mode not in ("manual", "automation") or mode == self._mode:
return
self._mode = mode
self.mode_changed.emit(mode)
# ----------------------------------------------------------------- mount
@property
def mount_phase(self) -> str:
return self._mount_phase
@property
def mount_sample(self):
return self._mount_sample
def open_picker(self) -> None:
"""empty -> picking: reveal the sample changer."""
if self._mount_phase == "mounted":
return
self._mount_phase = "picking"
self.mount_changed.emit()
def mount_sample_obj(self, sample, reference: bool = False) -> None:
"""Select a sample to mount: store it, reset the pipeline, request mount."""
self._mount_sample = sample
self._mount_phase = "mounted"
self._reset_pipe()
self.mount_changed.emit()
self.pipe_changed.emit()
self.mount_requested.emit(sample, reference)
def unmount(self) -> None:
"""mounted -> empty: clear sample and request unmount."""
self._mount_sample = None
self._mount_phase = "empty"
self.mount_changed.emit()
self.unmount_requested.emit()
# -------------------------------------------------------------- pipeline
@property
def stage(self) -> str:
return self._stage
@property
def running(self) -> bool:
return self._running
@property
def seq_idx(self) -> int:
return self._seq_idx
def has_breakpoint(self, key: str) -> bool:
return bool(self._breaks.get(key))
def _reset_pipe(self) -> None:
self._stage = "center"
self._breaks = {}
self._running = False
self._seq_idx = 0
def pick_stage(self, key: str) -> None:
if self._stage != key:
self._stage = key
self.pipe_changed.emit()
def toggle_breakpoint(self, key: str) -> None:
self._breaks[key] = not self._breaks.get(key)
self.pipe_changed.emit()
def _stage_index(self, key: str) -> int:
for i, s in enumerate(STAGES):
if s.key == key:
return i
return 0
def run_now(self, key: str) -> None:
"""Set the sequence position to ``key``, start running, fire the intent."""
self._seq_idx = self._stage_index(key)
self._stage = key
self._running = True
self.pipe_changed.emit()
self._emit_stage_intent(key)
def _emit_stage_intent(self, key: str) -> None:
{
"center": self.center_requested,
"raster": self.raster_requested,
"xrf": self.xrf_requested,
"collect": self.collect_requested,
}.get(key, _NullSignal()).emit()
# transport
def play(self) -> None:
self._running = True
self.pipe_changed.emit()
# Kick off whatever stage the sequence is currently pointing at.
self._emit_stage_intent(STAGES[min(self._seq_idx, len(STAGES) - 1)].key)
def pause(self) -> None:
self._running = False
self.pipe_changed.emit()
def skip(self) -> None:
self._seq_idx = min(self._seq_idx + 1, len(STAGES) - 1)
self.pipe_changed.emit()
def end(self) -> None:
self._running = False
self._seq_idx = 0
self._stage = "center"
self.pipe_changed.emit()
def next_stage(self) -> Stage:
return STAGES[min(self._seq_idx, len(STAGES) - 1)]
def stopped_at_breakpoint(self) -> bool:
return self._running and self.has_breakpoint(self.next_stage().key)
# ------------------------------------------------------------ queue (qb)
@property
def queue(self) -> list[QueueItem]:
return self._queue
@property
def defaults(self) -> dict[str, bool]:
return self._defaults
def _is_queued(self, sample) -> bool:
sid = getattr(sample, "id", id(sample))
return any(q.sample_id == sid for q in self._queue)
def queue_add(self, sample) -> None:
if self._is_queued(sample):
return
self._queue.append(QueueItem(sample, dict(self._defaults)))
self.queue_changed.emit()
def queue_add_many(self, samples) -> None:
added = False
for s in samples:
if not self._is_queued(s):
self._queue.append(QueueItem(s, dict(self._defaults)))
added = True
if added:
self.queue_changed.emit()
def queue_remove(self, index: int) -> None:
if 0 <= index < len(self._queue):
del self._queue[index]
self.queue_changed.emit()
def queue_move(self, index: int, direction: int) -> None:
j = index + direction
if 0 <= index < len(self._queue) and 0 <= j < len(self._queue):
self._queue[index], self._queue[j] = self._queue[j], self._queue[index]
self.queue_changed.emit()
def queue_clear(self) -> None:
if self._queue:
self._queue = []
self.queue_changed.emit()
def toggle_row_protocol(self, index: int, key: str) -> None:
if 0 <= index < len(self._queue):
p = self._queue[index].protocol
p[key] = not p.get(key)
self.queue_changed.emit()
def toggle_default(self, key: str) -> None:
self._defaults[key] = not self._defaults.get(key)
self.queue_changed.emit()
def start_automation(self) -> None:
if self._queue:
self.automation_start_requested.emit(list(self._queue))
class _NullSignal:
"""Fallback for stage keys with no intent (e.g. mount/process)."""
def emit(self, *args) -> None: # noqa: D401 - no-op
pass
+186
View File
@@ -0,0 +1,186 @@
"""The bottom status bar: live beamline readouts from DAQStatusModel."""
from __future__ import annotations
from PySide6.QtCore import Qt, Signal, Slot
from PySide6.QtGui import QAction
from PySide6.QtWidgets import QHBoxLayout, QLabel, QMenu, QWidget
from aare.gui.new_gui.theme import STATUSBAR_H, Palette
from aare.gui.new_gui.widgets.common import ClickableLabel
# (display label, DAQWorker method key) for the clickable state LED menu.
STATE_OPTIONS: tuple[tuple[str, str], ...] = (
("Dewar transfer", "dewar_exchange"),
("Sample exchange", "sample_exchange"),
("Robot sample exchange", "robot_sample_exchange"),
("Sample alignment", "sample_alignment"),
("Beam location", "beam_location"),
("Beamstop alignment", "beamstop_alignment"),
("Flux measurement", "flux_measurement"),
("Data collection", "data_collection"),
("Crystal snapshot", "xtal_snapshot"),
("X-ray fluorescence", "xray_fluorescence"),
)
# LED colour by BeamlineStateEnum name (group colours from the old GUI).
_STATE_COLORS: dict[str, str] = {
"DewarTransfer": "#805ad5",
"SampleExchange": "#ed8936", "RobotSampleExchange": "#ed8936",
"SampleAlignment": "#48bb78", "BeamLocation": "#48bb78",
"BeamstopAlignment": "#48bb78", "FluxMeasurement": "#48bb78",
"DataCollection": "#ec4899", "XtalSnapshot": "#ec4899",
"XrayFluorescence": "#ec4899",
}
class StatusBar(QWidget):
"""Mono readouts: Flux · Ring · λ · Cryo · Shutter · ... · State LED · SC.
Values are read straight off the status model using the documented paths
(see new-gui-backend-paths). The State item is a clickable LED that opens a
beamline-state transition menu.
"""
state_change_requested = Signal(str) # DAQWorker method key
def __init__(self, palette: Palette, parent=None):
super().__init__(parent)
self.setObjectName("StatusBar")
self.setFixedHeight(STATUSBAR_H)
self._p = palette
self._busy = False
lay = QHBoxLayout(self)
lay.setContentsMargins(16, 0, 16, 0)
lay.setSpacing(18)
self._flux = self._item(lay)
self._ring = self._item(lay)
self._lam = self._item(lay)
self._cryo = self._item(lay)
self._shutter = self._item(lay)
lay.addStretch(1)
# clickable State LED
self._state_dot = QLabel()
self._state_dot.setFixedSize(8, 8)
self._set_state_dot("#a49d8f")
self._state = ClickableLabel()
self._state.setObjectName("StatusItem")
self._state.setTextFormat(Qt.RichText)
self._state.setToolTip("Click to switch beamline state")
self._state.clicked.connect(self._show_state_menu)
lay.addWidget(self._state_dot)
lay.addWidget(self._state)
self._sc = self._item(lay)
# initial placeholders
self._render(self._flux, "Flux", "")
self._render(self._ring, "Ring", "")
self._render(self._lam, "λ", "")
self._render(self._cryo, "Cryo", "")
self._render(self._shutter, "Shutter", "")
self._render(self._state, "State", "")
self._render(self._sc, "SC", "")
def _set_state_dot(self, color: str) -> None:
self._state_dot.setStyleSheet(f"background:{color}; border-radius:4px;")
def _show_state_menu(self) -> None:
menu = QMenu(self)
title = QAction("Switch beamline state", menu)
title.setEnabled(False)
menu.addAction(title)
menu.addSeparator()
if self._busy:
busy = QAction("Beamline busy — transitions disabled", menu)
busy.setEnabled(False)
menu.addAction(busy)
else:
for label, key in STATE_OPTIONS:
act = QAction(label, menu)
act.triggered.connect(
lambda _=False, k=key: self.state_change_requested.emit(k)
)
menu.addAction(act)
menu.exec(self._state.mapToGlobal(self._state.rect().bottomLeft()))
def _item(self, lay: QHBoxLayout) -> QLabel:
lbl = QLabel()
lbl.setObjectName("StatusItem")
lbl.setTextFormat(Qt.RichText)
lay.addWidget(lbl)
return lbl
def _render(self, lbl: QLabel, name: str, value: str,
value_color: str | None = None) -> None:
col = value_color or self._p.text_primary
lbl.setText(
f"<span style='color:{self._p.text_muted};'>{name} </span>"
f"<span style='color:{col};'>{value}</span>"
)
@Slot(object)
def update_daq_status(self, s) -> None:
p = self._p
bl = getattr(s, "bl", None)
# Flux (photons/s) -> scientific-ish
flux = getattr(bl, "flux_ph_s", None)
if flux is not None:
self._render(self._flux, "Flux", self._fmt_flux(flux))
# Ring current
ring = getattr(bl, "ring_current_mA", None)
if ring is not None:
color = p.danger if ring < 5 else (p.breakpoint if ring < 390 else p.success)
self._render(self._ring, "Ring", f"{ring:.0f} mA", color)
# Wavelength
diff = getattr(s, "diffraction", None)
lam = getattr(diff, "wavelength_angstrom", None)
if lam is not None:
self._render(self._lam, "λ", f"{lam:.3f} Å")
# Cryojet temperature
cryo = getattr(bl, "cryojet_K", None)
if cryo is not None:
color = p.accent if cryo < 110 else (p.breakpoint if cryo < 250 else p.danger)
self._render(self._cryo, "Cryo", f"{cryo:.1f} K", color)
# Shutter
shutter = getattr(bl, "shutter_open", None)
if shutter is not None:
txt = "Open" if shutter else "Closed"
color = p.success if shutter else p.text_muted
self._render(self._shutter, "Shutter", txt, color)
# Busy flag gates state transitions
self._busy = bool(getattr(s, "busy", False))
# Beamline state (clickable LED)
state = getattr(s, "state", None)
if state is not None:
label = state.display_name() if hasattr(state, "display_name") else str(state)
name = getattr(state, "name", None)
self._set_state_dot(_STATE_COLORS.get(name, p.text_faint))
self._render(self._state, "State", label, p.text_primary)
# Sample changer (TELL)
tell_ok = getattr(s, "tell_connected", None)
if tell_ok is not None:
txt = "Ready" if tell_ok else "Offline"
color = p.success if tell_ok else p.danger
self._render(self._sc, "SC", txt, color)
@staticmethod
def _fmt_flux(flux: float) -> str:
if flux <= 0:
return "0"
from math import floor, log10
exp = int(floor(log10(flux)))
mant = flux / (10 ** exp)
sup = str(exp).translate(str.maketrans("0123456789-", "⁰¹²³⁴⁵⁶⁷⁸⁹⁻"))
return f"{mant:.1f}×10{sup}"
+268
View File
@@ -0,0 +1,268 @@
"""Design tokens and Qt Style Sheet for the redesigned AareGUI.
This is the single source of truth for colour, typography, spacing and radii,
mirroring the "Design Tokens" table in the design handoff README. Widgets should
read values from :data:`LIGHT` / :data:`DARK` (or use the generated QSS) rather
than hard-coding hex strings.
"""
from __future__ import annotations
from dataclasses import dataclass
@dataclass(frozen=True)
class Palette:
"""A single colour theme. Light and dark are 1:1 (see README)."""
# Surfaces
app_bg: str
panel_bg: str
header_bg: str
surface: str # cards / white
# Borders
border_panel: str
border_control: str
pending_track: str
pending_node: str
chip_off_bg: str
# Text
text_primary: str
text_secondary: str
text_muted: str
text_faint: str # labels
# Accent (purple)
accent: str
accent_text: str # text drawn on top of the accent fill
accent_tint_bg: str
accent_tint_border: str
accent_ring: str
# Semantic
success: str
success_tint: str
breakpoint: str
danger: str
# Camera viewport gradient stops (radial, 75% 80%)
cam_stop0: str
cam_stop1: str
cam_stop2: str
LIGHT = Palette(
app_bg="#f3f1ea",
panel_bg="#fbfaf6",
header_bg="#f7f5ef",
surface="#ffffff",
border_panel="#e5e1d7",
border_control="#ddd8cc",
pending_track="#e1dccf",
pending_node="#c9c2b2",
chip_off_bg="#efece3",
text_primary="#221f19",
text_secondary="#4d473d",
text_muted="#7a756a",
text_faint="#a49d8f",
accent="#6d4bd1",
accent_text="#ffffff",
accent_tint_bg="#f0e9fb",
accent_tint_border="#cabaf2",
accent_ring="rgba(109,75,209,.28)",
success="#1a7f37",
success_tint="#e3f2e8",
breakpoint="#c2641f",
danger="#cf222e",
cam_stop0="#2b2622",
cam_stop1="#141312",
cam_stop2="#0a0a0b",
)
DARK = Palette(
app_bg="#15121c",
panel_bg="#1e1a28",
header_bg="#1e1a28",
surface="#241f30",
border_panel="#332c44",
border_control="#332c44",
pending_track="#332c44",
pending_node="#4a4159",
chip_off_bg="#2a2438",
text_primary="#ece8f4",
text_secondary="#cfc8db",
text_muted="#9c95ad",
text_faint="#807890",
accent="#9b7bff",
accent_text="#190f2e",
accent_tint_bg="#2c2440",
accent_tint_border="#473a63",
accent_ring="rgba(155,123,255,.32)",
success="#3fb950",
success_tint="#16331f",
breakpoint="#e0975a",
danger="#f0837d",
cam_stop0="#2b2622",
cam_stop1="#141312",
cam_stop2="#0a0a0b",
)
# --- Typography -----------------------------------------------------------
FONT_SANS = "IBM Plex Sans"
FONT_MONO = "IBM Plex Mono"
# Fallbacks used when the bundled TTFs are unavailable.
FONT_SANS_FALLBACK = f'"{FONT_SANS}", "Segoe UI", system-ui, sans-serif'
FONT_MONO_FALLBACK = f'"{FONT_MONO}", "Consolas", "DejaVu Sans Mono", monospace'
# --- Fixed dimensions from the spec --------------------------------------
TOPBAR_H = 54
STATUSBAR_H = 30
CAMERA_TABS_H = 38
START_BAR_H = 80
CHANGER_W = 340
MOTORS_W = 286
LIBRARY_W = 288
def build_qss(p: Palette) -> str:
"""Return a global Qt Style Sheet for the given palette.
Object-name / property selectors keep per-widget styling out of the widget
code. Widgets set ``objectName`` or a dynamic ``role`` property to opt in.
"""
return f"""
QWidget {{
font-family: {FONT_SANS_FALLBACK};
font-size: 13px;
color: {p.text_primary};
}}
QWidget#AppRoot {{ background: {p.app_bg}; }}
/* ---- Top bar ---- */
QWidget#TopBar {{
background: {p.surface};
border-bottom: 1px solid {p.border_panel};
}}
QLabel#LogoTile {{
background: {p.accent};
color: {p.accent_text};
border-radius: 7px;
font-weight: 700;
font-size: 14px;
}}
QLabel#AppName {{ font-weight: 700; font-size: 15px; }}
QLabel#BeamlineId {{
font-family: {FONT_MONO_FALLBACK};
font-size: 11px;
color: {p.text_muted};
}}
QLabel#Avatar {{
background: {p.chip_off_bg};
color: {p.text_secondary};
border-radius: 17px;
font-size: 12px;
}}
/* ---- Mode toggle (segmented) ---- */
QWidget#ModeToggle {{ background: {p.chip_off_bg}; border-radius: 9px; }}
QPushButton#ModeSeg {{
border: none;
background: transparent;
color: {p.text_muted};
font-weight: 500;
padding: 6px 16px;
border-radius: 6px;
}}
QPushButton#ModeSeg:checked {{
background: {p.accent};
color: {p.accent_text};
font-weight: 600;
}}
/* ---- Baton chip ---- */
QWidget#BatonChip {{
background: {p.accent_tint_bg};
border: 1px solid {p.accent_tint_border};
border-radius: 20px;
}}
QLabel#BatonText {{ font-size: 12px; color: {p.text_primary}; }}
QLabel#BatonId {{
font-family: {FONT_MONO_FALLBACK};
font-size: 11px; color: {p.text_muted};
}}
/* ---- Status bar ---- */
QWidget#StatusBar {{
background: {p.surface};
border-top: 1px solid {p.border_panel};
}}
QLabel#StatusItem {{
font-family: {FONT_MONO_FALLBACK};
font-size: 11px; color: {p.text_muted};
}}
/* ---- Panels ---- */
QWidget#SidePanel {{ background: {p.panel_bg}; }}
QFrame#Divider {{ background: {p.border_panel}; max-height: 1px; min-height: 1px; border: none; }}
QLabel#SectionLabel {{
font-size: 12px; font-weight: 600; color: {p.text_faint};
letter-spacing: 1px;
}}
QLabel#MicroLabel {{
font-size: 10px; font-weight: 700; color: {p.text_faint};
letter-spacing: 1px;
}}
/* ---- Search fields / line edits ---- */
QLineEdit {{
background: {p.surface};
border: 1px solid {p.border_panel};
border-radius: 8px;
padding: 8px 12px;
font-size: 12px;
color: {p.text_primary};
}}
QLineEdit::placeholder {{ color: {p.text_faint}; }}
/* ---- Buttons ---- */
QPushButton {{
background: {p.surface};
border: 1px solid {p.border_control};
border-radius: 8px;
padding: 8px 14px;
font-size: 12px;
color: {p.text_secondary};
}}
QPushButton:hover {{ border-color: {p.accent_tint_border}; }}
QPushButton#Primary {{
background: {p.accent};
color: {p.accent_text};
border: none;
font-weight: 600;
border-radius: 10px;
padding: 11px 22px;
}}
QPushButton#Primary:hover {{ background: {p.accent}; }}
QPushButton#Danger {{ color: {p.danger}; }}
/* ---- Camera tab strip ---- */
QWidget#CameraTabs {{ background: {p.app_bg}; border-bottom: 1px solid {p.border_panel}; }}
QPushButton#CamTab {{
background: transparent; border: none;
color: {p.text_muted}; padding: 8px 14px; font-size: 12px;
}}
QPushButton#CamTab:checked {{
background: {p.surface};
border: 1px solid {p.border_panel};
border-bottom: none;
border-top-left-radius: 7px; border-top-right-radius: 7px;
color: {p.text_primary}; font-weight: 600;
}}
/* ---- Bottom / pipeline panel ---- */
QWidget#BottomPanel {{ background: {p.surface}; border-top: 1px solid {p.border_panel}; }}
QWidget#HeaderBlock {{ background: {p.header_bg}; border-bottom: 1px solid {p.border_panel}; }}
/* ---- Scroll areas transparent so panel bg shows through ---- */
QScrollArea {{ background: transparent; border: none; }}
QScrollArea > QWidget > QWidget {{ background: transparent; }}
"""
+208
View File
@@ -0,0 +1,208 @@
"""The shared top bar: logo, mode toggle, baton chip, avatar."""
from __future__ import annotations
from PySide6.QtCore import Qt, Signal
from PySide6.QtWidgets import (
QButtonGroup,
QHBoxLayout,
QLabel,
QPushButton,
QWidget,
)
from aare.gui.new_gui.theme import TOPBAR_H, Palette
class ModeToggle(QWidget):
"""Segmented control: Manual / Automation (exclusive checkable buttons)."""
mode_changed = Signal(str)
def __init__(self, palette: Palette, parent=None):
super().__init__(parent)
self.setObjectName("ModeToggle")
lay = QHBoxLayout(self)
lay.setContentsMargins(3, 3, 3, 3)
lay.setSpacing(0)
self._group = QButtonGroup(self)
self._group.setExclusive(True)
self._buttons: dict[str, QPushButton] = {}
for key, label in (("manual", "Manual"), ("automation", "Automation")):
btn = QPushButton(label)
btn.setObjectName("ModeSeg")
btn.setCheckable(True)
btn.setCursor(Qt.PointingHandCursor)
btn.clicked.connect(lambda _=False, k=key: self.mode_changed.emit(k))
self._group.addButton(btn)
lay.addWidget(btn)
self._buttons[key] = btn
self._buttons["manual"].setChecked(True)
def set_mode(self, mode: str) -> None:
if mode in self._buttons:
self._buttons[mode].setChecked(True)
class TopBar(QWidget):
"""Top chrome strip. ``mode_changed`` is re-exposed from the toggle."""
mode_changed = Signal(str)
request_baton = Signal()
release_baton = Signal()
cancel_baton_request = Signal()
def __init__(self, palette: Palette, beamline_label: str, parent=None):
super().__init__(parent)
self.setObjectName("TopBar")
self.setFixedHeight(TOPBAR_H)
p = palette
lay = QHBoxLayout(self)
lay.setContentsMargins(16, 0, 16, 0)
lay.setSpacing(16)
# left cluster
left = QHBoxLayout()
left.setSpacing(10)
logo = QLabel("Aa")
logo.setObjectName("LogoTile")
logo.setFixedSize(28, 28)
logo.setAlignment(Qt.AlignCenter)
name = QLabel("AareGUI")
name.setObjectName("AppName")
self._beamline = QLabel(beamline_label)
self._beamline.setObjectName("BeamlineId")
left.addWidget(logo)
left.addWidget(name)
left.addWidget(self._beamline)
lay.addLayout(left)
# mode toggle
self.toggle = ModeToggle(p)
self.toggle.mode_changed.connect(self.mode_changed)
lay.addWidget(self.toggle)
lay.addStretch(1)
# baton chip
self._baton = QWidget()
self._baton.setObjectName("BatonChip")
bl = QHBoxLayout(self._baton)
bl.setContentsMargins(12, 6, 12, 6)
bl.setSpacing(8)
self._dot = QLabel()
self._dot.setFixedSize(8, 8)
self._set_dot(p.success)
self._baton_text = QLabel("You hold the baton")
self._baton_text.setObjectName("BatonText")
self._baton_id = QLabel("")
self._baton_id.setObjectName("BatonId")
bl.addWidget(self._dot)
bl.addWidget(self._baton_text)
bl.addWidget(self._baton_id)
lay.addWidget(self._baton)
# baton action button (Grab / Request / Release / Cancel)
self._baton_btn = QPushButton("Grab")
self._baton_btn.setCursor(Qt.PointingHandCursor)
self._baton_action = "grab"
self._baton_btn.clicked.connect(self._on_baton_btn)
lay.addWidget(self._baton_btn)
# avatar
self._avatar = QLabel("--")
self._avatar.setObjectName("Avatar")
self._avatar.setFixedSize(34, 34)
self._avatar.setAlignment(Qt.AlignCenter)
lay.addWidget(self._avatar)
self._p = palette
self._is_staff = False
self._last_baton = None
self._style_baton_btn()
def _set_dot(self, color: str) -> None:
self._dot.setStyleSheet(f"background:{color}; border-radius:4px;")
def set_mode(self, mode: str) -> None:
self.toggle.set_mode(mode)
def set_staff(self, is_staff: bool) -> None:
if is_staff != self._is_staff:
self._is_staff = is_staff
if self._last_baton is not None:
self.update_baton_status(self._last_baton)
def _on_baton_btn(self) -> None:
{
"grab": self.request_baton,
"request": self.request_baton,
"release": self.release_baton,
"cancel": self.cancel_baton_request,
}.get(self._baton_action, self.request_baton).emit()
def _style_baton_btn(self) -> None:
p = self._p if hasattr(self, "_p") else None
if p is None:
return
primary = self._baton_action in ("grab", "request")
if primary:
css = (f"background:{p.accent}; color:#fff; border:none;")
else:
css = (f"background:{p.surface}; border:1px solid {p.border_control};"
f" color:{p.text_secondary};")
self._baton_btn.setStyleSheet(
f"QPushButton {{ {css} border-radius:8px; padding:6px 14px;"
f" font-size:12px; font-weight:600; }}"
)
def update_baton_status(self, status) -> None:
"""Reflect a BatonStatus with staff-gated holder identity.
Staff see the holder's username; non-staff only see whether *another
user* or *staff* holds control (no identity leak).
"""
p = self._p
self._last_baton = status
holder = getattr(status, "holder", None)
username = getattr(holder, "username", None) if holder else None
holder_is_staff = bool(getattr(holder, "is_staff", False)) if holder else False
you_hold = bool(getattr(status, "you_are_holder", False))
pending = bool(getattr(status, "you_have_pending_request", False))
if you_hold:
self._baton_text.setText("You hold the baton")
self._set_dot(p.success)
self._baton_action = "release"
self._baton_btn.setText("Release")
elif pending:
self._baton_text.setText("Requesting control…")
self._set_dot(p.breakpoint)
self._baton_action = "cancel"
self._baton_btn.setText("Cancel")
elif holder is not None:
if self._is_staff and username:
self._baton_text.setText(f"{username} holds the baton")
elif holder_is_staff:
self._baton_text.setText("Staff has the baton")
else:
self._baton_text.setText("Another user has the baton")
self._set_dot(p.text_faint)
self._baton_action = "request"
self._baton_btn.setText("Request control")
else:
self._baton_text.setText("Beamline vacant")
self._set_dot(p.breakpoint)
self._baton_action = "grab"
self._baton_btn.setText("Grab")
# Holder identity (mono id) is staff-only.
self._baton_id.setText(f"· {username}" if (self._is_staff and username) else "")
self._style_baton_btn()
if self._is_staff and username:
self._avatar.setText(username[:2].upper())
elif you_hold:
self._avatar.setText("ME")
else:
self._avatar.setText("--")
@@ -0,0 +1,68 @@
"""A dismissible top alert banner for errors, warnings and completion notices."""
from __future__ import annotations
from PySide6.QtCore import Qt, QTimer
from PySide6.QtWidgets import QHBoxLayout, QLabel, QPushButton, QWidget
from aare.gui.new_gui.theme import Palette
class AlertBanner(QWidget):
"""A thin coloured strip below the top bar. Hidden when there's no message."""
def __init__(self, palette: Palette, parent=None):
super().__init__(parent)
self._p = palette
self.setAttribute(Qt.WA_StyledBackground, True)
self.setVisible(False)
lay = QHBoxLayout(self)
lay.setContentsMargins(16, 8, 12, 8)
lay.setSpacing(10)
self._icon = QLabel("")
self._text = QLabel("")
self._text.setWordWrap(True)
lay.addWidget(self._icon)
lay.addWidget(self._text, 1)
close = QPushButton("")
close.setCursor(Qt.PointingHandCursor)
close.setFixedSize(22, 22)
close.setStyleSheet(
"QPushButton { border:none; background:transparent;"
f" color:{palette.text_muted}; font-size:13px; }}"
)
close.clicked.connect(self.clear)
lay.addWidget(close)
self._timer = QTimer(self)
self._timer.setSingleShot(True)
self._timer.timeout.connect(self.clear)
def show_message(self, text: str, level: str = "info",
auto_clear_ms: int = 8000) -> None:
"""level: 'info' | 'success' | 'warning' | 'error'."""
p = self._p
styles = {
"info": (p.accent_tint_bg, p.accent_tint_border, p.text_primary, ""),
"success": (p.success_tint, p.success, p.text_primary, ""),
"warning": (p.chip_off_bg, p.breakpoint, p.text_primary, ""),
"error": ("#fbe9e7", p.danger, p.text_primary, ""),
}
bg, border, fg, icon = styles.get(level, styles["info"])
self.setStyleSheet(
f"AlertBanner {{ background:{bg}; border-bottom:1px solid {border}; }}"
)
self._icon.setText(icon)
self._icon.setStyleSheet(f"color:{border}; font-size:14px; font-weight:700;")
self._text.setText(text)
self._text.setStyleSheet(f"color:{fg}; font-size:12.5px;")
self.setVisible(True)
self._timer.stop()
if auto_clear_ms and level not in ("error",):
self._timer.start(auto_clear_ms)
def clear(self) -> None:
self._timer.stop()
self.setVisible(False)
self._text.clear()
@@ -0,0 +1,119 @@
"""Live automation progress panel, driven by the automation_progress SSE stream.
Renders the per-sample workflow steps (Mount / Center / Raster / Collect / Final)
with status icons, plus live metrics (current sample, samples left, avg time,
ETA). Pause/Stop are GUI-side: the controller stops launching the next sample.
"""
from __future__ import annotations
import time
from PySide6.QtCore import Qt
from PySide6.QtWidgets import QLabel, QVBoxLayout, QWidget
from aare.gui.new_gui.theme import Palette
from aare.gui.new_gui.widgets.common import hline, section_label
# WorkflowStateKind value/name -> human label
_STEP_LABELS = {
"MOUNT": "Mount", "mount": "Mount",
"LOOP_CENTRE": "Center", "loop_centre": "Center", "LOOP_CENTER": "Center",
"RASTER": "Raster", "raster": "Raster",
"DATA_COLLECTION": "Collect", "data_collection": "Collect",
"FINAL": "Final", "final": "Final",
}
_STATUS_ICONS = {
"PENDING": "", "RUNNING": "🔵", "SUCCESS": "",
"FAILED": "", "SKIPPED": "⏭️", "PAUSED": "⏸️",
}
def _enum_key(v) -> str:
return getattr(v, "name", None) or getattr(v, "value", None) or str(v)
class AutomationProgressPanel(QWidget):
"""Right-column panel showing live automation progress."""
def __init__(self, palette: Palette, parent=None):
super().__init__(parent)
self.setObjectName("SidePanel")
self.setFixedWidth(300)
self._p = palette
lay = QVBoxLayout(self)
lay.setContentsMargins(16, 14, 16, 14)
lay.setSpacing(10)
lay.addWidget(section_label("RUN PROGRESS"))
self._stats = QLabel("Idle — start the queue to run.")
self._stats.setWordWrap(True)
self._stats.setStyleSheet(f"font-size:12px; color:{palette.text_muted};")
lay.addWidget(self._stats)
lay.addWidget(hline(palette))
self._steps = QLabel("")
self._steps.setWordWrap(True)
self._steps.setTextFormat(Qt.RichText)
self._steps.setStyleSheet("font-size:12.5px;")
lay.addWidget(self._steps)
lay.addStretch(1)
def set_idle(self) -> None:
self._stats.setText("Idle — start the queue to run.")
self._steps.setText("")
def update_progress(self, progress) -> None:
cur = getattr(progress, "current_sample_name", "") or ""
left = getattr(progress, "samples_in_queue", 0)
avg = getattr(progress, "avg_time_per_sample", 0.0) or 0.0
remaining = avg * max(0, left)
lines = [
f"<b>Current sample:</b> {cur}",
f"<b>Samples left:</b> {left}",
f"<b>Avg / sample:</b> {self._fmt(avg)}",
f"<b>Queue remaining:</b> {self._fmt(remaining)}",
]
if remaining > 0:
lines.append(f"<b>Done by:</b> {self._eta(remaining)}")
if getattr(progress, "finished", False):
ok = getattr(progress, "success", None)
lines.append("<b>✅ Finished</b>" if ok else "<b>⛔ Stopped</b>")
self._stats.setText("<br/>".join(lines))
rows = []
for st in getattr(progress, "steps", []) or []:
title = _STEP_LABELS.get(_enum_key(getattr(st, "step", "")),
_enum_key(getattr(st, "step", "")))
icon = _STATUS_ICONS.get(_enum_key(getattr(st, "status", "")), "")
dur = ""
started = getattr(st, "started_at", None)
if started:
end = getattr(st, "completed_at", None) or time.time()
dur = f" ({self._fmt(end - started)})"
msg = getattr(st, "message", "") or ""
extra = f"{msg}" if msg else ""
err = getattr(st, "error_code", None)
if err:
extra += f"<br/><small>Error: {err}</small>"
rows.append(f"{icon} <b>{title}</b>{dur}{extra}")
self._steps.setText("<br/>".join(rows))
@staticmethod
def _fmt(seconds: float) -> str:
seconds = int(seconds)
if seconds < 60:
return f"{seconds}s"
m, s = divmod(seconds, 60)
if m < 60:
return f"{m}m {s}s"
h, m = divmod(m, 60)
return f"{h}h {m}m"
@staticmethod
def _eta(seconds: float) -> str:
# Avoid Date.now-style nondeterminism concerns: use local clock for display.
t = time.localtime(time.time() + seconds)
return time.strftime("%H:%M", t)
@@ -0,0 +1,150 @@
"""Baton request dialogs: incoming (accept/refuse) and outgoing (pending)."""
from __future__ import annotations
from PySide6.QtCore import QTimer, Signal
from PySide6.QtWidgets import (
QDialog,
QHBoxLayout,
QLabel,
QProgressBar,
QPushButton,
QVBoxLayout,
)
from aare.gui.new_gui.theme import Palette
class BatonIncomingDialog(QDialog):
"""Shown when a peer requests control from you. Timeout auto-accepts."""
accepted_signal = Signal()
refused_signal = Signal()
def __init__(self, requester: str, timeout_s: int, palette: Palette, parent=None):
super().__init__(parent)
self._p = palette
self._remaining = max(1, int(timeout_s))
self.setWindowTitle("Control Request")
self.setModal(False)
lay = QVBoxLayout(self)
lay.setContentsMargins(22, 20, 22, 18)
lay.setSpacing(12)
header = QLabel("🔔 Control Request")
header.setStyleSheet("font-size:15px; font-weight:700;")
lay.addWidget(header)
msg = QLabel(f"<b>{requester}</b> is requesting control of the beamline.")
msg.setWordWrap(True)
lay.addWidget(msg)
self._bar = QProgressBar()
self._bar.setRange(0, self._remaining)
self._bar.setValue(self._remaining)
self._bar.setTextVisible(False)
self._bar.setFixedHeight(6)
lay.addWidget(self._bar)
warn = QLabel("⚠️ If you don't respond, control transfers automatically.")
warn.setStyleSheet(f"font-size:11px; color:{palette.text_faint};")
lay.addWidget(warn)
btns = QHBoxLayout()
btns.addStretch(1)
refuse = QPushButton("✗ Refuse")
refuse.setStyleSheet(
f"QPushButton {{ background:{palette.surface};"
f" border:1px solid {palette.border_control}; border-radius:8px;"
f" padding:8px 16px; color:{palette.danger}; font-weight:600; }}"
)
accept = QPushButton("✓ Accept")
accept.setStyleSheet(
f"QPushButton {{ background:{palette.success}; color:#fff; border:none;"
f" border-radius:8px; padding:8px 16px; font-weight:600; }}"
)
refuse.clicked.connect(self._on_refuse)
accept.clicked.connect(self._on_accept)
btns.addWidget(refuse)
btns.addWidget(accept)
lay.addLayout(btns)
self._timer = QTimer(self)
self._timer.setInterval(1000)
self._timer.timeout.connect(self._tick)
self._timer.start()
def _tick(self) -> None:
self._remaining -= 1
self._bar.setValue(max(0, self._remaining))
if self._remaining <= 0:
self._timer.stop()
self._on_accept() # timeout = auto-accept (matches old GUI)
def _on_accept(self) -> None:
self._timer.stop()
self.accepted_signal.emit()
self.accept()
def _on_refuse(self) -> None:
self._timer.stop()
self.refused_signal.emit()
self.reject()
class BatonPendingDialog(QDialog):
"""Shown while waiting for the holder to respond to your request."""
cancelled_signal = Signal()
def __init__(self, holder: str, timeout_s: int, palette: Palette, parent=None):
super().__init__(parent)
self._p = palette
self._total = max(1, int(timeout_s))
self.setWindowTitle("Requesting control")
self.setModal(False)
lay = QVBoxLayout(self)
lay.setContentsMargins(22, 20, 22, 18)
lay.setSpacing(12)
header = QLabel("⏳ Requesting control")
header.setStyleSheet("font-size:15px; font-weight:700;")
lay.addWidget(header)
target = holder or "the current holder"
self._msg = QLabel(f"Waiting for <b>{target}</b> to respond…")
self._msg.setWordWrap(True)
lay.addWidget(self._msg)
self._bar = QProgressBar()
self._bar.setRange(0, self._total)
self._bar.setValue(self._total)
self._bar.setTextVisible(False)
self._bar.setFixedHeight(6)
lay.addWidget(self._bar)
btns = QHBoxLayout()
btns.addStretch(1)
cancel = QPushButton("Cancel request")
cancel.setStyleSheet(
f"QPushButton {{ background:{palette.surface};"
f" border:1px solid {palette.border_control}; border-radius:8px;"
f" padding:8px 16px; color:{palette.text_secondary}; }}"
)
cancel.clicked.connect(self._on_cancel)
btns.addWidget(cancel)
lay.addLayout(btns)
def update_remaining(self, seconds: int) -> None:
self._bar.setValue(max(0, min(self._total, int(seconds))))
def set_queued(self) -> None:
self._msg.setText(
"The beamline is busy. Your request was accepted and the baton will "
"transfer when the current operation completes."
)
self._bar.setRange(0, 0) # indeterminate
def _on_cancel(self) -> None:
self.cancelled_signal.emit()
self.reject()
+450
View File
@@ -0,0 +1,450 @@
"""The central camera viewport with crosshair, overlays and a raster grid-draw tool.
Wires to the live SAMCAM feed via :class:`PredictionSubscriber` (``image`` ->
:meth:`update_pixmap`). When no frame is available it paints the design's dark
radial gradient so the layout still reads. Empty/picking states and the Mount
CTA are real child widgets so their buttons are clickable; the crosshair, target
ring, legend and raster grid are painted.
"""
from __future__ import annotations
from PySide6.QtCore import QPoint, QRect, QRectF, Qt, Signal
from PySide6.QtGui import (
QColor,
QFont,
QPainter,
QPen,
QPixmap,
QRadialGradient,
)
from PySide6.QtWidgets import (
QFrame,
QLabel,
QPushButton,
QVBoxLayout,
QWidget,
)
from aare.gui.new_gui.theme import FONT_MONO_FALLBACK, Palette
class CameraViewport(QWidget):
"""Camera image + overlays. Modes: 'empty' | 'picking' | 'mounted'."""
mount_clicked = Signal()
# Emitted while/after dragging a raster grid. Args are the rectangle in
# IMAGE-PIXEL coordinates (x, y, w, h) so the raster controller can convert
# to mm using the status pixel_in_mm.
grid_drawn = Signal(float, float, float, float)
# Left-click on the live image (not in grid-draw mode) in IMAGE-PIXEL coords —
# drives implicit click-to-centre.
clicked_point = Signal(float, float)
def __init__(self, palette: Palette, parent: QWidget | None = None):
super().__init__(parent)
self._p = palette
self._pixmap: QPixmap | None = None
self._fps: float = 0.0
self._zoom: float = 2.0
self._mode = "empty"
self._target_point: QPoint | None = None # image-pixel coords
# Raster grid-draw state
self._grid_draw_enabled = False
self._grid_rect_img: QRectF | None = None # in image-pixel coords
self._grid_nx = 0
self._grid_ny = 0
self._heatmap = None # QImage or None
self._heatmap_alpha = 180
self._drag_start: QPoint | None = None # widget coords
self._drag_cur: QPoint | None = None
# geometry of the last painted frame (for widget<->image mapping)
self._img_w = 1
self._img_h = 1
self._draw_rect = QRect() # where the frame is drawn in widget space
# control (baton) state — when False the viewport is a read-only guest view
self._in_control = True
self.setMinimumHeight(220)
self.setMouseTracking(True)
self._build_overlays()
self._build_control_overlay()
self.set_mode("empty")
# ------------------------------------------------------------ overlays
def _build_overlays(self) -> None:
p = self._p
# Empty-state modal card
self._empty_card = QFrame(self)
self._empty_card.setStyleSheet(
"QFrame { background: rgba(18,17,16,0.74);"
" border:1px solid rgba(255,255,255,0.14); border-radius:16px; }"
)
ecl = QVBoxLayout(self._empty_card)
ecl.setContentsMargins(34, 30, 34, 30)
ecl.setSpacing(0)
ecl.setAlignment(Qt.AlignHCenter)
tile = QLabel("")
tile.setAlignment(Qt.AlignCenter)
tile.setFixedSize(56, 56)
tile.setStyleSheet(
f"background:{p.accent}; color:#fff; border-radius:14px; font-size:27px;"
)
title = QLabel("No sample mounted")
title.setAlignment(Qt.AlignCenter)
title.setStyleSheet("color:#fff; font-size:17px; font-weight:700;")
body = QLabel(
"The beamline is idle. Mount a sample from the changer to begin the "
"Center → Collect pipeline."
)
body.setWordWrap(True)
body.setAlignment(Qt.AlignCenter)
body.setStyleSheet("color:#c7c2bb; font-size:12.5px;")
body.setMaximumWidth(280)
btn = QPushButton("⊕ Mount sample")
btn.setCursor(Qt.PointingHandCursor)
btn.setStyleSheet(
f"QPushButton {{ background:{p.accent}; color:#fff; border:none;"
f" border-radius:10px; padding:11px 22px; font-size:14px; font-weight:600; }}"
)
btn.clicked.connect(self.mount_clicked)
ecl.addWidget(tile, 0, Qt.AlignHCenter)
ecl.addSpacing(16)
ecl.addWidget(title)
ecl.addSpacing(6)
ecl.addWidget(body)
ecl.addSpacing(20)
ecl.addWidget(btn, 0, Qt.AlignHCenter)
self._empty_card.adjustSize()
# Picking toast
self._toast = QLabel(
"← Choose a sample in the Sample Changer to mount it", self
)
self._toast.setStyleSheet(
"background: rgba(18,17,16,0.66); border:1px solid rgba(255,255,255,0.12);"
" border-radius:12px; padding:15px 22px; font-size:13px; color:#e7e3db;"
)
self._toast.adjustSize()
def _build_control_overlay(self) -> None:
"""Full-rect dim + message shown when this user does not hold the baton."""
self._control_overlay = QFrame(self)
self._control_overlay.setStyleSheet(
"QFrame { background: rgba(8,9,12,0.62); }"
)
col = QVBoxLayout(self._control_overlay)
col.setAlignment(Qt.AlignCenter)
col.setSpacing(6)
self._control_title = QLabel("GUEST MODE")
self._control_title.setAlignment(Qt.AlignCenter)
self._control_title.setStyleSheet(
"color:#ece8f4; font-size:18px; font-weight:700; letter-spacing:2px;"
" background:transparent;"
)
self._control_sub = QLabel("")
self._control_sub.setAlignment(Qt.AlignCenter)
self._control_sub.setStyleSheet(
"color:#c7c2bb; font-size:12.5px; background:transparent;"
)
col.addWidget(self._control_title)
col.addWidget(self._control_sub)
self._control_overlay.setVisible(False)
def set_control_state(self, in_control: bool, title: str = "GUEST MODE",
sub: str = "") -> None:
"""in_control=False dims the viewport and blocks interaction."""
self._in_control = in_control
self._control_title.setText(title)
self._control_sub.setText(sub)
self._control_overlay.setVisible(not in_control)
if not in_control:
self._control_overlay.setGeometry(self.rect())
self._control_overlay.raise_()
self.update()
# --------------------------------------------------------------- state
def set_mode(self, mode: str) -> None:
self._mode = mode
self._empty_card.setVisible(mode == "empty")
self._toast.setVisible(mode == "picking")
self._reposition_overlays()
self.update()
def set_grid_draw_enabled(self, enabled: bool) -> None:
self._grid_draw_enabled = enabled
self.setCursor(Qt.CrossCursor if enabled else Qt.ArrowCursor)
if not enabled:
self._drag_start = self._drag_cur = None
self.update()
def set_grid_cells(self, n_x: int, n_y: int) -> None:
self._grid_nx, self._grid_ny = max(0, n_x), max(0, n_y)
self.update()
def clear_grid(self) -> None:
self._grid_rect_img = None
self._grid_nx = self._grid_ny = 0
self._drag_start = self._drag_cur = None
self._heatmap = None
self.update()
def set_heatmap(self, image, alpha: int | None = None) -> None:
"""Overlay a completed-raster heatmap (QImage) on the current grid rect."""
self._heatmap = image
if alpha is not None:
self._heatmap_alpha = max(0, min(255, alpha))
self.update()
def set_heatmap_alpha(self, alpha: int) -> None:
self._heatmap_alpha = max(0, min(255, alpha))
self.update()
def set_grid_rect(self, x: float, y: float, w: float, h: float) -> None:
"""Set the grid rect in image-pixel coords (used to place a heatmap)."""
self._grid_rect_img = QRectF(x, y, w, h)
self.update()
# ------------------------------------------------------------- slots
def update_pixmap(self, pm: QPixmap) -> None:
self._pixmap = pm
if not pm.isNull():
self._img_w = pm.width()
self._img_h = pm.height()
self.update()
def update_fps(self, fps: float) -> None:
self._fps = fps
self.update()
def set_zoom(self, zoom: float) -> None:
self._zoom = zoom
self.update()
def update_target_point(self, data: dict) -> None:
tp = data.get("target_point") if isinstance(data, dict) else None
if tp and "x" in tp and "y" in tp:
self._target_point = QPoint(int(tp["x"]), int(tp["y"]))
else:
self._target_point = None
self.update()
# -------------------------------------------------- coordinate mapping
def _widget_to_image(self, pt: QPoint) -> QPoint:
if self._draw_rect.width() <= 0 or self._draw_rect.height() <= 0:
return QPoint(0, 0)
fx = (pt.x() - self._draw_rect.x()) / self._draw_rect.width()
fy = (pt.y() - self._draw_rect.y()) / self._draw_rect.height()
fx = min(max(fx, 0.0), 1.0)
fy = min(max(fy, 0.0), 1.0)
return QPoint(int(fx * self._img_w), int(fy * self._img_h))
def _image_to_widget(self, pt: QPoint) -> QPoint:
if self._img_w <= 0 or self._img_h <= 0:
return QPoint(0, 0)
x = self._draw_rect.x() + pt.x() / self._img_w * self._draw_rect.width()
y = self._draw_rect.y() + pt.y() / self._img_h * self._draw_rect.height()
return QPoint(int(x), int(y))
def _image_rect_to_widget(self, r: QRectF) -> QRect:
tl = self._image_to_widget(QPoint(int(r.left()), int(r.top())))
br = self._image_to_widget(QPoint(int(r.right()), int(r.bottom())))
return QRect(tl, br)
# --------------------------------------------------------- mouse (grid)
def mousePressEvent(self, event): # noqa: N802
if not self._in_control:
super().mousePressEvent(event)
return
if self._grid_draw_enabled and event.button() == Qt.LeftButton:
self._drag_start = event.position().toPoint()
self._drag_cur = self._drag_start
self.update()
elif (event.button() == Qt.LeftButton and self._mode == "mounted"
and self._pixmap is not None and not self._pixmap.isNull()):
# Implicit click-to-centre: report the clicked point in image pixels.
img = self._widget_to_image(event.position().toPoint())
self.clicked_point.emit(float(img.x()), float(img.y()))
else:
super().mousePressEvent(event)
def mouseMoveEvent(self, event): # noqa: N802
if self._grid_draw_enabled and self._drag_start is not None:
self._drag_cur = event.position().toPoint()
self.update()
else:
super().mouseMoveEvent(event)
def mouseReleaseEvent(self, event): # noqa: N802
if (self._grid_draw_enabled and self._drag_start is not None
and event.button() == Qt.LeftButton):
self._drag_cur = event.position().toPoint()
a = self._widget_to_image(self._drag_start)
b = self._widget_to_image(self._drag_cur)
x, y = min(a.x(), b.x()), min(a.y(), b.y())
w, h = abs(b.x() - a.x()), abs(b.y() - a.y())
if w > 2 and h > 2:
self._grid_rect_img = QRectF(x, y, w, h)
self.grid_drawn.emit(float(x), float(y), float(w), float(h))
self._drag_start = self._drag_cur = None
self.update()
else:
super().mouseReleaseEvent(event)
# ------------------------------------------------------------- layout
def resizeEvent(self, event): # noqa: N802
super().resizeEvent(event)
self._reposition_overlays()
if hasattr(self, "_control_overlay"):
self._control_overlay.setGeometry(self.rect())
def _reposition_overlays(self) -> None:
if self._empty_card.isVisible():
self._empty_card.adjustSize()
sz = self._empty_card.size()
self._empty_card.move(
(self.width() - sz.width()) // 2,
(self.height() - sz.height()) // 2,
)
if self._toast.isVisible():
self._toast.adjustSize()
sz = self._toast.size()
self._toast.move(
(self.width() - sz.width()) // 2,
(self.height() - sz.height()) // 2,
)
# ------------------------------------------------------------- paint
def paintEvent(self, event): # noqa: N802
painter = QPainter(self)
painter.setRenderHint(QPainter.Antialiasing)
r = self.rect()
if self._pixmap is not None and not self._pixmap.isNull():
scaled = self._pixmap.scaled(
r.size(), Qt.KeepAspectRatio, Qt.SmoothTransformation
)
x = (r.width() - scaled.width()) // 2
y = (r.height() - scaled.height()) // 2
self._draw_rect = QRect(x, y, scaled.width(), scaled.height())
# letterbox
painter.fillRect(r, QColor(self._p.cam_stop2))
painter.drawPixmap(x, y, scaled)
else:
grad = QRadialGradient(r.width() * 0.75, r.height() * 0.80,
max(r.width(), r.height()) * 1.0)
grad.setColorAt(0.0, QColor(self._p.cam_stop0))
grad.setColorAt(0.45, QColor(self._p.cam_stop1))
grad.setColorAt(1.0, QColor(self._p.cam_stop2))
painter.fillRect(r, grad)
self._draw_rect = r
self._paint_crosshair(painter, r)
self._paint_overlay_text(painter, r)
if self._mode == "mounted":
self._paint_mounted_overlays(painter, r)
self._paint_grid(painter)
def _paint_crosshair(self, painter: QPainter, r: QRect) -> None:
pen = QPen(QColor(255, 255, 255, 46))
pen.setWidth(1)
painter.setPen(pen)
cx, cy = r.width() // 2, r.height() // 2
painter.drawLine(cx, cy - 21, cx, cy + 21)
painter.drawLine(cx - 21, cy, cx + 21, cy)
def _paint_overlay_text(self, painter: QPainter, r: QRect) -> None:
painter.setPen(QColor("#867f96"))
f = QFont(FONT_MONO_FALLBACK.split(",")[0].strip('"'))
f.setPixelSize(11)
painter.setFont(f)
fps = "-" if self._fps != self._fps else f"{self._fps:.0f}" # NaN-safe
text = f"SAMCAM · {fps} fps · {self._zoom:.1f}×"
painter.drawText(QRect(0, 8, r.width() - 14, 16),
Qt.AlignRight | Qt.AlignTop, text)
def _paint_mounted_overlays(self, painter: QPainter, r: QRect) -> None:
# Target ring: at target point if known, else design's bottom-right area.
if self._target_point is not None:
c = self._image_to_widget(self._target_point)
else:
c = QPoint(r.width() - 120, r.height() - 90)
painter.setBrush(Qt.NoBrush)
painter.setPen(QPen(QColor(self._p.accent), 2))
painter.drawEllipse(c, 15, 15)
halo = QColor(self._p.accent)
halo.setAlpha(56)
painter.setPen(QPen(halo, 4))
painter.drawEllipse(c, 17, 17)
# Legend card (bottom-left)
self._paint_legend(painter, r)
def _paint_legend(self, painter: QPainter, r: QRect) -> None:
items = [("#9b7bff", "Target + coords"),
("#d29922", "Loop / face"),
("#3fb950", "Crystal")]
pad, line_h, sw = 11, 18, 9
f = QFont(FONT_MONO_FALLBACK.split(",")[0].strip('"'))
f.setPixelSize(10)
painter.setFont(f)
w = 150
h = pad * 2 + line_h * len(items)
x, y = 14, r.height() - 14 - h
card = QRect(x, y, w, h)
painter.setBrush(QColor(8, 10, 13, 168))
painter.setPen(QPen(QColor("#332c44"), 1))
painter.drawRoundedRect(card, 7, 7)
for i, (col, label) in enumerate(items):
ly = y + pad + i * line_h
painter.setBrush(QColor(col))
painter.setPen(Qt.NoPen)
painter.drawRoundedRect(QRect(x + pad, ly + 2, sw, sw), 2, 2)
painter.setPen(QColor("#d9d3e4"))
painter.drawText(QRect(x + pad + sw + 7, ly, w - pad * 2 - sw, line_h),
Qt.AlignLeft | Qt.AlignVCenter, label)
def _paint_grid(self, painter: QPainter) -> None:
# Live drag rectangle
rect = None
if self._drag_start is not None and self._drag_cur is not None:
rect = QRect(self._drag_start, self._drag_cur).normalized()
elif self._grid_rect_img is not None:
rect = self._image_rect_to_widget(self._grid_rect_img)
if rect is None or rect.width() < 1 or rect.height() < 1:
return
dragging = self._drag_start is not None and self._drag_cur is not None
# Completed-result heatmap (blitted, nearest-neighbour, when not dragging).
if self._heatmap is not None and not dragging:
painter.save()
painter.setRenderHint(QPainter.SmoothPixmapTransform, False)
painter.setOpacity(self._heatmap_alpha / 255.0)
painter.drawImage(rect, self._heatmap)
painter.restore()
painter.setBrush(Qt.NoBrush)
painter.setPen(QPen(QColor(self._p.accent), 2))
painter.drawRect(rect)
# Cell gridlines (only while drawing / before a heatmap exists).
if self._heatmap is None or dragging:
nx = self._grid_nx if self._grid_nx > 0 else 0
ny = self._grid_ny if self._grid_ny > 0 else 0
cell = QColor(self._p.accent)
cell.setAlpha(90)
painter.setPen(QPen(cell, 1))
if nx > 1:
for i in range(1, nx):
gx = rect.left() + rect.width() * i / nx
painter.drawLine(int(gx), rect.top(), int(gx), rect.bottom())
if ny > 1:
for j in range(1, ny):
gy = rect.top() + rect.height() * j / ny
painter.drawLine(rect.left(), int(gy), rect.right(), int(gy))
+215
View File
@@ -0,0 +1,215 @@
"""Small reusable widgets and helpers shared across the redesigned GUI."""
from __future__ import annotations
from PySide6.QtCore import QPoint, QRect, QSize, Qt, Signal
from PySide6.QtWidgets import (
QFrame,
QLabel,
QLayout,
QPushButton,
QSizePolicy,
QWidget,
)
class ClickableLabel(QLabel):
"""A QLabel that emits ``clicked`` on left mouse press."""
clicked = Signal()
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.setCursor(Qt.PointingHandCursor)
def mousePressEvent(self, event): # noqa: N802
if event.button() == Qt.LeftButton:
self.clicked.emit()
super().mousePressEvent(event)
from aare.gui.new_gui.theme import FONT_MONO_FALLBACK, Palette
def hline(p: Palette) -> QFrame:
"""A 1px horizontal divider matching the panel border colour."""
line = QFrame()
line.setObjectName("Divider")
line.setFixedHeight(1)
line.setStyleSheet(f"background:{p.border_panel}; border:none;")
return line
def vline(p: Palette, height: int = 30) -> QFrame:
line = QFrame()
line.setFixedWidth(1)
line.setFixedHeight(height)
line.setStyleSheet(f"background:{p.border_panel}; border:none;")
return line
def section_label(text: str) -> QLabel:
lbl = QLabel(text)
lbl.setObjectName("SectionLabel")
return lbl
def micro_label(text: str) -> QLabel:
lbl = QLabel(text)
lbl.setObjectName("MicroLabel")
return lbl
def mono_label(text: str, size: int = 13, color: str | None = None,
weight: int = 500) -> QLabel:
lbl = QLabel(text)
style = f"font-family:{FONT_MONO_FALLBACK}; font-size:{size}px; font-weight:{weight};"
if color:
style += f" color:{color};"
lbl.setStyleSheet(style)
return lbl
class Chip(QPushButton):
"""A small checkable pill used for protocol toggles and zoom levels.
On = accent fill / white text; off = chip-off bg / faint text. Styling is
self-contained so chips can live anywhere without global QSS dependencies.
"""
def __init__(self, text: str, palette: Palette, checked: bool = False,
mono: bool = True, parent: QWidget | None = None):
super().__init__(text, parent)
self._p = palette
self._mono = mono
self.setCheckable(True)
self.setChecked(checked)
self.setCursor(Qt.PointingHandCursor)
self.toggled.connect(lambda _: self._restyle())
self._restyle()
def _restyle(self) -> None:
p = self._p
fam = f"font-family:{FONT_MONO_FALLBACK};" if self._mono else ""
if self.isChecked():
fill, color = p.accent, p.accent_text
else:
fill, color = p.chip_off_bg, p.text_faint
self.setStyleSheet(
f"QPushButton {{ {fam} font-size:10.5px; padding:4px 10px;"
f" border:none; border-radius:11px; background:{fill}; color:{color}; }}"
)
class FlowLayout(QLayout):
"""A height-for-width flow layout (wraps items like inline-block).
Adapted from the canonical Qt flow-layout example; used for chip rows that
should wrap rather than clip on narrow panels.
"""
def __init__(self, parent: QWidget | None = None, margin: int = 0, spacing: int = 6):
super().__init__(parent)
self._items: list = []
self._spacing = spacing
self.setContentsMargins(margin, margin, margin, margin)
def __del__(self):
while self.count():
self.takeAt(0)
def addItem(self, item): # noqa: N802 (Qt override)
self._items.append(item)
def count(self):
return len(self._items)
def itemAt(self, index): # noqa: N802
if 0 <= index < len(self._items):
return self._items[index]
return None
def takeAt(self, index): # noqa: N802
if 0 <= index < len(self._items):
return self._items.pop(index)
return None
def expandingDirections(self): # noqa: N802
return Qt.Orientations(Qt.Orientation(0))
def hasHeightForWidth(self): # noqa: N802
return True
def heightForWidth(self, width): # noqa: N802
return self._do_layout(QRect(0, 0, width, 0), test_only=True)
def setGeometry(self, rect): # noqa: N802
super().setGeometry(rect)
self._do_layout(rect, test_only=False)
def sizeHint(self): # noqa: N802
return self.minimumSize()
def minimumSize(self): # noqa: N802
size = QSize()
for item in self._items:
size = size.expandedTo(item.minimumSize())
m = self.contentsMargins()
size += QSize(m.left() + m.right(), m.top() + m.bottom())
return size
def _do_layout(self, rect: QRect, test_only: bool) -> int:
x, y = rect.x(), rect.y()
line_height = 0
for item in self._items:
w = item.sizeHint().width()
h = item.sizeHint().height()
next_x = x + w + self._spacing
if next_x - self._spacing > rect.right() and line_height > 0:
x = rect.x()
y = y + line_height + self._spacing
next_x = x + w + self._spacing
line_height = 0
if not test_only:
item.setGeometry(QRect(QPoint(x, y), QSize(w, h)))
x = next_x
line_height = max(line_height, h)
return y + line_height - rect.y()
class ParamCard(QFrame):
"""A small two-line read-only card (label over mono value).
Used in the contextual settings rows (Raster/XRF/Collect parameters). The
value label can be updated live via :meth:`set_value`.
"""
def __init__(self, label: str, value: str, palette: Palette,
highlighted: bool = False, parent: QWidget | None = None):
super().__init__(parent)
from PySide6.QtWidgets import QVBoxLayout
p = palette
if highlighted:
bg, border, lbl_c, val_c = p.accent_tint_bg, p.accent, p.accent, p.accent
else:
bg, border, lbl_c, val_c = (
p.surface, p.border_control, p.text_faint, p.text_primary,
)
self.setStyleSheet(
f"ParamCard {{ background:{bg}; border:1px solid {border};"
f" border-radius:7px; }} QLabel {{ background:transparent; border:none; }}"
)
self.setSizePolicy(QSizePolicy.Maximum, QSizePolicy.Maximum)
vb = QVBoxLayout(self)
vb.setContentsMargins(11, 5, 11, 5)
vb.setSpacing(1)
self._lab = QLabel(label)
self._lab.setStyleSheet(f"font-size:9px; color:{lbl_c};")
self._val = QLabel(value)
self._val.setStyleSheet(
f"font-family:{FONT_MONO_FALLBACK}; font-size:12px; color:{val_c};"
)
vb.addWidget(self._lab)
vb.addWidget(self._val)
def set_value(self, value: str) -> None:
self._val.setText(value)
+82
View File
@@ -0,0 +1,82 @@
"""Viridis heatmap + raster-metric extraction for completed raster grids.
Mirrors ``raster_grid_manager``: each grid cell (a ScanResult image) yields a
metric value; values are normalised and mapped through viridis into an RGBA
QImage (alpha 0 for invalid/negative cells) which the camera blits over the grid.
"""
from __future__ import annotations
import numpy as np
from PySide6.QtGui import QImage
# 10-stop viridis LUT (purple -> yellow), interpolated per value.
_VIRIDIS = np.array([
[0x44, 0x01, 0x54], [0x48, 0x28, 0x78], [0x3e, 0x4a, 0x89],
[0x31, 0x68, 0x8e], [0x26, 0x82, 0x8e], [0x1f, 0x9e, 0x89],
[0x35, 0xb7, 0x79], [0x6e, 0xce, 0x58], [0xb5, 0xde, 0x2b],
[0xfd, 0xe7, 0x25],
], dtype=np.float64)
# (key, label, extractor) — extractor maps a ScanResult image object to a value.
METRICS: tuple[tuple[str, str], ...] = (
("spots", "Spots"),
("spots_low_res", "Spots (low-res)"),
("spots_indexed", "Spots indexed"),
("index", "Indexing"),
("res", "Resolution"),
("b", "B-factor"),
("bkg", "Background"),
("spots_ice", "Ice spots"),
("pr", "Profile radius"),
)
def metric_value(img, key: str):
"""Pull a single metric value off a ScanResult image object."""
if key == "pr":
spots_low = getattr(img, "spots_low_res", 0) or 0
return getattr(img, "index", 0) / max(spots_low, 1)
return getattr(img, key, None)
def viridis_rgb(norm: np.ndarray) -> np.ndarray:
"""norm in [0,1] -> (N,3) uint8 via linear interpolation in the LUT."""
norm = np.clip(norm, 0.0, 1.0)
pos = norm * (len(_VIRIDIS) - 1)
lo = np.floor(pos).astype(int)
hi = np.minimum(lo + 1, len(_VIRIDIS) - 1)
frac = (pos - lo)[:, None]
rgb = _VIRIDIS[lo] * (1 - frac) + _VIRIDIS[hi] * frac
return rgb.astype(np.uint8)
def build_heatmap_qimage(values: list, n_x: int, n_y: int) -> QImage | None:
"""Build an n_x×n_y RGBA QImage from per-cell metric values (row-major).
Invalid (None / NaN / negative) cells are fully transparent.
"""
count = n_x * n_y
if count <= 0 or not values:
return None
flat = np.array(
[np.nan if v is None else float(v) for v in values[:count]]
+ [np.nan] * max(0, count - len(values)),
dtype=np.float64,
)
valid = np.isfinite(flat) & (flat >= 0)
if valid.any():
vmin = float(flat[valid].min())
vmax = float(flat[valid].max())
else:
vmin, vmax = 0.0, 1.0
diff = (vmax - vmin) or 1.0
norm = np.clip((flat - vmin) / diff, 0.0, 1.0)
norm[~valid] = 0.0
rgba = np.zeros((count, 4), dtype=np.uint8)
rgba[:, :3] = viridis_rgb(norm)
rgba[:, 3] = np.where(valid, 255, 0).astype(np.uint8)
rgba = np.ascontiguousarray(rgba.reshape(n_y, n_x, 4))
return QImage(rgba.data, n_x, n_y, 4 * n_x, QImage.Format.Format_RGBA8888).copy()
@@ -0,0 +1,322 @@
"""Motors panel (Manual mode, right column): OMEGA, SMARGON, ZOOM, LIGHT.
Readouts update from DAQStatusModel; controls emit intent signals the main
window wires to DAQWorker (set_omega_rel, move_smargon, zoom, front/back light).
"""
from __future__ import annotations
from PySide6.QtCore import Qt, Signal
from PySide6.QtWidgets import (
QButtonGroup,
QDoubleSpinBox,
QGridLayout,
QHBoxLayout,
QLabel,
QPushButton,
QSlider,
QVBoxLayout,
QWidget,
)
from aare.gui.new_gui.theme import FONT_MONO_FALLBACK, MOTORS_W, Palette
from aare.gui.new_gui.widgets.common import hline, section_label
OMEGA_STEPS = (-90, -45, -10, 10, 45, 90)
ZOOM_LEVELS = (1.0, 2.0, 3.5, 5.8)
JOG_STEPS_UM = (10, 50, 100, 500)
class MotorsPanel(QWidget):
omega_rel = Signal(float) # degrees, relative
smargon_jog = Signal(float, float) # dx_mm, dy_mm
zoom_changed = Signal(float)
front_light_changed = Signal(int)
back_light_changed = Signal(int)
energy_changed = Signal(float) # keV (staff only)
def __init__(self, palette: Palette, parent=None):
super().__init__(parent)
self.setObjectName("SidePanel")
self.setFixedWidth(MOTORS_W)
self._p = palette
self._jog_idx = 2 # default 100 µm
from PySide6.QtWidgets import QScrollArea
scroll = QScrollArea(self)
scroll.setWidgetResizable(True)
scroll.setFrameShape(QScrollArea.NoFrame)
body = QWidget()
scroll.setWidget(body)
outer = QVBoxLayout(self)
outer.setContentsMargins(0, 0, 0, 0)
outer.addWidget(scroll)
lay = QVBoxLayout(body)
lay.setContentsMargins(14, 14, 14, 14)
lay.setSpacing(0)
# ---- OMEGA ----
lay.addWidget(section_label("OMEGA"))
lay.addSpacing(10)
row = QHBoxLayout()
self._omega_val = QLabel("0.00°")
self._omega_val.setStyleSheet(
f"font-family:{FONT_MONO_FALLBACK}; font-size:22px; font-weight:600;"
)
row.addWidget(self._omega_val)
row.addStretch(1)
wsym = QLabel("ω")
wsym.setStyleSheet(f"font-size:11px; color:{palette.text_faint};")
row.addWidget(wsym)
lay.addLayout(row)
lay.addSpacing(10)
grid = QGridLayout()
grid.setSpacing(6)
for i, step in enumerate(OMEGA_STEPS):
btn = QPushButton(f"{step:+d}")
btn.setCursor(Qt.PointingHandCursor)
btn.clicked.connect(lambda _=False, s=step: self.omega_rel.emit(float(s)))
grid.addWidget(btn, i // 3, i % 3)
lay.addLayout(grid)
lay.addSpacing(16)
lay.addWidget(hline(palette))
lay.addSpacing(16)
# ---- SMARGON ----
lay.addWidget(section_label("SMARGON"))
lay.addSpacing(10)
readouts = QHBoxLayout()
self._chi_val = self._readout(readouts, "Chi", palette)
self._phi_val = self._readout(readouts, "Phi", palette)
lay.addLayout(readouts)
lay.addSpacing(10)
pad = QGridLayout()
pad.setSpacing(6)
up = self._jog_btn("")
down = self._jog_btn("")
left = self._jog_btn("")
right = self._jog_btn("")
self._center = QPushButton("100µm")
self._center.setCursor(Qt.PointingHandCursor)
self._center.setStyleSheet(
f"QPushButton {{ background:{palette.surface};"
f" border:1px solid {palette.border_control}; border-radius:6px;"
f" font-size:10px; color:{palette.text_faint}; padding:7px 0; }}"
)
self._center.clicked.connect(self._cycle_step)
up.clicked.connect(lambda: self._jog(0, 1))
down.clicked.connect(lambda: self._jog(0, -1))
left.clicked.connect(lambda: self._jog(-1, 0))
right.clicked.connect(lambda: self._jog(1, 0))
pad.addWidget(up, 0, 1)
pad.addWidget(left, 1, 0)
pad.addWidget(self._center, 1, 1)
pad.addWidget(right, 1, 2)
pad.addWidget(down, 2, 1)
padw = QWidget()
padw.setLayout(pad)
padw.setFixedWidth(150)
center_pad = QHBoxLayout()
center_pad.addStretch(1)
center_pad.addWidget(padw)
center_pad.addStretch(1)
lay.addLayout(center_pad)
lay.addSpacing(16)
lay.addWidget(hline(palette))
lay.addSpacing(16)
# ---- ZOOM ----
lay.addWidget(section_label("ZOOM"))
lay.addSpacing(10)
zoom_row = QHBoxLayout()
zoom_row.setSpacing(6)
self._zoom_group = QButtonGroup(self)
self._zoom_group.setExclusive(True)
self._zoom_btns: dict[float, QPushButton] = {}
for z in ZOOM_LEVELS:
b = QPushButton(f"{z:.1f}×")
b.setCheckable(True)
b.setCursor(Qt.PointingHandCursor)
b.clicked.connect(lambda _=False, zz=z: self.zoom_changed.emit(zz))
b.toggled.connect(lambda _on, bb=b: self._restyle_zoom(bb))
self._zoom_group.addButton(b)
self._zoom_btns[z] = b
zoom_row.addWidget(b)
self._restyle_zoom(b)
zoom_row.addStretch(1)
lay.addLayout(zoom_row)
lay.addSpacing(16)
lay.addWidget(hline(palette))
lay.addSpacing(16)
# ---- LIGHT ----
lay.addWidget(section_label("LIGHT"))
lay.addSpacing(10)
self._front = self._light_slider(lay, "Front", palette,
self.front_light_changed)
lay.addSpacing(12)
self._back = self._light_slider(lay, "Back", palette,
self.back_light_changed)
# ---- ENERGY (staff only) ----
self._energy_section = QWidget()
es = QVBoxLayout(self._energy_section)
es.setContentsMargins(0, 0, 0, 0)
es.setSpacing(0)
es.addSpacing(16)
es.addWidget(hline(palette))
es.addSpacing(16)
es.addWidget(section_label("ENERGY"))
es.addSpacing(10)
erow = QHBoxLayout()
erow.setSpacing(6)
self._energy_spin = QDoubleSpinBox()
self._energy_spin.setDecimals(3)
self._energy_spin.setRange(4.0, 25.0)
self._energy_spin.setSingleStep(0.01)
self._energy_spin.setSuffix(" keV")
self._energy_spin.setValue(12.4)
self._energy_spin.setStyleSheet(
f"QDoubleSpinBox {{ font-family:{FONT_MONO_FALLBACK}; font-size:14px;"
f" background:{palette.surface}; border:1px solid {palette.border_control};"
f" border-radius:6px; padding:5px 8px; color:{palette.text_primary}; }}"
)
set_btn = QPushButton("Set")
set_btn.setCursor(Qt.PointingHandCursor)
set_btn.setStyleSheet(
f"QPushButton {{ background:{palette.accent}; color:#fff; border:none;"
f" border-radius:6px; padding:6px 14px; font-weight:600; }}"
)
set_btn.clicked.connect(
lambda: self.energy_changed.emit(self._energy_spin.value())
)
erow.addWidget(self._energy_spin, 1)
erow.addWidget(set_btn)
es.addLayout(erow)
self._energy_section.setVisible(False)
lay.addWidget(self._energy_section)
lay.addStretch(1)
def set_staff(self, is_staff: bool) -> None:
self._energy_section.setVisible(is_staff)
# -------- helpers --------
def _readout(self, lay: QHBoxLayout, name: str, p: Palette) -> QLabel:
box = QVBoxLayout()
box.setSpacing(0)
lab = QLabel(name)
lab.setStyleSheet(f"font-size:10.5px; color:{p.text_faint};")
val = QLabel("0.0°")
val.setStyleSheet(f"font-family:{FONT_MONO_FALLBACK}; font-size:15px;")
box.addWidget(lab)
box.addWidget(val)
wrap = QWidget()
wrap.setLayout(box)
lay.addWidget(wrap, 1)
return val
def _jog_btn(self, glyph: str) -> QPushButton:
b = QPushButton(glyph)
b.setCursor(Qt.PointingHandCursor)
return b
def _light_slider(self, lay: QVBoxLayout, name: str, p: Palette,
signal) -> QSlider:
lbl = QLabel(name)
lbl.setStyleSheet(f"font-size:10.5px; color:{p.text_faint};")
lay.addWidget(lbl)
lay.addSpacing(5)
s = QSlider(Qt.Horizontal)
s.setRange(0, 100)
s.setStyleSheet(self._slider_qss(p))
s.valueChanged.connect(signal)
lay.addWidget(s)
return s
@staticmethod
def _slider_qss(p: Palette) -> str:
return (
f"QSlider::groove:horizontal {{ height:4px; background:#e9e5db;"
f" border-radius:2px; }}"
f" QSlider::sub-page:horizontal {{ background:{p.accent};"
f" border-radius:2px; }}"
f" QSlider::handle:horizontal {{ width:12px; height:12px; margin:-4px 0;"
f" border-radius:6px; background:{p.accent}; }}"
)
def _restyle_zoom(self, b: QPushButton) -> None:
p = self._p
if b.isChecked():
b.setStyleSheet(
f"QPushButton {{ background:{p.accent}; color:#fff; border:none;"
f" border-radius:6px; font-size:11px; font-weight:600; padding:5px 10px; }}"
)
else:
b.setStyleSheet(
f"QPushButton {{ background:{p.surface};"
f" border:1px solid {p.border_control}; border-radius:6px;"
f" font-size:11px; padding:5px 10px; }}"
)
def _cycle_step(self) -> None:
self._jog_idx = (self._jog_idx + 1) % len(JOG_STEPS_UM)
self._center.setText(f"{JOG_STEPS_UM[self._jog_idx]}µm")
def _jog(self, sx: int, sy: int) -> None:
step_mm = JOG_STEPS_UM[self._jog_idx] / 1000.0
self.smargon_jog.emit(sx * step_mm, sy * step_mm)
# -------- live updates --------
def update_daq_status(self, s) -> None:
geom = getattr(s, "geom", None)
if geom is not None:
omega = getattr(geom, "omega_deg", None)
if omega is not None:
self._omega_val.setText(f"{omega:.2f}°")
smg = getattr(geom, "smargon", None)
if smg is not None:
chi = getattr(smg, "chi_deg", None)
phi = getattr(smg, "phi_deg", None)
if chi is not None:
self._chi_val.setText(f"{chi:.1f}°")
if phi is not None:
self._phi_val.setText(f"{phi:.1f}°")
bl = getattr(s, "bl", None)
if bl is not None:
zoom = getattr(bl, "zoom", None)
if zoom is not None:
self._sync_zoom(zoom)
self._sync_slider(self._front, getattr(bl, "front_light", None))
self._sync_slider(self._back, getattr(bl, "back_light", None))
diff = getattr(s, "diffraction", None)
energy = getattr(diff, "energy_keV", None) if diff else None
if energy is not None and not self._energy_spin.hasFocus():
self._energy_spin.blockSignals(True)
self._energy_spin.setValue(float(energy))
self._energy_spin.blockSignals(False)
def _sync_zoom(self, zoom: float) -> None:
# check the nearest level without re-emitting
nearest = min(ZOOM_LEVELS, key=lambda z: abs(z - zoom))
btn = self._zoom_btns.get(nearest)
if btn and not btn.isChecked():
btn.blockSignals(True)
btn.setChecked(True)
btn.blockSignals(False)
self._restyle_zoom(btn)
@staticmethod
def _sync_slider(slider: QSlider, value) -> None:
if value is None:
return
iv = int(round(value))
if slider.value() != iv and not slider.isSliderDown():
slider.blockSignals(True)
slider.setValue(iv)
slider.blockSignals(False)
+284
View File
@@ -0,0 +1,284 @@
"""The interactive pipeline stage tracker (Manual mode, mounted).
A horizontal row of stage nodes joined by connector tracks. Interactive nodes
are clickable (select a stage) and carry a breakpoint flag that toggles
independently. Visual rules follow the design handoff:
- done -> solid success fill + ✓
- selected -> solid accent fill + ring
- pending optional (raster/xrf) -> dashed border
- collect pending -> solid accent border
- generic pending (process/unmount) -> solid grey border
- connector -> success when the preceding node is done, else pending track
"""
from __future__ import annotations
from PySide6.QtCore import QRect, Qt, Signal
from PySide6.QtGui import QColor, QFont, QPainter, QPen
from PySide6.QtWidgets import QHBoxLayout, QPushButton, QWidget
from aare.gui.new_gui.state import STAGES, Stage
from aare.gui.new_gui.theme import Palette
NODE_W = 70
NODE_H = 62
CIRCLE_R = 12
CIRCLE_CY = 18
class _Connector(QWidget):
"""A 2px horizontal track between two nodes."""
def __init__(self, palette: Palette, parent=None):
super().__init__(parent)
self._p = palette
self._done = False
self.setFixedHeight(NODE_H)
self.setMinimumWidth(12)
def set_done(self, done: bool) -> None:
if done != self._done:
self._done = done
self.update()
def paintEvent(self, event): # noqa: N802
painter = QPainter(self)
col = self._p.success if self._done else self._p.pending_track
y = CIRCLE_CY
painter.fillRect(QRect(0, y - 1, self.width(), 2), QColor(col))
class StageNode(QWidget):
"""A single pipeline node: painted circle + label (+ optional caption + flag)."""
clicked = Signal(str) # stage key
breakpoint_toggled = Signal(str)
def __init__(self, stage: Stage, palette: Palette,
breakpoints_enabled: bool = True, preview: bool = False,
parent=None):
super().__init__(parent)
self._stage = stage
self._p = palette
self._preview = preview
self._selected = False
# In preview (not-mounted) mode, Mount is the accented "start here" node
# rather than a completed step.
self._start_here = preview and stage.key == "mount"
self._done = (stage.key == "mount") and not preview
self._has_break = False
self._bp_enabled = breakpoints_enabled and stage.interactive
self.setFixedSize(NODE_W, NODE_H)
if stage.interactive:
self.setCursor(Qt.PointingHandCursor)
self._flag: QPushButton | None = None
if self._bp_enabled:
self._flag = QPushButton("", self)
self._flag.setFixedSize(18, 18)
self._flag.setCursor(Qt.PointingHandCursor)
self._flag.setToolTip("Toggle breakpoint")
self._flag.move(NODE_W - 18 - 2, 2)
self._flag.clicked.connect(self._on_flag)
self._restyle_flag()
# ----- state setters -----
def set_selected(self, selected: bool) -> None:
if selected != self._selected:
self._selected = selected
self.update()
def set_done(self, done: bool) -> None:
if self._preview:
return
if self._stage.key == "mount":
done = True
if done != self._done:
self._done = done
self.update()
def set_breakpoint(self, on: bool) -> None:
if on != self._has_break:
self._has_break = on
self._restyle_flag()
def set_preview(self, preview: bool) -> None:
"""Toggle preview (no-sample) styling: Mount becomes 'start here'."""
self._preview = preview
self._start_here = preview and self._stage.key == "mount"
self._done = (self._stage.key == "mount") and not preview
if self._flag is not None:
self._flag.setVisible(not preview)
self.update()
# ----- interaction -----
def _on_flag(self) -> None:
# Child button consumes the click, so the node is not also selected.
self.breakpoint_toggled.emit(self._stage.key)
def mousePressEvent(self, event): # noqa: N802
if self._stage.interactive and event.button() == Qt.LeftButton:
self.clicked.emit(self._stage.key)
else:
super().mousePressEvent(event)
def _restyle_flag(self) -> None:
if not self._flag:
return
p = self._p
if self._has_break:
css = (f"background:{p.breakpoint}; color:#fff;"
f" border:1px solid {p.breakpoint};")
else:
css = (f"background:{p.surface}; color:{p.text_faint};"
f" border:1px solid {p.text_faint};")
self._flag.setStyleSheet(
f"QPushButton {{ {css} border-radius:9px; font-size:9px; padding:0; }}"
)
# design: off-state flag is 55% opacity
self._flag.setWindowOpacity(1.0)
# ----- paint -----
def paintEvent(self, event): # noqa: N802
p = self._p
painter = QPainter(self)
painter.setRenderHint(QPainter.Antialiasing)
# selected wrapper tint
if self._selected:
painter.setBrush(QColor(p.accent_tint_bg))
painter.setPen(Qt.NoPen)
painter.drawRoundedRect(self.rect().adjusted(0, 0, -1, -1), 10, 10)
cx, cy, r = NODE_W // 2, CIRCLE_CY, CIRCLE_R
circle = QRect(cx - r, cy - r, 2 * r, 2 * r)
glyph = ""
glyph_color = "#fff"
if self._start_here:
painter.setBrush(Qt.NoBrush)
painter.setPen(QPen(QColor(p.accent), 2))
painter.drawEllipse(circle)
self._draw_ring(painter, circle)
glyph = ""
glyph_color = p.accent
elif self._selected and not self._done:
painter.setBrush(QColor(p.accent))
painter.setPen(Qt.NoPen)
painter.drawEllipse(circle)
self._draw_ring(painter, circle)
elif self._done:
painter.setBrush(QColor(p.success))
painter.setPen(Qt.NoPen)
painter.drawEllipse(circle)
glyph = ""
if self._selected:
self._draw_ring(painter, circle)
else:
painter.setBrush(Qt.NoBrush)
if self._stage.optional:
pen = QPen(QColor(p.pending_node), 2, Qt.DashLine)
elif self._stage.key == "collect":
pen = QPen(QColor(p.accent), 2)
else:
pen = QPen(QColor(p.pending_node), 2)
painter.setPen(pen)
painter.drawEllipse(circle)
if glyph:
painter.setPen(QColor(glyph_color))
f = QFont()
f.setPixelSize(12)
f.setBold(True)
painter.setFont(f)
painter.drawText(circle, Qt.AlignCenter, glyph)
# label
if self._selected or self._start_here:
lbl_color = p.accent
elif self._done:
lbl_color = p.text_muted
elif self._stage.optional:
lbl_color = p.text_faint
else:
lbl_color = p.text_faint if not self._stage.key == "collect" else p.accent
painter.setPen(QColor(lbl_color))
lf = QFont()
lf.setPixelSize(11)
if self._stage.key == "collect":
lf.setBold(True)
painter.setFont(lf)
painter.drawText(QRect(0, cy + r + 2, NODE_W, 14),
Qt.AlignHCenter | Qt.AlignTop, self._stage.label)
# caption (optional / start here)
caption = "start here" if self._start_here else (
"optional" if self._stage.optional else "")
if caption:
painter.setPen(QColor(p.accent if self._start_here else p.text_faint))
cf = QFont()
cf.setPixelSize(9)
painter.setFont(cf)
painter.drawText(QRect(0, cy + r + 16, NODE_W, 12),
Qt.AlignHCenter | Qt.AlignTop, caption)
def _draw_ring(self, painter: QPainter, circle: QRect) -> None:
ring = QColor(self._p.accent)
ring.setAlpha(72)
painter.setBrush(Qt.NoBrush)
painter.setPen(QPen(ring, 3))
painter.drawEllipse(circle.adjusted(-3, -3, 3, 3))
class PipelineTracker(QWidget):
"""Row of stage nodes + connectors, driven by :class:`AppState`."""
stage_clicked = Signal(str)
breakpoint_toggled = Signal(str)
def __init__(self, palette: Palette, breakpoints_enabled: bool = True,
preview: bool = False, parent=None):
super().__init__(parent)
self._p = palette
self._preview = preview
self._nodes: dict[str, StageNode] = {}
self._connectors: list[_Connector] = []
lay = QHBoxLayout(self)
lay.setContentsMargins(0, 0, 0, 0)
lay.setSpacing(0)
for i, stage in enumerate(STAGES):
node = StageNode(stage, palette, breakpoints_enabled, preview=preview)
node.clicked.connect(self.stage_clicked)
node.breakpoint_toggled.connect(self.breakpoint_toggled)
self._nodes[stage.key] = node
lay.addWidget(node)
if i < len(STAGES) - 1:
conn = _Connector(palette)
self._connectors.append(conn)
lay.addWidget(conn, 1)
def set_preview(self, preview: bool) -> None:
if preview == self._preview:
return
self._preview = preview
for node in self._nodes.values():
node.set_preview(preview)
def update_from_state(self, state) -> None:
"""Reflect the current pipeline state (selection, done set, breakpoints)."""
seq_idx = state.seq_idx
for i, stage in enumerate(STAGES):
node = self._nodes[stage.key]
node.set_selected(state.stage == stage.key)
# done = mount, or any stage already passed in the sequence
done = (not self._preview) and (stage.key == "mount" or (i < seq_idx))
node.set_done(done)
if stage.interactive:
node.set_breakpoint(state.has_breakpoint(stage.key))
# connectors: green when the node before them is done
for i, conn in enumerate(self._connectors):
left_key = STAGES[i].key
done = (not self._preview) and (left_key == "mount" or (i < seq_idx))
conn.set_done(done)
@@ -0,0 +1,687 @@
"""Bottom panel of the Manual centre column.
Two states, swapped by the Manual view via a QStackedWidget:
- NotMountedPanel: a Mount CTA plus the all-pending pipeline preview.
- MountedPipelinePanel: the interactive stage tracker, a contextual settings row
that swaps with the selected stage, and a transport row.
Contextual settings expose editable parameters (defaults sourced from the
beamline YAML by the caller) and emit semantic run signals. Request objects are
assembled by the wiring layer, which also owns pixel<->mm geometry.
"""
from __future__ import annotations
from PySide6.QtCore import Qt, Signal
from PySide6.QtWidgets import (
QComboBox,
QDoubleSpinBox,
QFrame,
QHBoxLayout,
QLabel,
QPushButton,
QSlider,
QStackedWidget,
QVBoxLayout,
QWidget,
)
from aare.gui.new_gui.theme import FONT_MONO_FALLBACK, Palette
from aare.gui.new_gui.widgets.common import (
micro_label,
mono_label,
vline,
)
from aare.gui.new_gui.widgets.heatmap import METRICS
from aare.gui.new_gui.widgets.pipeline import PipelineTracker
def _primary_button(text: str, palette: Palette) -> QPushButton:
b = QPushButton(text)
b.setObjectName("Primary")
b.setCursor(Qt.PointingHandCursor)
b.setStyleSheet(
f"QPushButton {{ background:{palette.accent}; color:#fff; border:none;"
f" border-radius:8px; padding:8px 16px; font-size:12.5px; font-weight:600; }}"
)
return b
def _outline_button(text: str, palette: Palette) -> QPushButton:
b = QPushButton(text)
b.setCursor(Qt.PointingHandCursor)
b.setStyleSheet(
f"QPushButton {{ background:{palette.surface};"
f" border:1px solid {palette.border_control}; border-radius:8px;"
f" padding:8px 14px; font-size:12.5px; color:{palette.text_secondary}; }}"
)
return b
class _EditCard(QFrame):
"""A small card holding an editable numeric value (label over spinbox)."""
def __init__(self, label: str, palette: Palette, value: float, suffix: str = "",
decimals: int = 2, minimum: float = 0.0, maximum: float = 1e6,
step: float = 1.0, parent=None):
super().__init__(parent)
p = palette
self.setStyleSheet(
f"_EditCard {{ background:{p.surface}; border:1px solid {p.border_control};"
f" border-radius:7px; }} QLabel {{ background:transparent; border:none; }}"
)
vb = QVBoxLayout(self)
vb.setContentsMargins(11, 5, 11, 5)
vb.setSpacing(1)
lab = QLabel(label)
lab.setStyleSheet(f"font-size:9px; color:{p.text_faint};")
self.spin = QDoubleSpinBox()
self.spin.setDecimals(decimals)
self.spin.setRange(minimum, maximum)
self.spin.setSingleStep(step)
self.spin.setValue(value)
if suffix:
self.spin.setSuffix(f" {suffix}")
self.spin.setButtonSymbols(QDoubleSpinBox.NoButtons)
self.spin.setStyleSheet(
f"QDoubleSpinBox {{ font-family:{FONT_MONO_FALLBACK}; font-size:12px;"
f" border:none; background:transparent; color:{p.text_primary}; padding:0; }}"
)
vb.addWidget(lab)
vb.addWidget(self.spin)
def value(self) -> float:
return self.spin.value()
# --------------------------------------------------------------------------
class ContextualSettings(QStackedWidget):
"""Per-stage settings row. Defaults dict keys: exp_time_s, transmission, dtz,
start_omega_deg, increment_omega_deg, steps (from beamline YAML)."""
run_center = Signal(str) # method: 'loop' | 'xray' | 'click'
run_raster = Signal()
run_xrf = Signal()
run_collect = Signal()
raster_draw_toggled = Signal(bool)
raster_metric_changed = Signal(str) # completed-grid heatmap metric key
raster_alpha_changed = Signal(int) # heatmap opacity 0-255
raster_grids_clicked = Signal() # open the grids table
automate_toggled = Signal(bool) # rotation simple-strategy toggle
def __init__(self, palette: Palette, defaults: dict, parent=None):
super().__init__(parent)
self._p = palette
self._defaults = defaults or {}
self._pages: dict[str, QWidget] = {}
self._run_buttons: list[QPushButton] = []
# value-source ('database' | 'user') per parametrised panel
self._source: dict[str, str] = {"raster": "user", "collect": "user"}
self._fields: dict[str, list[_EditCard]] = {"raster": [], "collect": []}
self._db_params = None
self._automate = False
self._build_center()
self._build_raster()
self._build_xrf()
self._build_collect()
self.setMinimumHeight(64)
# -- value source (Database vs User) + automate-parameters toggles --
def _make_source_toggle(self, which: str) -> QWidget:
from PySide6.QtWidgets import QButtonGroup
box = QWidget()
box.setObjectName("ModeToggle")
bl = QHBoxLayout(box)
bl.setContentsMargins(3, 3, 3, 3)
bl.setSpacing(0)
grp = QButtonGroup(box)
grp.setExclusive(True)
for key, label in (("database", "Database"), ("user", "User values")):
b = QPushButton(label)
b.setObjectName("ModeSeg")
b.setCheckable(True)
b.setCursor(Qt.PointingHandCursor)
b.setChecked(key == self._source[which])
b.clicked.connect(lambda _=False, w=which, k=key: self._set_source(w, k))
grp.addButton(b)
bl.addWidget(b)
return box
def _set_source(self, which: str, src: str) -> None:
self._source[which] = src
self._apply_source(which)
def _apply_source(self, which: str) -> None:
read_only = self._source[which] == "database"
for card in self._fields[which]:
card.spin.setReadOnly(read_only)
card.spin.setDisabled(read_only and which == "collect" and self._automate)
if read_only and self._db_params is not None:
self._populate_db(which)
def set_db_params(self, params) -> None:
"""Feed the mounted sample's aaredb_params (DataCollectionParameters)."""
self._db_params = params
for which in ("raster", "collect"):
if self._source[which] == "database":
self._apply_source(which)
def _populate_db(self, which: str) -> None:
p = self._db_params
if p is None:
return
if which == "collect":
rng = getattr(p, "totalrange", None) or getattr(p, "totalangle", None)
osc = getattr(p, "oscillation", None)
trans = getattr(p, "transmission", None)
if rng is not None:
self._range.spin.setValue(float(rng))
if osc is not None:
self._osc.spin.setValue(float(osc))
if trans is not None:
self._collect_trans.spin.setValue(float(trans) * 100.0)
else: # raster — only exposure/transmission map cleanly from DB
trans = getattr(p, "transmission", None)
if trans is not None:
self._raster_trans.spin.setValue(float(trans) * 100.0)
def collect_value_source(self) -> str:
return self._source["collect"]
def is_automated(self) -> bool:
return self._automate
def show_stage(self, key: str) -> None:
page = self._pages.get(key)
if page is not None:
self.setCurrentWidget(page)
def set_run_enabled(self, enabled: bool) -> None:
"""Disable the '▶ Run … now' buttons when no sample is mounted."""
tip = "" if enabled else "Mount a sample to run this stage"
for b in self._run_buttons:
b.setEnabled(enabled)
b.setToolTip(tip)
def _row(self, key: str) -> QHBoxLayout:
page = QWidget()
lay = QHBoxLayout(page)
lay.setContentsMargins(0, 0, 0, 0)
lay.setSpacing(8)
self.addWidget(page)
self._pages[key] = page
return lay
def _build_center(self) -> None:
lay = self._row("center")
lay.addWidget(micro_label("CENTER · method"))
loop = _primary_button("▶ Loop centering (auto)", self._p)
loop.clicked.connect(lambda: self.run_center.emit("loop"))
self._run_buttons.append(loop)
lay.addWidget(loop)
hint = QLabel("or click a point on the camera to centre it")
hint.setStyleSheet(f"font-size:11px; color:{self._p.text_faint};")
lay.addWidget(hint)
lay.addStretch(1)
def _build_raster(self) -> None:
lay = self._row("raster")
lay.addWidget(micro_label("RASTER"))
lay.addWidget(self._make_source_toggle("raster"))
self._cell_x = _EditCard("Cell width", self._p, 20.0, "µm", decimals=0,
minimum=1, maximum=500, step=1)
self._cell_y = _EditCard("Cell height", self._p, 16.0, "µm", decimals=0,
minimum=1, maximum=500, step=1)
self._raster_trans = _EditCard(
"Transmission", self._p,
float(self._defaults.get("transmission", 1.0)) * 100.0,
"%", decimals=1, minimum=0, maximum=100, step=1,
)
self._fields["raster"] = [self._cell_x, self._cell_y, self._raster_trans]
self._draw_btn = QPushButton("✏ Draw grid")
self._draw_btn.setCheckable(True)
self._draw_btn.setCursor(Qt.PointingHandCursor)
self._draw_btn.toggled.connect(self._on_draw_toggled)
self._style_draw_btn(False)
self._raster_run = _primary_button("▶ Run raster now", self._p)
self._raster_run.clicked.connect(self.run_raster)
self._run_buttons.append(self._raster_run)
lay.addWidget(self._cell_x)
lay.addWidget(self._cell_y)
lay.addWidget(self._raster_trans)
lay.addWidget(self._draw_btn)
lay.addWidget(self._raster_run)
# --- completed-results controls (hidden until a grid completes) ---
self._raster_results = QWidget()
rr = QHBoxLayout(self._raster_results)
rr.setContentsMargins(0, 0, 0, 0)
rr.setSpacing(8)
rr.addWidget(vline(self._p, 28))
rr.addWidget(micro_label("RESULT"))
self._metric_combo = QComboBox()
for key, label in METRICS:
self._metric_combo.addItem(label, key)
self._metric_combo.currentIndexChanged.connect(
lambda _i: self.raster_metric_changed.emit(self._metric_combo.currentData())
)
self._metric_combo.setStyleSheet(
f"QComboBox {{ background:{self._p.surface};"
f" border:1px solid {self._p.border_control}; border-radius:6px;"
f" padding:4px 8px; font-size:12px; }}"
)
rr.addWidget(self._metric_combo)
alpha = QSlider(Qt.Horizontal)
alpha.setRange(0, 255)
alpha.setValue(180)
alpha.setFixedWidth(80)
alpha.valueChanged.connect(self.raster_alpha_changed)
rr.addWidget(QLabel("opacity"))
rr.addWidget(alpha)
self._grids_btn = QPushButton("Grids: 0")
self._grids_btn.setCursor(Qt.PointingHandCursor)
self._grids_btn.setStyleSheet(
f"QPushButton {{ background:{self._p.surface};"
f" border:1px solid {self._p.border_control}; border-radius:6px;"
f" padding:5px 12px; font-size:12px; color:{self._p.text_secondary}; }}"
)
self._grids_btn.clicked.connect(self.raster_grids_clicked)
rr.addWidget(self._grids_btn)
self._raster_results.setVisible(False)
lay.addWidget(self._raster_results)
lay.addStretch(1)
def set_raster_results_count(self, n: int) -> None:
self._raster_results.setVisible(n > 0)
self._grids_btn.setText(f"Grids: {n}")
def raster_metric(self) -> str:
return self._metric_combo.currentData() or "spots"
def _build_xrf(self) -> None:
lay = self._row("xrf")
lay.addWidget(micro_label("XRF"))
self._xrf_time = _EditCard("Count time", self._p, 1.0, "s", decimals=2,
minimum=0.01, maximum=600, step=0.5)
self._xrf_trans = _EditCard(
"Transmission", self._p,
float(self._defaults.get("transmission", 1.0)) * 100.0,
"%", decimals=1, minimum=0, maximum=100, step=1,
)
run = _primary_button("▶ Run XRF now", self._p)
run.clicked.connect(self.run_xrf)
self._run_buttons.append(run)
lay.addWidget(self._xrf_time)
lay.addWidget(self._xrf_trans)
lay.addWidget(run)
lay.addStretch(1)
def _build_collect(self) -> None:
lay = self._row("collect")
lay.addWidget(micro_label("COLLECT"))
# Rotation vs Screening mode
self._collect_mode = "rotation"
from PySide6.QtWidgets import QButtonGroup
mode_box = QWidget()
mode_box.setObjectName("ModeToggle")
mb = QHBoxLayout(mode_box)
mb.setContentsMargins(3, 3, 3, 3)
mb.setSpacing(0)
self._mode_group = QButtonGroup(mode_box)
self._mode_group.setExclusive(True)
self._mode_btns: dict[str, QPushButton] = {}
for key, label in (("rotation", "Rotation"), ("screening", "Screening")):
b = QPushButton(label)
b.setObjectName("ModeSeg")
b.setCheckable(True)
b.setCursor(Qt.PointingHandCursor)
b.setChecked(key == "rotation")
b.clicked.connect(lambda _=False, k=key: self._set_collect_mode(k))
self._mode_group.addButton(b)
mb.addWidget(b)
self._mode_btns[key] = b
lay.addWidget(mode_box)
lay.addWidget(self._make_source_toggle("collect"))
# Automate-parameters (simple data-collection strategy) toggle
self._automate_chip = QPushButton("Automate parameters")
self._automate_chip.setCheckable(True)
self._automate_chip.setCursor(Qt.PointingHandCursor)
self._automate_chip.toggled.connect(self._on_automate)
self._style_automate_chip(False)
lay.addWidget(self._automate_chip)
self._dtz = _EditCard("Detector", self._p,
float(self._defaults.get("dtz", 200.0)), "mm",
decimals=0, minimum=50, maximum=1000, step=5)
self._range = _EditCard("Range", self._p, 360.0, "°", decimals=1,
minimum=0.1, maximum=3600, step=10)
self._osc = _EditCard("Osc. width", self._p,
float(self._defaults.get("increment_omega_deg", 0.1)),
"°", decimals=2, minimum=0.01, maximum=10, step=0.05)
self._collect_trans = _EditCard(
"Transmission", self._p,
float(self._defaults.get("transmission", 1.0)) * 100.0,
"%", decimals=1, minimum=0, maximum=100, step=1,
)
self._fields["collect"] = [
self._dtz, self._range, self._osc, self._collect_trans,
]
self._auto_note = QLabel("Parameters computed automatically (simple strategy).")
self._auto_note.setStyleSheet(f"font-size:11px; color:{self._p.text_faint};")
self._auto_note.setVisible(False)
# Screening widgets (shown only in Screening mode)
self._screen_box = QWidget()
sb = QHBoxLayout(self._screen_box)
sb.setContentsMargins(0, 0, 0, 0)
sb.setSpacing(8)
self._screen_preset = QComboBox()
for label, data in (
("1 image", {"steps": 1, "omega_step_deg": 0}),
("2 images · 90°", {"steps": 2, "omega_step_deg": 90}),
("4 images · 90°", {"steps": 4, "omega_step_deg": 90}),
("3 images · 60°", {"steps": 3, "omega_step_deg": 60}),
("2 images · 45°", {"steps": 2, "omega_step_deg": 45}),
("4 images · 45°", {"steps": 4, "omega_step_deg": 45}),
):
self._screen_preset.addItem(label, data)
self._screen_preset.setStyleSheet(
f"QComboBox {{ background:{self._p.surface};"
f" border:1px solid {self._p.border_control}; border-radius:6px;"
f" padding:4px 8px; font-size:12px; }}"
)
self._screen_angle = _EditCard("Image angle", self._p, 1.0, "°",
decimals=2, minimum=0.01, maximum=90, step=0.1)
self._screen_time = _EditCard("Count time", self._p, 0.1, "s",
decimals=2, minimum=0.01, maximum=60, step=0.05)
sb.addWidget(self._screen_preset)
sb.addWidget(self._screen_angle)
sb.addWidget(self._screen_time)
self._screen_box.setVisible(False)
run = _primary_button("▶ Collect now", self._p)
run.clicked.connect(self.run_collect)
self._run_buttons.append(run)
lay.addWidget(self._dtz)
lay.addWidget(self._range)
lay.addWidget(self._osc)
lay.addWidget(self._collect_trans)
lay.addWidget(self._screen_box)
lay.addWidget(self._auto_note)
lay.addWidget(run)
lay.addStretch(1)
def _set_collect_mode(self, mode: str) -> None:
self._collect_mode = mode
screening = mode == "screening"
# rotation-only fields
for w in (self._range, self._osc, self._automate_chip):
w.setVisible(not screening)
if screening and self._automate:
self._automate_chip.setChecked(False) # screening overrides automate
self._screen_box.setVisible(screening)
def collect_mode(self) -> str:
return self._collect_mode
def collect_dtz(self) -> float:
return self._dtz.value()
def screening_params(self) -> dict:
return {
"preset": self._screen_preset.currentData(),
"image_angle": self._screen_angle.value(),
"count_time": self._screen_time.value(),
}
def _style_automate_chip(self, on: bool) -> None:
p = self._p
if on:
css = f"background:{p.accent}; color:#fff; border:none;"
else:
css = f"background:{p.chip_off_bg}; color:{p.text_faint}; border:none;"
self._automate_chip.setStyleSheet(
f"QPushButton {{ {css} border-radius:11px; padding:6px 12px;"
f" font-size:11px; font-weight:600; }}"
)
def _on_automate(self, on: bool) -> None:
self._automate = on
self._style_automate_chip(on)
# In automate mode the detailed cards are server-computed -> hide them.
for card in self._fields["collect"]:
card.setVisible(not on)
self._auto_note.setVisible(on)
self.automate_toggled.emit(on)
# --- raster draw toggle styling ---
def _on_draw_toggled(self, on: bool) -> None:
self._style_draw_btn(on)
self.raster_draw_toggled.emit(on)
def _style_draw_btn(self, on: bool) -> None:
p = self._p
if on:
self._draw_btn.setText("✏ Drawing… (drag on camera)")
css = f"background:{p.accent}; color:#fff; border:none;"
else:
self._draw_btn.setText("✏ Draw grid")
css = f"background:{p.surface}; border:1px solid {p.border_control}; color:{p.text_secondary};"
self._draw_btn.setStyleSheet(
f"QPushButton {{ {css} border-radius:8px; padding:8px 14px; font-size:12.5px; }}"
)
def set_draw_active(self, on: bool) -> None:
self._draw_btn.blockSignals(True)
self._draw_btn.setChecked(on)
self._style_draw_btn(on)
self._draw_btn.blockSignals(False)
# --- parameter accessors (used by the wiring layer) ---
def raster_cell_mm(self) -> tuple[float, float]:
return self._cell_x.value() / 1000.0, self._cell_y.value() / 1000.0
def raster_transmission(self) -> float:
return self._raster_trans.value() / 100.0
def xrf_params(self) -> tuple[float, float]:
return self._xrf_time.value(), self._xrf_trans.value() / 100.0
def collect_transmission(self) -> float:
return self._collect_trans.value() / 100.0
def collect_params(self) -> dict:
rng = self._range.value()
osc = self._osc.value()
steps = max(1, round(rng / osc)) if osc > 0 else 1
return {
"dtz": self._dtz.value(),
"incr_omega_deg": osc,
"steps": steps,
"transmission": self._collect_trans.value() / 100.0,
}
# --------------------------------------------------------------------------
class TransportRow(QWidget):
"""Play / pause / skip / end transport + NEXT ACTION + run status + Unmount."""
play = Signal()
pause = Signal()
skip = Signal()
end = Signal()
unmount = Signal()
def __init__(self, palette: Palette, parent=None):
super().__init__(parent)
self._p = palette
lay = QHBoxLayout(self)
lay.setContentsMargins(0, 0, 0, 0)
lay.setSpacing(14)
btns = QHBoxLayout()
btns.setSpacing(6)
self._play = self._tbtn("")
self._pause = self._tbtn("❚❚")
self._skip = self._tbtn("")
self._end = self._tbtn("", danger=True)
self._play.clicked.connect(self.play)
self._pause.clicked.connect(self.pause)
self._skip.clicked.connect(self.skip)
self._end.clicked.connect(self.end)
for b in (self._play, self._pause, self._skip, self._end):
btns.addWidget(b)
lay.addLayout(btns)
lay.addWidget(vline(palette))
na = QVBoxLayout()
na.setSpacing(1)
na.addWidget(micro_label("NEXT ACTION"))
self._next = mono_label("Center", 13, palette.text_primary, weight=600)
na.addWidget(self._next)
lay.addLayout(na)
self._status = QLabel("Idle")
self._status.setStyleSheet(f"font-size:11.5px; color:{palette.text_faint};")
lay.addWidget(self._status)
lay.addStretch(1)
self._unmount = QPushButton("⏏ Unmount")
self._unmount.setCursor(Qt.PointingHandCursor)
self._unmount.setStyleSheet(
f"QPushButton {{ background:{palette.surface};"
f" border:1px solid {palette.border_control}; border-radius:8px;"
f" padding:8px 16px; font-size:12.5px; color:{palette.danger}; }}"
)
self._unmount.clicked.connect(self.unmount)
lay.addWidget(self._unmount)
def _tbtn(self, glyph: str, danger: bool = False) -> QPushButton:
b = QPushButton(glyph)
b.setFixedSize(34, 34)
b.setCursor(Qt.PointingHandCursor)
b._danger = danger # type: ignore[attr-defined]
self._style_tbtn(b, active=False)
return b
def _style_tbtn(self, b: QPushButton, active: bool) -> None:
p = self._p
if active:
css = f"background:{p.accent}; color:#fff; border:none;"
else:
color = p.danger if getattr(b, "_danger", False) else p.text_secondary
css = f"background:{p.surface}; border:1px solid {p.border_control}; color:{color};"
b.setStyleSheet(
f"QPushButton {{ {css} border-radius:8px; font-size:11px; }}"
)
def update_from_state(self, state) -> None:
running = state.running
self._style_tbtn(self._play, active=running)
self._style_tbtn(self._pause, active=not running)
self._next.setText(state.next_stage().action_label)
if state.stopped_at_breakpoint():
self._status.setText("⏸ Stopped at breakpoint")
self._status.setStyleSheet(
f"font-size:11.5px; color:{self._p.breakpoint}; font-weight:600;"
)
elif running:
self._status.setText("● Running")
self._status.setStyleSheet(
f"font-size:11.5px; color:{self._p.success}; font-weight:600;"
)
else:
self._status.setText("Idle")
self._status.setStyleSheet(
f"font-size:11.5px; color:{self._p.text_faint};"
)
# --------------------------------------------------------------------------
class PipelinePanel(QWidget):
"""Interactive pipeline panel for BOTH mount states.
Mounted: full tracker (done states + breakpoints), enabled run buttons,
transport + unmount. Not mounted: preview tracker (Mount = start here), the
same contextual settings (editable, so parameters can be pre-adjusted) but
run buttons disabled and the transport hidden.
"""
stage_clicked = Signal(str)
breakpoint_toggled = Signal(str)
def __init__(self, palette: Palette, defaults: dict,
breakpoints_enabled: bool = True, parent=None):
super().__init__(parent)
self.setObjectName("BottomPanel")
self._p = palette
self._mounted = True
lay = QVBoxLayout(self)
lay.setContentsMargins(22, 14, 22, 14)
lay.setSpacing(12)
# header
head = QHBoxLayout()
head.setSpacing(10)
self._name = mono_label("", 14, palette.text_primary, weight=700)
self._badge = QLabel("MOUNTED")
self._badge.setStyleSheet(
f"font-size:10.5px; background:{palette.accent_tint_bg};"
f" color:{palette.accent}; padding:3px 9px; border-radius:11px; font-weight:600;"
)
self._meta = mono_label("", 11, palette.text_muted)
head.addWidget(self._name)
head.addWidget(self._badge)
head.addWidget(self._meta)
head.addStretch(1)
self._hint = QLabel("")
self._hint.setStyleSheet(f"font-size:11px; color:{palette.text_faint};")
head.addWidget(self._hint)
lay.addLayout(head)
# tracker
self.tracker = PipelineTracker(palette, breakpoints_enabled)
self.tracker.stage_clicked.connect(self.stage_clicked)
self.tracker.breakpoint_toggled.connect(self.breakpoint_toggled)
lay.addWidget(self.tracker)
# contextual settings
self.settings = ContextualSettings(palette, defaults)
lay.addWidget(self.settings)
# transport
self.transport = TransportRow(palette)
lay.addWidget(self.transport)
self.set_mounted(False)
def set_mounted(self, mounted: bool) -> None:
self._mounted = mounted
self._badge.setVisible(mounted)
self.transport.setVisible(mounted)
self.settings.set_run_enabled(mounted)
self.tracker.set_preview(not mounted)
if mounted:
self._hint.setText("↳ click a stage to act · tap its ⚑ to set a breakpoint")
else:
self._name.setText("No sample mounted")
self._name.setStyleSheet(self._name.styleSheet())
self._meta.setText("")
self._hint.setText("↳ click a stage to pre-set its parameters, then mount a sample")
def update_from_state(self, state) -> None:
self.tracker.update_from_state(state)
self.transport.update_from_state(state)
self.settings.show_stage(state.stage)
sample = state.mount_sample
if self._mounted and sample is not None:
self._name.setText(getattr(sample, "sample_name", ""))
puck = getattr(sample, "puck_name", "") or ""
loc = sample.loc_str() if hasattr(sample, "loc_str") else ""
self._meta.setText(f"{puck} · {loc}".strip(" ·"))
@@ -0,0 +1,70 @@
"""A small table of completed raster grids with per-row Goto."""
from __future__ import annotations
from PySide6.QtCore import Qt, Signal
from PySide6.QtWidgets import (
QDialog,
QHBoxLayout,
QHeaderView,
QPushButton,
QTableWidget,
QTableWidgetItem,
QVBoxLayout,
QWidget,
)
from aare.gui.new_gui.theme import Palette
class RasterGridsDialog(QDialog):
"""Lists CompletedRasterGridElem rows; emits goto_grid(index)."""
goto_grid = Signal(int)
def __init__(self, grids: list, palette: Palette, parent=None):
super().__init__(parent)
self._p = palette
self.setWindowTitle("Completed raster grids")
self.setModal(False)
self.resize(460, 280)
lay = QVBoxLayout(self)
lay.setContentsMargins(14, 14, 14, 14)
self._table = QTableWidget(0, 5)
self._table.setHorizontalHeaderLabels(
["#", "Omega", "Chi/Phi", "Grid", "Goto"]
)
self._table.verticalHeader().setVisible(False)
self._table.horizontalHeader().setSectionResizeMode(QHeaderView.Stretch)
lay.addWidget(self._table)
self.set_grids(grids)
def set_grids(self, grids: list) -> None:
self._table.setRowCount(0)
for i, elem in enumerate(grids):
req = getattr(elem, "request", None)
omega = getattr(req, "omega_deg", None)
stl = getattr(req, "smargon_top_left", None)
chi = getattr(stl, "chi_deg", None) if stl else None
phi = getattr(stl, "phi_deg", None) if stl else None
n_x = getattr(req, "n_x", 0)
n_y = getattr(req, "n_y", 0)
row = self._table.rowCount()
self._table.insertRow(row)
self._table.setItem(row, 0, QTableWidgetItem(str(i + 1)))
self._table.setItem(
row, 1, QTableWidgetItem("" if omega is None else f"{omega:.1f}°"))
cp = ""
if chi is not None or phi is not None:
cp = f"{chi if chi is not None else 0:.1f}/{phi if phi is not None else 0:.1f}"
self._table.setItem(row, 2, QTableWidgetItem(cp))
self._table.setItem(row, 3, QTableWidgetItem(f"{n_x}×{n_y}"))
btn = QPushButton("Goto")
btn.setCursor(Qt.PointingHandCursor)
btn.clicked.connect(lambda _=False, ix=i: self.goto_grid.emit(ix))
cell = QWidget()
cl = QHBoxLayout(cell)
cl.setContentsMargins(2, 2, 2, 2)
cl.addWidget(btn)
self._table.setCellWidget(row, 4, cell)
@@ -0,0 +1,337 @@
"""Sample Changer (TELL) panel for Manual mode.
Populated from the ``spreadsheet`` signal (SampleShortInfoList). Rows are grouped
by puck; clicking a row mounts that sample. The currently-mounted row is
highlighted. Status dot: mounted (accent) / collected (success) / ready (ring).
"""
from __future__ import annotations
from PySide6.QtCore import Qt, Signal
from PySide6.QtWidgets import (
QHBoxLayout,
QLabel,
QLineEdit,
QScrollArea,
QVBoxLayout,
QWidget,
)
from aare.gui.new_gui.theme import CHANGER_W, FONT_MONO_FALLBACK, Palette
from aare.gui.new_gui.widgets.common import section_label
def sample_label(s) -> str:
return getattr(s, "sample_name", "")
def sample_pos(s) -> str:
fn = getattr(s, "loc_str", None)
if callable(fn):
try:
return fn()
except Exception:
pass
return ""
def sample_puck(s) -> str:
return getattr(s, "puck_name", "") or ""
def sample_id(s):
return getattr(s, "db_id", id(s))
class SampleRow(QWidget):
"""A sample row. Left-click selects; right-click offers Mount."""
clicked = Signal(object) # select
mount_requested = Signal(object) # right-click -> mount
def __init__(self, sample, palette: Palette, mounted: bool = False,
collected: bool = False, selected: bool = False, parent=None):
super().__init__(parent)
self._sample = sample
self._p = palette
self.setAttribute(Qt.WA_StyledBackground, True)
self.setCursor(Qt.PointingHandCursor)
lay = QHBoxLayout(self)
lay.setContentsMargins(10, 9, 10, 9)
lay.setSpacing(10)
dot = QLabel()
dot.setFixedSize(9, 9)
if mounted:
dot.setStyleSheet(f"background:{palette.accent}; border-radius:4px;")
elif collected:
dot.setStyleSheet(f"background:{palette.success}; border-radius:4px;")
else:
dot.setStyleSheet(
f"border:2px solid {palette.pending_node}; border-radius:4px;"
" background:transparent;"
)
lay.addWidget(dot)
name = QLabel(sample_label(sample))
weight = 700 if mounted else 500
color = palette.text_primary if (mounted or collected) else palette.text_secondary
name.setStyleSheet(
f"font-family:{FONT_MONO_FALLBACK}; font-weight:{weight}; color:{color};"
" background:transparent;"
)
lay.addWidget(name)
if mounted:
badge = QLabel("MOUNTED")
badge.setStyleSheet(
f"font-size:10px; background:{palette.accent}; color:#fff;"
" padding:2px 7px; border-radius:9px; font-weight:600;"
)
lay.addWidget(badge)
lay.addStretch(1)
pos = QLabel(sample_pos(sample))
pos.setStyleSheet(
f"font-family:{FONT_MONO_FALLBACK}; font-size:11px;"
f" color:{palette.text_faint}; background:transparent;"
)
lay.addWidget(pos)
if mounted:
self.setStyleSheet(
f"SampleRow {{ background:{palette.accent_tint_bg};"
f" border:1px solid {palette.accent_tint_border}; border-radius:9px; }}"
)
elif selected:
self.setStyleSheet(
f"SampleRow {{ background:{palette.chip_off_bg};"
f" border:1px solid {palette.accent}; border-radius:9px; }}"
)
else:
self.setStyleSheet(
"SampleRow { border-radius:9px; }"
f" SampleRow:hover {{ background:{palette.chip_off_bg}; }}"
)
def mousePressEvent(self, event): # noqa: N802
if event.button() == Qt.LeftButton:
self.clicked.emit(self._sample)
elif event.button() == Qt.RightButton:
self._show_menu(event)
super().mousePressEvent(event)
def _show_menu(self, event) -> None:
from PySide6.QtWidgets import QMenu
menu = QMenu(self)
act = menu.addAction("Mount sample")
act.triggered.connect(lambda: self.mount_requested.emit(self._sample))
menu.exec(event.globalPosition().toPoint())
class SampleChangerPanel(QWidget):
"""Left panel in Manual mode. ``picking`` shows the 'pick to mount' hint.
Staff get a Samples / Reference-tools segmented toggle; reference tools mount
with ``reference=True``.
"""
sample_selected = Signal(object, bool) # sample, reference
def __init__(self, palette: Palette, parent=None):
super().__init__(parent)
self.setObjectName("SidePanel")
self.setFixedWidth(CHANGER_W)
self._p = palette
self._samples: list = []
self._reference_tools: list = []
self._mode = "samples" # 'samples' | 'reference'
self._is_staff = False
self._mounted_id = None
self._selected_sample = None
outer = QVBoxLayout(self)
outer.setContentsMargins(0, 0, 0, 0)
outer.setSpacing(0)
# header
header = QWidget()
header.setStyleSheet(f"border-bottom:1px solid {palette.border_panel};")
hl = QVBoxLayout(header)
hl.setContentsMargins(16, 14, 16, 12)
hl.setSpacing(10)
top = QHBoxLayout()
top.setContentsMargins(0, 0, 0, 0)
top.addWidget(section_label("SAMPLE CHANGER · TELL"))
top.addStretch(1)
self._hint = QLabel("pick to mount")
self._hint.setStyleSheet(
f"font-size:11px; color:{palette.accent}; font-weight:600;"
)
self._hint.setVisible(False)
top.addWidget(self._hint)
hl.addLayout(top)
# Samples / Reference-tools toggle (staff only)
self._toggle = QWidget()
self._toggle.setObjectName("ModeToggle")
tl = QHBoxLayout(self._toggle)
tl.setContentsMargins(3, 3, 3, 3)
tl.setSpacing(0)
from PySide6.QtWidgets import QButtonGroup, QPushButton
self._seg_group = QButtonGroup(self)
self._seg_group.setExclusive(True)
self._seg: dict[str, QPushButton] = {}
for key, label in (("samples", "Samples"), ("reference", "Reference tools")):
b = QPushButton(label)
b.setObjectName("ModeSeg")
b.setCheckable(True)
b.setCursor(Qt.PointingHandCursor)
b.clicked.connect(lambda _=False, k=key: self._set_mode(k))
self._seg_group.addButton(b)
tl.addWidget(b)
self._seg[key] = b
self._seg["samples"].setChecked(True)
self._toggle.setVisible(False)
hl.addWidget(self._toggle)
self._search = QLineEdit()
self._search.setPlaceholderText("Search samples…")
self._search.textChanged.connect(self._rebuild)
hl.addWidget(self._search)
outer.addWidget(header)
# scrollable list
self._scroll = QScrollArea()
self._scroll.setWidgetResizable(True)
self._scroll.setFrameShape(QScrollArea.NoFrame)
self._list = QWidget()
self._list_lay = QVBoxLayout(self._list)
self._list_lay.setContentsMargins(8, 6, 8, 6)
self._list_lay.setSpacing(2)
self._list_lay.addStretch(1)
self._scroll.setWidget(self._list)
outer.addWidget(self._scroll, 1)
# footer: Mount the selected sample
footer = QWidget()
footer.setStyleSheet(f"border-top:1px solid {palette.border_panel};")
fl = QVBoxLayout(footer)
fl.setContentsMargins(12, 10, 12, 12)
from PySide6.QtWidgets import QPushButton as _QPB
self._mount_btn = _QPB("⊕ Mount selected")
self._mount_btn.setCursor(Qt.PointingHandCursor)
self._mount_btn.setEnabled(False)
self._mount_btn.clicked.connect(self._on_mount_btn)
self._style_mount_btn()
fl.addWidget(self._mount_btn)
outer.addWidget(footer)
def _style_mount_btn(self) -> None:
p = self._p
if self._mount_btn.isEnabled():
css = f"background:{p.accent}; color:#fff; border:none;"
else:
css = (f"background:{p.chip_off_bg}; color:{p.text_faint};"
f" border:none;")
self._mount_btn.setStyleSheet(
f"QPushButton {{ {css} border-radius:8px; padding:9px 14px;"
f" font-size:12.5px; font-weight:600; }}"
)
def _on_row_clicked(self, sample) -> None:
self._selected_sample = sample
self._mount_btn.setEnabled(True)
self._style_mount_btn()
self._rebuild()
def _on_mount_btn(self) -> None:
if self._selected_sample is not None:
self.sample_selected.emit(self._selected_sample, self._mode == "reference")
def set_picking(self, picking: bool) -> None:
self._hint.setVisible(picking)
def set_mounted_id(self, mounted_id) -> None:
if mounted_id != self._mounted_id:
self._mounted_id = mounted_id
self._rebuild()
def set_samples(self, samples) -> None:
self._samples = list(samples)
self._rebuild()
def set_reference_tools(self, tools) -> None:
self._reference_tools = list(tools)
if self._mode == "reference":
self._rebuild()
def set_staff(self, is_staff: bool) -> None:
self._is_staff = is_staff
self._toggle.setVisible(is_staff)
if not is_staff and self._mode != "samples":
self._set_mode("samples")
def _set_mode(self, mode: str) -> None:
if mode == self._mode:
return
self._mode = mode
self._selected_sample = None
self._mount_btn.setEnabled(False)
self._style_mount_btn()
self._search.setPlaceholderText(
"Search reference tools…" if mode == "reference" else "Search samples…"
)
self._rebuild()
def _active_list(self) -> list:
return self._reference_tools if self._mode == "reference" else self._samples
def _clear_list(self) -> None:
while self._list_lay.count() > 1: # keep trailing stretch
item = self._list_lay.takeAt(0)
w = item.widget()
if w:
w.setParent(None)
w.deleteLater()
def _rebuild(self) -> None:
self._clear_list()
query = self._search.text().strip().lower()
is_ref = self._mode == "reference"
current_puck = None
count = 0
for s in self._active_list():
if query and query not in sample_label(s).lower():
continue
puck = sample_puck(s)
if puck != current_puck:
current_puck = puck
grp = QLabel(puck)
grp.setObjectName("MicroLabel")
grp.setContentsMargins(8, 8, 8, 4)
self._list_lay.insertWidget(self._list_lay.count() - 1, grp)
mounted = (self._mounted_id is not None
and sample_id(s) == self._mounted_id)
selected = (self._selected_sample is not None
and sample_id(s) == sample_id(self._selected_sample))
collected = getattr(s, "rotation_count", 0) > 0
row = SampleRow(s, self._p, mounted=mounted, collected=collected,
selected=selected)
row.clicked.connect(self._on_row_clicked)
row.mount_requested.connect(
lambda sm, ref=is_ref: self.sample_selected.emit(sm, ref)
)
self._list_lay.insertWidget(self._list_lay.count() - 1, row)
count += 1
if count == 0:
empty_txt = "No reference tools" if is_ref else "No samples"
empty = QLabel(empty_txt if not self._active_list() else "No matches")
empty.setAlignment(Qt.AlignCenter)
empty.setStyleSheet(
f"color:{self._p.text_faint}; font-size:11.5px; padding:12px;"
)
self._list_lay.insertWidget(self._list_lay.count() - 1, empty)
@@ -0,0 +1,138 @@
"""Fluorescence (XRF) spectrum view — QtCharts line plot + controls.
Shows the spectrum returned by ``fluorimeter_spectrum`` (energy keV vs counts)
and live frames from the fluorimeter SSE stream. Controls drive
fluorimeter_start / stop / request_snapshot on the DAQ.
"""
from __future__ import annotations
import numpy as np
from PySide6.QtCharts import QChart, QChartView, QLineSeries, QValueAxis
from PySide6.QtCore import Qt, Signal
from PySide6.QtGui import QPainter
from PySide6.QtWidgets import (
QHBoxLayout,
QLabel,
QPushButton,
QVBoxLayout,
QWidget,
)
from aare.gui.new_gui.theme import Palette
from aare.gui.new_gui.widgets.common import micro_label
class SpectrumView(QWidget):
"""Energy/counts spectrum with Start/Stop/Snapshot controls."""
start_clicked = Signal()
stop_clicked = Signal()
snapshot_clicked = Signal()
def __init__(self, palette: Palette, parent=None):
super().__init__(parent)
self._p = palette
self.setStyleSheet(f"background:{palette.surface};")
lay = QVBoxLayout(self)
lay.setContentsMargins(14, 12, 14, 12)
lay.setSpacing(8)
# header / controls
head = QHBoxLayout()
head.setSpacing(8)
head.addWidget(micro_label("XRF · FLUORESCENCE SPECTRUM"))
head.addStretch(1)
self._peak = QLabel("Peak: —")
self._peak.setStyleSheet(f"font-size:11px; color:{palette.text_muted};")
self._dead = QLabel("Dead time: —")
self._dead.setStyleSheet(f"font-size:11px; color:{palette.text_muted};")
head.addWidget(self._peak)
head.addWidget(self._dead)
lay.addLayout(head)
# chart
self._series = QLineSeries()
self._chart = QChart()
self._chart.addSeries(self._series)
self._chart.legend().hide()
self._axis_x = QValueAxis()
self._axis_x.setTitleText("Energy [keV]")
self._axis_y = QValueAxis()
self._axis_y.setTitleText("Counts")
self._chart.addAxis(self._axis_x, Qt.AlignBottom)
self._chart.addAxis(self._axis_y, Qt.AlignLeft)
self._series.attachAxis(self._axis_x)
self._series.attachAxis(self._axis_y)
self._view = QChartView(self._chart)
self._view.setRenderHint(QPainter.Antialiasing)
lay.addWidget(self._view, 1)
# control buttons
ctl = QHBoxLayout()
ctl.setSpacing(8)
start = self._btn("▶ Start", primary=True)
stop = self._btn("❚❚ Stop")
snap = self._btn("◍ Snapshot")
start.clicked.connect(self.start_clicked)
stop.clicked.connect(self.stop_clicked)
snap.clicked.connect(self.snapshot_clicked)
ctl.addWidget(start)
ctl.addWidget(stop)
ctl.addWidget(snap)
ctl.addStretch(1)
lay.addLayout(ctl)
def _btn(self, text: str, primary: bool = False) -> QPushButton:
b = QPushButton(text)
b.setCursor(Qt.PointingHandCursor)
p = self._p
if primary:
css = f"background:{p.accent}; color:#fff; border:none;"
else:
css = (f"background:{p.surface}; border:1px solid {p.border_control};"
f" color:{p.text_secondary};")
b.setStyleSheet(
f"QPushButton {{ {css} border-radius:8px; padding:7px 14px; font-size:12px; }}"
)
return b
# ----- updates -----
def update_spectrum(self, model) -> None:
"""From fluorimeter_spectrum_update (FluorescenceSpectrumOutputModel)."""
energy = getattr(model, "energy_eV", None)
spectrum = getattr(model, "spectrum", None)
if not energy or not spectrum or len(energy) != len(spectrum):
return
x = np.asarray(energy, dtype=float) / 1000.0 # keV
y = np.asarray(spectrum, dtype=float)
self._plot(x, y)
dead = getattr(model, "average_dead_time", None)
if dead is not None:
self._dead.setText(f"Dead time: {dead * 100.0:.2f}%")
def update_live(self, data, bkg, status) -> None:
"""From fluorimeter_update(list, list, int): counts vs channel."""
if not data:
return
y = np.asarray(data, dtype=float)
x = np.arange(y.size, dtype=float)
self._axis_x.setTitleText("Channel")
self._plot(x, y)
if status is not None and status >= 0:
self._dead.setText(f"Status: {status}")
def _plot(self, x: np.ndarray, y: np.ndarray) -> None:
order = np.argsort(x)
x, y = x[order], y[order]
self._series.clear()
# QLineSeries.append in a tight loop is fine for a few k points
pts = [(float(xi), float(yi)) for xi, yi in zip(x, y)]
for xi, yi in pts:
self._series.append(xi, yi)
if x.size:
self._axis_x.setRange(float(x.min()), float(x.max()))
self._axis_y.setRange(float(min(0.0, y.min())), float(max(1.0, y.max())))
mi = int(np.argmax(y))
self._peak.setText(f"Peak: {x[mi]:.3f} · {y[mi]:.0f} cts")