diff --git a/csaxs_bec/bec_ipython_client/plugins/flomni/AI_docs/BEC info.md b/csaxs_bec/bec_ipython_client/plugins/flomni/AI_docs/BEC info.md new file mode 100644 index 0000000..f2668d4 --- /dev/null +++ b/csaxs_bec/bec_ipython_client/plugins/flomni/AI_docs/BEC info.md @@ -0,0 +1,450 @@ +# BEC – Beamline Experiment Control: System Overview + +> **Erstellt:** April 2026 +> **Zweck:** Referenzdokument für Claude (AI-Assistent) zur Orientierung im BEC-Ökosystem am cSAXS-Beamline (X12SA, PSI). +> **Nicht entwickelt von Nutzer:** Der Nutzer entwickelt *BEC-Begleit-Software* für cSAXS und die 3D-Ptychographie-Setups (LamNI, OMNY), nicht den BEC-Kern selbst. + +--- + +## 1. Was ist BEC? + +**BEC** steht für **Beamline and Experiment Control**. Es ist ein modulares, microservice-basiertes System zur Steuerung von Experimenten an Großforschungsanlagen (Synchrotrons, Neutronenquellen). Entwickelt am PSI (Paul Scherrer Institut), Schweiz. + +- **Lizenz:** BSD-3-Clause +- **Sprache:** Python (>99%) +- **Python-Versionen:** 3.11, 3.12, 3.13 +- **Aktuelle Version:** ~3.117.x (bec-Kern), ~3.5.x (bec_widgets), ~1.36.x (ophyd_devices) +- **Kommunikations-Backbone:** Redis (Pub/Sub + Streams) +- **Dokumentation:** https://bec.readthedocs.io / https://beamline-experiment-control.readthedocs.io +--- + +## 2. Architektur-Überblick + +``` +┌──────────────────────────────────────────────────────────────────┐ +│ BEC Ecosystem │ +│ │ +│ ┌─────────────────┐ ┌─────────────────┐ │ +│ │ bec_ipython_ │ │ bec_widgets │ ← GUIs (PySide6) │ +│ │ client (CLI) │ │ (Qt/PySide6) │ │ +│ └────────┬────────┘ └────────┬────────┘ │ +│ │ │ │ +│ └──────────┬───────────┘ │ +│ │ bec_lib (gemeinsame Bibliothek) │ +│ │ Redis Connector / Messages / Endpoints │ +│ │ │ +│ ┌──────────▼───────────────────────────┐ │ +│ │ REDIS │ │ +│ │ (Pub/Sub + Streams, single source │ │ +│ │ of truth für alle Services) │ │ +│ └──────────┬───────────────────────────┘ │ +│ │ │ +│ ┌──────────▼──────────────────┐ │ +│ │ bec_server │ │ +│ │ ┌─────────────────────┐ │ │ +│ │ │ device_server │ │ ← ophyd / EPICS │ +│ │ │ scan_server │ │ │ +│ │ │ scan_bundler │ │ │ +│ │ │ file_writer │ │ │ +│ │ │ data_processing │ │ ← DAP Plugins │ +│ │ │ scihub │ │ ← Logbook, Atlas │ +│ │ └─────────────────────┘ │ │ +│ └──────────┬──────────────────┘ │ +│ │ │ +│ ┌──────────▼──────────────────┐ │ +│ │ Hardware / EPICS IOCs │ │ +│ │ (Motoren, Detektoren, ...) │ │ +│ └─────────────────────────────┘ │ +└──────────────────────────────────────────────────────────────────┘ + +Beamline-spezifische Plugins (z.B. csaxs_bec): + - Erweitern device_server, scan_server, file_writer, bec_widgets + - Laden über BEC Plugin-System (pyproject.toml entry points) +``` + +--- + +## 3. Kern-Repositories + +Alle unter: https://gitea.psi.ch/bec (Mirrors von https://github.com/bec-project) + +| Repository | Beschreibung | Wichtig für | +|---|---|---| +| **bec** | Haupt-Monorepo | Alles | +| **bec_widgets** | Qt/PySide6 GUI-Toolkit | GUI-Entwicklung | +| **ophyd_devices** | Geräteklassen (ophyd-Erweiterung) | Device-Entwicklung | +| **csaxs_bec** | cSAXS-spezifische Plugins | cSAXS / LamNI / OMNY | + +--- + +## 4. Repository: `bec` (Monorepo) + +**URL:** https://gitea.psi.ch/bec/bec +**GitHub:** https://github.com/bec-project/bec +**Struktur:** + +``` +bec/ +├── bec_lib/ ← Gemeinsame Python-Bibliothek +│ └── bec_lib/ +│ ├── client.py ← BECClient Hauptklasse +│ ├── device.py ← Device-Klassen (Wrapper um ophyd) +│ ├── devicemanager.py ← DeviceManager (dev.samx, etc.) +│ ├── endpoints.py ← Redis Message Endpoints (alle Topics) +│ ├── messages.py ← Message-Typen (Pydantic-ähnlich) +│ ├── redis_connector.py ← Redis-Verbindung (pub/sub, streams) +│ ├── connector.py ← Abstrakte Connector-Klasse +│ ├── scan_items.py ← Scan-Objekte +│ ├── queue_items.py ← Scan-Queue-Management +│ ├── callback_handler.py← Callback-System +│ ├── alarm_handler.py ← Alarm-Handling +│ ├── bec_service.py ← Basis-Klasse für alle BEC-Services +│ ├── bec_errors.py ← Fehlerklassen +│ ├── logger.py ← Logging-System +│ ├── file_utils.py ← Datei-Hilfsfunktionen +│ ├── live_scan_data.py ← Live-Daten während Scan +│ ├── dap_plugins.py ← Data Analysis Plugins +│ ├── dap_plugin_objects.py +│ ├── lmfit_serializer.py← lmfit-Integration (Kurvenanpassung) +│ ├── metadata_schema.py ← Scan-Metadata Schema +│ ├── plugin_helper.py ← Plugin-Hilfsfunktionen +│ ├── logbook_connector.py← Scilog/Logbook +│ ├── acl_login.py ← Zugangskontrolle +│ ├── bl_state_manager.py← Beamline State +│ ├── codecs.py ← Serialisierung (msgpack) +│ ├── numpy_encoder.py ← NumPy-Serialisierung +│ ├── configs/ ← Default-Konfigurationen +│ ├── procedures/ ← Scan-Prozeduren +│ └── utils/ ← Hilfsfunktionen +│ +├── bec_server/ ← Server-Seite (alle Microservices) +│ └── bec_server/ +│ ├── device_server/ ← Kommuniziert mit Hardware via ophyd/EPICS +│ ├── scan_server/ ← Führt Scans aus, verwaltet Queue +│ ├── scan_bundler/ ← Bündelt Scan-Daten (Device + Scan Events) +│ ├── file_writer/ ← Schreibt HDF5/NeXus-Dateien +│ ├── file_writer_plugins/← Plugin-System für Dateiformat +│ ├── data_processing/ ← Online-Datenanalyse (DAP) +│ ├── procedures/ ← Server-seitige Prozeduren +│ ├── scihub/ ← Anbindung externe Dienste (Atlas, Scilog) +│ └── bec_server_utils/ ← CLI-Argument-Parsing +│ +├── bec_ipython_client/ ← IPython CLI Interface +│ └── bec_ipython_client/ +│ ├── bec_startup.py ← Startup-Script (lädt beim `bec`-Command) +│ ├── main.py ← Einstiegspunkt +│ ├── bec_magics.py ← IPython Magic Commands (%bec, etc.) +│ ├── beamline_mixin.py ← Beamline-spezifische Mixins +│ ├── callbacks/ ← CLI-Callbacks (Progress, Printing) +│ ├── high_level_interfaces/ ← Höhere API (scans, dev, etc.) +│ └── plugins/ ← Plugin-Verzeichnis +│ +├── pytest_bec_e2e/ ← End-to-End Tests +├── docs/ ← Sphinx-Dokumentation +├── bin/ ← Start-Scripts +└── bec_config_template.yaml ← Beispiel-Konfiguration +``` + +### Wichtige Konzepte in bec_lib + +**Redis Endpoints** (`endpoints.py`): Alle Redis-Topics sind hier zentral definiert. +Beispiele: +- `MessageEndpoints.device_readback(device_name)` → live Gerätewert +- `MessageEndpoints.scan_segment` → Scan-Datenpunkte +- `MessageEndpoints.scan_status` → Scan-Status +**BECClient** (`client.py`): Haupt-Einstiegspunkt für alle Clients: +```python +from bec_lib.client import BECClient +bec = BECClient() +bec.start() +scans = bec.scans +dev = bec.device_manager.devices +``` + +--- + +## 5. Repository: `ophyd_devices` + +**URL:** https://gitea.psi.ch/bec/ophyd_devices +**GitHub:** https://github.com/bec-project/ophyd_devices +**Beschreibung:** Erweiterung von [ophyd](https://blueskyproject.io/ophyd/) für Geräte, die nicht durch Standard-EPICS-ophyd abgedeckt sind. + +``` +ophyd_devices/ +├── ophyd_devices/ +│ ├── devices/ ← Konkrete Geräteklassen (EpicsMotor-Subklassen, etc.) +│ ├── interfaces/ ← Abstrakte Interface-Klassen (SimplePositioner, etc.) +│ ├── sim/ ← Simulierte Geräte für Tests/Demos +│ ├── configs/ ← Beispiel-Gerätekonfigurationen (YAML) +│ ├── utils/ ← Hilfsfunktionen (Static Device Tests, etc.) +│ └── tests/ ← Unit-Tests +└── tests/ ← Integrations-Tests +``` + +**Zentrale Konzepte:** +- Geräte erben von ophyd (`Device`, `EpicsMotor`, `EpicsSignal`, etc.) +- BEC-spezifische Erweiterungen: Callbacks für Redis, BEC-Signale +- `interfaces/` enthält abstrakte Klassen wie `SimplePositioner` +- Simulierte Geräte in `sim/` für Offline-Entwicklung +--- + +## 6. Repository: `bec_widgets` + +**URL:** https://gitea.psi.ch/bec/bec_widgets +**GitHub:** https://github.com/bec-project/bec_widgets +**Framework:** PySide6 (Qt6) + +``` +bec_widgets/ +├── bec_widgets/ +│ ├── widgets/ +│ │ ├── plots/ ← Waveform, MultiWaveform, Image/Heatmap +│ │ ├── control/ ← PositionerBox, TweakWidget, ScanControl +│ │ ├── containers/ ← BECDockArea (Haupt-Layout-Container) +│ │ ├── dap/ ← Data Analysis Plugin Widgets (lmfit Dialog) +│ │ ├── editors/ ← Code/Config-Editoren +│ │ ├── progress/ ← Scan-Fortschrittsanzeige +│ │ ├── services/ ← Service-Status-Widgets +│ │ ├── utility/ ← Diverse Hilfs-Widgets +│ │ └── games/ ← Easter Egg 🎮 +│ ├── cli/ ← RPC-CLI Bindings (auto-generiert via bw-generate-cli) +│ ├── applications/ ← Fertige Anwendungen (BEC Launcher, Designer, Terminal) +│ ├── utils/ ← Plugin-Template-Generator, Hilfsfunktionen +│ ├── examples/ ← Beispiel-Widgets +│ └── assets/ ← Icons, Bilder +├── tests/ +└── docs/ +``` + +**Kern-Konzepte:** +- `BECWidget` (Basisklasse): Alle Widgets erben davon. Bietet BEC/Redis-Verbindung, RPC. +- `BECDispatcher`: Vermittelt Redis-Events an Qt-Slots. +- `USER_ACCESS` Liste: Definiert, welche Methoden über RPC erreichbar sind. +- `SafeSlot`: Thread-sicherer Qt-Slot Decorator. +- `BECDockArea`: Haupt-GUI-Container mit Drag-and-Drop-Docking. +**Widget erstellen (Minimalbeispiel):** +```python +from bec_widgets import BECWidget, SafeSlot +from bec_lib.endpoints import MessageEndpoints + +class MyWidget(BECWidget, QWidget): + USER_ACCESS = ["my_method"] + + def __init__(self, parent=None, **kwargs): + super().__init__(parent=parent, **kwargs) + self.bec_dispatcher.connect_slot( + self.on_data, + MessageEndpoints.device_readback("samx") + ) + + @SafeSlot(dict, dict) + def on_data(self, data, meta): + ... +``` + +**CLI starten:** `bec` → öffnet IPython + BECDockArea GUI +**Designer:** `bec-designer` → Qt Designer mit BEC-Widgets +**RPC generieren:** `bw-generate-cli --target ` + +--- + +## 7. Repository: `csaxs_bec` + +**URL:** https://gitea.psi.ch/bec/csaxs_bec +**Beschreibung:** cSAXS-spezifische Plugins und Konfigurationen für BEC (X12SA Beamline, PSI) + +``` +csaxs_bec/ +├── csaxs_bec/ +│ ├── devices/ ← cSAXS-Hardware-Geräteklassen +│ │ ├── epics/ ← Standard EPICS Geräte (MCS-Karte, etc.) +│ │ ├── ids_cameras/ ← IDS-Kameras (Alignment-Kameras) +│ │ ├── jungfraujoch/ ← JungFrauJoch (JFJ) Detektor-Integration +│ │ ├── npoint/ ← nPoint Piezo-Positionierer (Nano-Scans) +│ │ ├── omny/ ← OMNY Tomographie-Stage +│ │ ├── panda_box/ ← PandaBox Timing/Trigger-System +│ │ ├── pseudo_devices/ ← Pseudo-Geräte (berechnete Positionen) +│ │ ├── sls_devices/ ← SLS-spezifische Geräte (Synchrotron Light Source) +│ │ ├── smaract/ ← SmarAct Piezo-Positionierer +│ │ ├── tests_utils/ ← Test-Hilfsfunktionen (patch_dual_pvs) +│ │ └── device_list.md ← Dokumentation aller Geräte +│ │ +│ ├── scans/ ← cSAXS-spezifische Scan-Plugins +│ │ ├── flomni_fermat_scan.py ← FlOMNI Fermat-Spiral Scan (Ptychographie) +│ │ ├── omny_fermat_scan.py ← OMNY Fermat-Spiral Scan +│ │ ├── LamNIFermatScan.py ← LamNI Fermat-Scan (3D Ptychographie!) +│ │ ├── jungfrau_joch_scan.py ← JFJ-spezifischer Scan +│ │ ├── sgalil_grid.py ← SGalil Grid-Scan (Galil-Controller) +│ │ ├── owis_grid.py ← OWIS Grid-Scan +│ │ ├── scan_plugin_template.py← Vorlage für neue Scan-Plugins +│ │ └── metadata_schema/ ← cSAXS Metadata-Schema +│ │ +│ ├── bec_widgets/ ← cSAXS-spezifische GUI-Widgets +│ │ ├── widgets/ +│ │ │ ├── xray_eye/ ← X-Ray Eye Widget (Kamera-Alignment-Tool) +│ │ │ └── client.py ← RPC-Client für cSAXS-Widgets +│ │ └── auto_updates/ ← Automatische GUI-Updates +│ │ +│ ├── bec_ipython_client/ ← cSAXS CLI-Erweiterungen +│ │ └── ... ← Web-Generator, Auth-Integration +│ │ +│ ├── device_configs/ ← YAML-Gerätekonfigurationen +│ │ └── ... ← z.B. bec_device_config_sastt.yaml +│ │ +│ ├── file_writer/ ← cSAXS-spezifischer File Writer Plugin +│ ├── dap_services/ ← Online Datenanalyse (DAP) Services +│ ├── macros/ ← BEC-Makros +│ ├── services/ ← Zusatz-Services +│ └── deployments/ ← Deployment-Konfigurationen +│ +├── bin/ ← Start-Scripts +├── docs/ ← Dokumentation +└── tests/ ← Tests +``` + +### Hardware am cSAXS (X12SA) + +| Gerät | Klasse/Modul | Typ | +|---|---|---| +| Eiger | EPICS IOC | Röntgen-Flächendetektor | +| Pilatus 300K | EPICS IOC | Röntgen-Flächendetektor | +| FalconX1 | EPICS IOC | Fluoreszenz-Detektor | +| JungFrauJoch (JFJ) | `devices/jungfraujoch/` | Röntgen-Detektor (High-rate) | +| OMNY | `devices/omny/` | Tomographie-Drehtisch | +| nPoint | `devices/npoint/` | Piezo-Positionierer (Nano) | +| SmarAct | `devices/smaract/` | Piezo-Positionierer | +| PandaBox | `devices/panda_box/` | Timing/Trigger-System | +| MCS-Karte | `devices/epics/` | Multi-Channel Scaler | +| IDS-Kameras | `devices/ids_cameras/` | Sichtbare-Licht-Kameras | +| SGalil | EPICS/Config | Galil-Motorcontroller | +| OWIS | Config | Motorcontroller | +| Delay Generator (DDG) | EPICS IOC | Pulsgenerator (Timing) | + +### Scans am cSAXS + +| Scan | Datei | Verwendung | +|---|---|---| +| `sgalil_grid` | `sgalil_grid.py` | Standard-Raster-Scan (Galil-Controller) | +| `omny_fermat_scan` | `omny_fermat_scan.py` | OMNY Fermat-Spiral (Ptychographie) | +| `flomni_fermat_scan` | `flomni_fermat_scan.py` | FlOMNI Fermat-Spiral | +| `LamNIFermatScan` | `LamNIFermatScan.py` | **3D Ptychographie (LamNI)** | +| `jungfrau_joch_scan` | `jungfrau_joch_scan.py` | JFJ-Burst-Akquisition | +| `owis_grid` | `owis_grid.py` | OWIS-Raster-Scan | + +--- + +## 8. Beamline-Setup: cSAXS (X12SA) + +**Beamline:** X12SA, Paul Scherrer Institut (PSI), Schweiz +**Synchrotron:** SLS (Swiss Light Source) + +### 3D Ptychographie / LamNI + +**LamNI** = *Laminar Nano-Imaging* – das 3D Ptychographie-Setup am cSAXS. +Relevante Komponenten: +- **Scan:** `LamNIFermatScan` (Fermat-Spiral, optimiert für Ptychographie) +- **Positionierung:** nPoint Piezo-Stages (Nanometer-Präzision) + übergeordnete Motoren +- **Detektor:** Eiger oder ähnlich (kohärentes Streumuster) +- **Timing:** PandaBox oder Delay-Generator +### OMNY + +OMNY = Tomographie-System mit Drehtisch. +- **Scan:** `omny_fermat_scan` +- **Gerät:** `devices/omny/` +- Verwendet einen dedizierten "Flyer" für kontinuierliche Akquisition +### Startup am cSAXS + +```bash +# BEC-Server starten (auf pc15543) +cd ~/Data10/software +source bec_venv/bin/activate +bec-server start +tmux attach -t bec + +# BEC-Client starten +bec + +# Gerätekonfiguration laden +bec.config.update_session_with_file('~/Data10/software/csaxs-bec/bec_plugins/configs/bec_device_config_sastt.yaml') +``` + +--- + +## 9. BEC Plugin-System + +BEC nutzt Python Entry Points für Plugins. Ein Plugin-Paket (z.B. `csaxs_bec`) registriert seine Komponenten in `pyproject.toml`: + +```toml +[project.entry-points."bec.plugins"] +csaxs_bec = "csaxs_bec" + +[project.entry-points."bec.scan_plugins"] +csaxs_scans = "csaxs_bec.scans" + +[project.entry-points."bec.file_writer_plugins"] +csaxs_writer = "csaxs_bec.file_writer" +``` + +BEC lädt automatisch alle registrierten Plugins beim Start. + +--- + +## 10. Kommunikation & Datenfluss + +``` +Hardware → EPICS IOC → ophyd (device_server) → Redis + ↓ +Client (bec_lib) ← Redis ← scan_bundler ← scan_server + ↓ + file_writer → HDF5/NeXus Dateien + ↓ + data_processing (DAP) → Online-Analyse +``` + +**Wichtige Redis Message-Typen** (in `bec_lib/messages.py`): +- `DeviceMessage` – Gerätewerte +- `ScanMessage` – Scan-Informationen +- `BECStatus` – Service-Status +- `AlarmMessage` – Alarme/Fehler +- `LogMessage` – Log-Einträge +- `ScilogMessage` – Logbuch-Einträge +--- + +## 11. Entwicklungs-Workflow + +### Code-Standards +- **Formatter:** Black (`--line-length=100 --skip-magic-trailing-comma`) +- **Import-Sortierung:** isort (`--profile=black`) +- **Linter:** Pylint +- **Tests:** pytest +- **Commit-Format:** Conventional Commits (`feat:`, `fix:`, `refactor:`, etc.) +### Typisches Widget-Entwicklungs-Muster (csaxs_bec) + +1. Widget-Klasse in `csaxs_bec/bec_widgets/widgets//` erstellen +2. Von `BECWidget` + Qt-Widget erben +3. `USER_ACCESS` für RPC definieren +4. Auf Redis-Endpoints subscriben via `bec_dispatcher.connect_slot()` +5. RPC-Client generieren: `bw-generate-cli --target csaxs_bec` +6. Qt-Designer-Plugin wird automatisch registriert +### Typisches Scan-Entwicklungs-Muster + +1. Scan-Klasse in `csaxs_bec/scans/` erstellen +2. Von BEC-Basis-Scan-Klasse erben +3. In `pyproject.toml` registrieren +4. Zugriff im Client: `scans.mein_scan(...)` +--- + +## 12. Nützliche Links + +| Ressource | URL | +|---|---| +| BEC Gitea | https://gitea.psi.ch/bec | +| BEC GitHub | https://github.com/bec-project | +| BEC Docs | https://bec.readthedocs.io | +| BEC Widgets Docs | https://bec-widgets.readthedocs.io | +| ophyd Docs | https://blueskyproject.io/ophyd/ | +| cSAXS BEC Repo | https://gitea.psi.ch/bec/csaxs_bec | + +--- + +*Dieses Dokument sollte dem Projekt hinzugefügt werden und dient als primäre Referenz für Claude in zukünftigen Gesprächen.* \ No newline at end of file diff --git a/csaxs_bec/bec_ipython_client/plugins/flomni/AI_docs/E2E_TESTING_GUIDE.md b/csaxs_bec/bec_ipython_client/plugins/flomni/AI_docs/E2E_TESTING_GUIDE.md new file mode 100644 index 0000000..7abbda2 --- /dev/null +++ b/csaxs_bec/bec_ipython_client/plugins/flomni/AI_docs/E2E_TESTING_GUIDE.md @@ -0,0 +1,282 @@ +# How E2E Tests Work in csaxs_bec + +**Purpose of this document:** a plugin-agnostic guide to writing and running +end-to-end tests against a live simulated BEC deployment in this repo. It +generalizes the patterns built for the flOMNI tomo-queue e2e suite +(`omny_e2e_tests/`) so the same approach can be reused for LamNI, OMNY, or any +other plugin — without re-deriving the traps below from scratch. If you are +an AI coding assistant asked to add or extend e2e tests in this repo, read +this file first. + +Companion docs, for concrete worked examples: +- `SIMULATED_ENDSTATIONS.md` — what the simulated device layer actually is + (protocol-level socket injection) and how to bring a sim session up. +- `TOMO_QUEUE_TESTING.md` — the fullest worked example: a real e2e checklist + built and passed against the flOMNI sim, including every gotcha in this + document as it was actually hit. + +--- + +## 1. What "e2e" means here, and when you need it + +A **unit test** exercises code with everything below it mocked or stubbed. An +**e2e test** in this repo means: a real `BECIPythonClient`, a real running +`ScanServer`/`ScanBundler`/`DeviceServer`/`SciHub`, real Redis, and real +plugin/device Python code (`Flomni`, `dev.*`, ...) — running against the +**simulated** device layer (`csaxs_bec/devices/sim/*`, see +`SIMULATED_ENDSTATIONS.md`) instead of real hardware. Nothing about the +control-flow code under test is mocked; only the wire protocol at the very +bottom is simulated. + +Write an e2e test (instead of, or in addition to, a unit test) when: +- the thing you need to prove is that a **global-var-backed** persistence + mechanism actually round-trips through real Redis (a mock client's + `get_global_var`/`set_global_var` proves nothing about serialization). + See "Redis-backed jobs..." below for a case this bit us in practice, and note this is different from + Redis contamination *between test runs*, which is §4 below. +- you need to prove a **live scan/move actually happened** — e.g. that a + queued job's parameters were really restored onto the live property a + scan reads from, not just that the right method was called with the right + arguments. +- you're testing **crash-resume** or **concurrency** — behavior that only + exists because state lives in a shared external store (Redis) that + survives process death, and multiple independent clients can touch it at + once. A mock can't simulate "the process was SIGKILLed." +- you're testing something that depends on **real device motion timing** + (progress reporting, ETA estimates, ordering of events during a move). + +Prove the logic with a fast mock/unit test first if you can (see +`TOMO_QUEUE_TESTING.md` §2 for an example of extracting real source via AST +into a mock harness) — it's much cheaper to iterate on. The e2e test is what +makes it *actually* verified, not just internally consistent. + +--- + +## 2. Folder convention: `omny_e2e_tests/`, not `tests/` + +E2e tests for this repo live in a **separate top-level folder**, +`omny_e2e_tests/`, not under `tests/`. This is deliberate: gitea's CI +(`.gitea/workflows/ci.yml`) runs `pytest ... ./csaxs_bec/tests/` +unconditionally on every push/PR, but these tests need a live sim (a running +BEC deployment + Redis), not just a Python environment. Keeping them +physically outside `tests/` means: +- they never get silently swept into (and slow down, or fail in) the + standard CI run, +- there's no confusion about whether a given `tests/...` path is a fast unit + test or a slow e2e test that assumes a live sim is already up. + +Run them explicitly: `pytest omny_e2e_tests/` (or a specific file/test within +it), against a sim session you've already started — see +`SIMULATED_ENDSTATIONS.md` for how to bring one up. + +If you're adding e2e coverage for a new plugin, put it under +`omny_e2e_tests/` too (a subfolder per plugin is fine once there's more than +one), not under `tests/`. + +--- + +## 3. The trap: don't use the shipped demo-config client fixtures + +BEC ships a pytest plugin, **`pytest_bec_e2e`** (registered `pytest11` entry +point, declared as a `dev` extra in `pyproject.toml`), providing a **live** +`BECClient` fixture stack: real server, real Redis, real global vars. Without +`--start-servers` it **attaches to an already-running server** — exactly the +sim session you started by hand. + +**Do not use `bec_client_lib_with_demo_config` / `bec_ipython_client_with_demo_config` +as they ship.** Both call `bec.config.load_demo_config(force=True)` internally. +Against a running sim session, this **overwrites the loaded simulated device +config** (e.g. `simulated_flomni.yaml`) with BEC's own generic demo devices — +silently breaking every test that assumes your plugin's real devices exist. + +Instead, write your own fixture that reuses the lower-level plumbing those +fixtures are built on (`bec_servers`, `bec_redis_fixture`, +`bec_services_config_file_path`) but either loads your own device config +explicitly, or — simpler, and what `omny_e2e_tests/` does — assumes the +already-running sim session has it loaded, and never calls +`load_demo_config()`/`update_session_with_file()` at all. + +--- + +## 4. The fixture pattern: a `build_()` bootstrap + +The reusable core, worth more than any individual test: a small set of files +that build a real, working plugin instance (`Flomni`, or your plugin's +equivalent) pointed at the running sim, with every real-instrument/test-only +side effect handled **once**, in one place. + +- **`_bootstrap.py`** — `build_(services_config_path) -> (bec, obj)`: + connects a `BECIPythonClient` (no demo-config load, see §3), does the + builtins/reload bootstrap (§5.2), neutralizes side effects (§5.3), and + performs any one-time real-instrument setup your plugin needs before it's + usable (§5.4). Also exposes a `shutdown(bec)` helper. +- **`conftest.py`** — the actual pytest fixture (e.g. `flomni_sim`). Calls + `build_()`, and snapshots + **resets to a known-empty state** + + restores any shared global-var state around each test (§6 — do not skip + the reset step). +- **`_run__subprocess.py`** — a standalone script, *not* a pytest file, + that calls `build_()` and then does the one blocking/dangerous + thing your crash/concurrency tests need to kill or race (e.g. + `flomni.tomo_queue_execute()`). Needed because of the main-thread + constraint in §5.1 — see that section for why this can't just be a Python + thread inside the test process. +- **`__helpers.py`** — everything else shared across test files: fast + test-data presets (so a real operation finishes in seconds, not minutes — + §5.5), a `spawn_..._subprocess()` wrapper around `subprocess.Popen` for the + script above, a `wait_until(predicate, timeout)` poller, and any + background-thread "sample state while something blocking runs in the + foreground" helper (§5.1). + +Every future e2e effort for the *same* plugin should extend these four files, +not duplicate their logic. A new plugin gets its own set, following the same +shape. + +--- + +## 5. Gotchas (hard-won, apply generally — not bugs, just non-obvious) + +These came from getting a real flOMNI scan to actually run end-to-end; none +of them are specific to tomo-queue, and any new e2e suite in this repo will +likely hit the same ones. + +### 5.1 Main-thread-only live-update machinery + +`BECIPythonClient`'s live-update machinery (anything that goes through +`ipython_live_updates.process_request` — which includes ordinary device +moves like `scans.umv(...)`) installs a `SIGINT` handler **per request**. +Python only allows `signal.signal()` from a process's real main thread. + +Consequence: **the actual scan/move/queue-execute call can never run on a +background Python thread.** Two different escapes, depending on what you +need: +- To *observe* live state (progress, a global var) while a blocking call + runs in the foreground on the main thread: sample on a background thread + instead (see `ProgressSampler` in `omny_e2e_tests/_queue_helpers.py`). +- To *kill* a running call (crash-resume tests) or run *two* independent + sessions concurrently (concurrency tests): spawn a real OS subprocess (its + own real main thread) via the `_run__subprocess.py` script, not a + thread. `subprocess.Popen` + `SIGKILL`/`terminate()`, not + `threading.Thread` + some cooperative stop flag. + +### 5.2 The builtins/reload bootstrap is order-sensitive + +Plugin modules commonly have module-level helpers (e.g. `flomni.py`'s +`def umv(*args): return scans.umv(...)`) that resolve globals like `scans` +from **the module's own globals**, populated only by an +`if builtins.__dict__.get("bec") is not None:` block that runs once, at +**import time**. If the module is imported (even transitively, by an +unrelated earlier import) before `builtins.bec` is set, that block never +runs, and the helper `NameError`s the first time it's actually called — +possibly minutes into a test, on the call that matters. + +Fix, in this order: set `builtins.__dict__["bec"]` (and `dev`, `scans`, ...) +**first**, then `importlib.reload()` the plugin module so the +import-time block re-runs against the now-populated builtins. This is needed +even in a fresh subprocess — the module may already have been imported once, +transitively, before your bootstrap code got to run. + +### 5.3 Real side effects must be neutralized for tests + +`Flomni.__init__` (and likely your plugin's `__init__`) has real, +unconditional side effects that are wrong in a test process: starting a +local HTTP server and background threads that POST to a real external URL +(webpage generator), sending a real SciLog summary at the end of every scan +regardless of whether SciLog is configured/reachable, blocking on an +operator confirmation prompt (`yesno`) that would otherwise hang a headless +test run forever. + +Patch these **once**, as plain class-attribute assignment in +`neutralize_side_effects()` inside `_bootstrap.py` — not with pytest's +`monkeypatch` per test, since every caller (the in-process fixture, the +subprocess script) wants the same patches for the life of the whole process, +and there's nothing to revert. Find these by actually running an +unpatched instantiation once and see what tries to reach the network in the +test log, wraps every scan in `try/except RuntimeError` — don't just guess. + +### 5.4 One-time real-instrument setup you must satisfy before anything works + +Some plugins need a real hardware-adjacent precondition satisfied before +their normal operations will run at all — e.g. flOMNI's RT interferometer +feedback loop must be enabled, and the sample stage moved near its "in" +position, before a scan can start; a second call must check whether that's +already true (the check itself, or the setup call, may be expensive — e.g. +re-zeroing interferometers — so don't redo it if a previous session in the +same test run already did). This kind of thing generally isn't visible from +reading the plugin's own primary API — it's usually folded into some other +higher-level "alignment" or "init" flow that tests otherwise skip. Find it by +tracing what a manual/real operator session does before their first scan, +not by trial and error against test failures. + +### 5.5 Make the real operation fast enough to actually test + +A real tomogram or scan can take many minutes at production settings — too +slow to run repeatedly in a test suite, especially for crash tests that need +to interrupt it mid-flight without the whole suite taking forever. Build a +"fast" parameter preset (single point per position instead of a full raster, +short per-point dwell/counting time, small field of view, coarse angular +stepping, ...) that still exercises the *real* code path, just with a much +smaller N — see `SHORT_SCAN_PARAMS`/`FAST_STEPSIZE` in +`omny_e2e_tests/_queue_helpers.py` for a concrete example (a full 8-subtomo +tomogram in roughly a minute, still long enough to interrupt mid-scan for +crash-resume tests). + +--- + +## 6. Redis state is not reset between separate `pytest` invocations + +**This is the single most likely cause of a mysterious, hard-to-reproduce +e2e test failure in this repo, and it is easy to build a fixture that looks +correct but doesn't actually protect against it.** + +The simulated BEC deployment's Redis instance is a **long-lived shared +resource** — it persists across separate `pytest` invocations, across a +crashed test run that never reached its fixture's `finally` block, and +across manual poking at the same sim session (e.g. someone debugging by +hand against the same Redis). A fixture that only **snapshots the current +value and restores it afterward** — which is the right pattern for not +disturbing a real operator's concurrent session — does **not** protect +against inheriting contamination that was already there *before* your test +started. + +Concretely: this bit the tomo-queue command-job e2e suite. A prior debugging +session had left a stale job stuck at `status: "running"` in the +`tomo_queue` global var. Every subsequent test that assumed it started from +an empty queue failed in confusing, seemingly-unrelated ways — "queue not +empty after a rejected add", `KeyError: 'kind'` on an old-format entry that +had nothing to do with the test that hit it. The fix +(`omny_e2e_tests/conftest.py`) is to **explicitly reset the relevant global +var(s) to a known-empty value at fixture setup**, in addition to (not +instead of) snapshotting and restoring around the test: + +```python +queue_snapshot = bec.get_global_var(_QUEUE_VAR) +bec.set_global_var(_QUEUE_VAR, []) # <- the part that's easy to forget +try: + yield flomni +finally: + bec.set_global_var(_QUEUE_VAR, queue_snapshot) +``` + +**Any new e2e fixture that reads/writes a global var must reset it to a +known value at setup, not just restore whatever was there afterward.** If +you hit a failure that doesn't make sense given the test's own logic, check +for leftover state before assuming it's a real regression: +`redis-cli get "user/vars/"`. + +--- + +## 7. Checklist for adding a new e2e test file + +1. Does a `build_()`-style bootstrap already exist for this plugin? + If not, build one following §4, working through §5's gotchas as you hit + them (you will). +2. Does the fixture reset every global var it touches to a known-empty value + at setup (§6), not just snapshot/restore? +3. If the test needs to kill a running operation or run two sessions + concurrently, does it use a real subprocess (§5.1), not a thread? +4. Is there a "fast" parameter preset so the test doesn't take minutes to + run (§5.5)? +5. Does the test file live under `omny_e2e_tests/` (§2), not `tests/`? +6. Run it against a live sim session before calling it done — see + `SIMULATED_ENDSTATIONS.md` for bringing one up. Nothing here is verified + until it has actually run against the sim (or real instrument). diff --git a/csaxs_bec/bec_ipython_client/plugins/flomni/AI_docs/Endstation INFO flomni lamni omny.md b/csaxs_bec/bec_ipython_client/plugins/flomni/AI_docs/Endstation INFO flomni lamni omny.md new file mode 100644 index 0000000..097d940 --- /dev/null +++ b/csaxs_bec/bec_ipython_client/plugins/flomni/AI_docs/Endstation INFO flomni lamni omny.md @@ -0,0 +1,607 @@ +# cSAXS Setups: LamNI, OMNY, FlOMNI – Vollständige technische Referenz + +> **Quellen:** Quellcode `csaxs_bec` + vier peer-reviewed Publikationen: +> - Holler et al., *J. Synchrotron Rad.* **27**, 730–736 (2020) → LamNI +> - Holler et al., *Rev. Sci. Instrum.* **89**, 043706 (2018) → OMNY +> - Holler et al., *Rev. Sci. Instrum.* **83**, 073703 (2012) → FlOMNI (RT-Vorgänger) +> - Holler & Raabe, *Opt. Eng.* **54**, 054101 (2015) → Tracking-Interferometer + +--- + +## ❗ WICHTIGE KORREKTUR: Was ist LamNI wirklich? + +**LamNI = Laminographic Nano-Imaging** (NICHT "Laminar Nano-Imaging"!) + +LamNI ist ein Instrument für **ptychographische Röntgen-Laminographie** (PyXL). +Laminographie unterscheidet sich grundlegend von Tomographie: + +| | Tomographie (OMNY) | Laminographie (LamNI) | +|---|---|---| +| Rotationsachse | ⊥ zum Röntgenstrahl (90°) | Geneigt zum Strahl (≠ 90°) | +| Probenform ideal | Zylindrisch (Pfeiler/Pillar) | Planar (flache Chips, Schichten) | +| FOV-Winkelbereich | 0°–180° (vollständig) | 0°–360° (voller Umlauf nötig) | +| Fehlende Information | Keine (vollständig) | Missing cone (statt Wedge) | +| Präparation | Destruktiv (FIB-Pillar nötig) | Minimal (großflächige Proben!) | +| Anwendung bei PSI | OMNY | LamNI | + +**Laminographiewinkel bei LamNI:** 61° (zwischen Rotationsachse und Röntgenstrahl) +Erzeugt durch: Erst 15° Kippen um x-Achse, dann 60° um y-Achse. + +**Das erklärt den Code direkt:** +```python +MOVEMENT_SCALE_X = np.sin(np.radians(15)) * np.cos(np.radians(30)) +MOVEMENT_SCALE_Y = np.cos(np.radians(15)) +``` +Die 15° entsprechen der Stage-Verkippung in x. Die Koordinatentransformation ist notwendig, weil der Piezo-Scanner in der geneigten Stage-Ebene bewegt, die Interferometer aber im Laborrahmen messen. + +--- + +## 1. LamNI – Laminographic Nano-Imaging + +### Physikalisches Prinzip + +Kohärente Röntgenstrahlung (~6–10 keV) wird durch eine Fresnel-Zonenplatte (FZP) fokussiert. Die Probe wird in einem Fermat-Spiralmuster abgetastet (Ptychographie). Für die 3D-Information wird die Probe um die geneigte Achse (61° zur Strahlrichtung) rotiert. Das erlaubt die Abbildung **flacher, großflächiger Proben** (ICs, Schichten) ohne destruktive Pillar-Präparation. + +**Anwendungen:** +- Integrierte Schaltkreise (IC, 16-nm-Technologie-Knoten demonstriert: 18.9 nm Auflösung) +- Magnetische Strukturen (zeitaufgelöste Laminographie, 50 nm / 70 ps) +- Großflächige Materialien in 3D +### Hardware-Komponenten (aus Paper 2020) + +``` +LamNI Mechanik (110 × 130 cm Grundplatte, 1 Tonne Gewicht) +│ +├── Röntgenoptik (upstream) +│ ├── FZP – auf xyz-Stages (Steinmeyer Mechatronik, Schrittmotoren) +│ ├── CS (Central Stop) – auf xy-Stages (SmarAct GmbH) +│ ├── OSA (Order Sorting Aperture) – auf xyz-Stages (SmarAct GmbH) +│ ├── Flachspiegel (x und y) für Interferometer am FZP-Halter +│ └── Helium-Flugrohr (reduziert Luftstreuung) +│ +└── Probenstage (gestapelt) + ├── Grobe Translationsstages (Steinmeyer Mechatronik, Schrittmotoren) + │ └── Für Stitching und Region-of-Interest-Auswahl + ├── Großer Rotationstisch (air-bearing, 365°, Radius 175 mm) + │ ├── Renishaw Tonic Dual-Encoder-System + │ ├── Luftlager → nach Rotation abgelassen → mechanisch geklemmt → steifer + │ └── Schrittmotor-Antrieb (Steinmeyer Mechatronik) + ├── Piezo-Scanner (Npoint NPXY100-216) + │ ├── 2 Translationsachsen (x, y in Stage-Ebene) + │ ├── 100 µm Stellbereich je Achse + │ ├── Interne kapazitive Sensoren (nur für Vorpositionierung) + │ └── Closed-loop zum exteroceptiven Interferometer (Hauptregelung!) + └── Anti-Rotations-Stage (Kugellager, PASSIV – kein Antrieb!) + ├── Trägt die Interferometer-Spiegel + ├── Dreht sich NICHT mit dem Probentisch (hält Spiegel senkrecht) + ├── Passiver Antrieb: Hebelarm + zwei Hardstops am Rotationstisch + ├── Pneumatische Bremse: Fixiert Spiegel während Scan + └── Kapazitive Sensoren (A: Abstand zu Hardstops, B: Winkelmotion) +``` + +### Device-Namen in BEC (LamNI) + +| BEC-Device | Hardware | Einheit | Anmerkung | +|---|---|---|---| +| `rtx` | RT-Controller X (Piezo-Scanner Npoint X-Achse) | µm | Interferometer-geregelt | +| `rty` | RT-Controller Y (Piezo-Scanner Npoint Y-Achse) | µm | Interferometer-geregelt | +| `lsamx` | Koarse-Motor X (Steinmeyer) | mm | Read-only bei aktivem Feedback | +| `lsamy` | Koarse-Motor Y (Steinmeyer) | mm | Read-only bei aktivem Feedback | +| `lsamrot` | Großer Rotationstisch (air-bearing) | Grad | Laminographie-Winkel | +| `loptx` | Optik-Motor X | mm | FZP/OSA-Ausrichtung | +| `lopty` | Optik-Motor Y | mm | | +| `loptz` | Optik-Motor Z | mm | | + +### Interferometer-System (LamNI) + +- **Typ:** Doppeldurchgang-Flachspiegel (KEIN Tracking-Interferometer!) + Grund: 12 mm × 12 mm Scanbereich zu groß für Tracking mit Kugelspiegeln +- **Laser:** Zygo Inc., 6 mm Strahldurchmesser +- **Gemessen:** Relative Position FZP ↔ Probe (x und y, exteroceptiv) +- **Anti-Rotations-Stage** hält Spiegel senkrecht zum Interferometerstrahl während Rotation +- **Positionsstabilität:** 1.1 nm Standardabweichung (x und y) +- **Positioniergenauigkeit:** 4.4 nm (H), 4.1 nm (V) std (ptychographisch verifiziert) +- **Schrittantwort:** < 40 ms für 1.5 µm Schritt +### LamNI Geometrie im Detail + +``` +Koordinatensystem (Labor): + z = Röntgenstrahl-Richtung + x = horizontal + y = vertikal (zeigt nach oben) + +Stage-Verkippung: + 1. 15° Kippung um x-Achse + 2. 60° Kippung um y-Achse + → Laminographie-Winkel (Strahl ↔ Rotationsachse) = 61° + +Piezo-Scan-Ebene: + = Probenebene (geneigt gegenüber Labor-Koordinaten) + +Interferometer-Messung: + = In Laborkoordinaten x und y + +→ Koordinatentransformation notwendig: + (Code: lamni_to_stage_coordinates / lamni_from_stage_coordinates) + +Rotation im Code (Winkelkorrektur): + alpha = (angle - 300 + 30.538) / 180 * pi + → Die 300° und 30.538° sind mechanische Offsets des Setups +``` + +### Anti-Rotations-Mechanismus (LamNI-spezifisch) + +``` +Problem: Interferometer-Spiegel müssen senkrecht zum Strahl bleiben, + aber Probe dreht sich mit dem Rotationstisch! + +Lösung: Anti-Rotations-Stage auf dem Piezo-Scanner + 1. Großer Rotationstisch dreht (z.B. 45°) + 2. Hebelarm der kleinen Stage berührt Hardstop an der Basis des großen Tisches + 3. Kleine Stage dreht sich passiv um -45° + 4. Ergebnis: Spiegel bleiben in Laborkoordinaten ausgerichtet ✓ + +Sicherheit: + - Kapazitive Sensoren (A): Abstand Hebel ↔ Hardstop überwacht + - Kapazitive Sensoren (B): Winkelmotion der kleinen Stage + - Pneumatische Bremse: Fixiert Spiegel während Scan + - Auto-Entklemmen wenn Hebel zu nah an Hardstop (Sicherheitsmechanismus) + +Prozedur bei Rotationswechsel: + 1. Piezo auf interne kapazitive Sensoren zentrieren + 2. Interferometer-Feedback ausschalten + 3. Bremse der kleinen Stage lösen + 4. Luftlager einschalten + 5. Großen Tisch zum Zielwinkel drehen (Encoder-Feedback) + 6. Kleine Stage klemmen + 7. Feinausrichtung der kleinen Stage (kap. Sensoren A → Servo-Regelung) + 8. Luftlager ausschalten (erhöht Steifigkeit) + 9. Letzten Encoder-Wert speichern (tatsächlicher Winkel für Rekonstruktion) +``` + +### Scan-Aufruf und Parameter (LamNI) + +```python +# Standard LamNI Fermat-Scan +scans.lamni_fermat_scan( + fov_size=[20], # FOV in Piezo-Ebene [µm], max ~80 µm + # [x] = quadratisch, [x,y] = rechteckig + step=0.5, # Schrittweite [µm] + exp_time=0.1, # Belichtungszeit [s] + angle=0, # Laminographie-Rotationswinkel [Grad] + center_x=0.02, # Scan-Zentrum X bei 0° [mm] – WIRD rotiert! + center_y=0, # Scan-Zentrum Y [mm] + shift_x=0, # Zusatzshift X [mm] – wird NICHT rotiert + shift_y=0, # Zusatzshift Y [mm] + stitch_x=0, # Stitch-Versatz X [µm] + stitch_y=0, # Stitch-Versatz Y [µm] + fov_circular=0, # Kreisförmiges FOV [µm] (zusätzliches Cropping) + stitch_overlap=1, # Stitch-Überlapp [µm] + scan_type="fly", # "fly" (HW-getriggert) oder "step" + frames_per_trigger=1, +) + +# Zu neuem Scan-Zentrum fahren (rotations-kompensiert): +scans.lamni_move_to_scan_center(shift_x_mm, shift_y_mm, angle_deg) +``` + +### LamNI Leistungsdaten + +| Parameter | Wert | +|---|---| +| Scan-Bereich | 12 mm × 12 mm (über Stitching) | +| Piezo-Bereich | 100 µm × 100 µm (Npoint NPXY100-216) | +| Positionsstabilität | 1.1 nm std | +| Schrittantwort | < 40 ms für 1.5 µm | +| Positioniergenauigkeit | 4 nm std (ptychographisch) | +| Beste 3D-Auflösung | 18.9 nm (IC 16-nm-Technologie) | +| Gewicht | 1 Tonne | +| Grundplatte | 110 × 130 cm | + +--- + +## 2. OMNY – tOMography Nano crYo Stage + +### Physikalisches Prinzip + +OMNY = kryogene Ptychographie-Tomographie. Die Rotationsachse ist senkrecht zum Röntgenstrahl (klassische Tomographie, 90°). Kryogene Bedingungen (90 K mit LN₂, 10 K mit LHe) schützen strahlungsempfindliche Proben. + +**Betrieb bei cSAXS seit:** Juni 2015 +**Vakuum:** Ultra-Hochvakuum (10⁻⁷–10⁻⁸ mbar) +**Probengeometrie:** Zylindrische Pfeiler (OMNY Pins) + +### Hardware-Komponenten (aus Paper 2018) + +``` +OMNY Mechanik (Vakuumkammer Ø 110 cm, 4 Tonnen Gewicht) +Mineral-Cast-Block: 110 × 110 × 52 cm³ +│ +├── Röntgenoptik +│ ├── FZP – auf 2D Piezo-Stage (nPoint, Inc., NPXY100D) +│ │ └── Controller: NPoint LC.403 +│ ├── CS (Central Stop) – auf xy-Stage (SmarAct SLC-2430-S-UHV) +│ └── OSA – auf xyz-Stage; Cryo 2 gekühlt (90 K) +│ └── Spiegel-Mounts: SmarAct STT-25.4-UHV-TI (motorisiert) +│ +├── Probenstage +│ ├── 4 Schrittmotor-Stages (xyz + Rotation um y) +│ │ ├── Linear: ±5 mm Range (Steinmeyer Mechatronik, Dresden) +│ │ ├── Rotation: 365°, Renishaw Tonic UHV Encoder +│ │ └── Controller: 3× Galil DMC-4080-D4140 +│ ├── Delta-Scanner (Tripod-Geometrie, PSI-Eigenentwicklung, Maag et al.) +│ │ ├── 3 translatorische DOF, 450 µm Gesamtbereich +│ │ ├── Schrittantwort: < 30 ms für ~2 µm Schritte +│ │ ├── Lokale Metrologie: attocube Faserinterferometer (nanometrisch!) +│ │ ├── Piezo aufgeteilt in 4 unabhängige Stapel: +│ │ │ ├── High-Current Stapel: VF-500 Verstärker (1A) – schnelle Bewegung +│ │ │ └── Low-Noise Stapel: SVR-150 – Feinregelung (< 10 nm Rauschen) +│ │ └── Rotationsmatrix-Korrektur (Delta-Geometrie ≠ xyz des Labors) +│ └── Referenzspiegel / Probenhalter (Al, diamantgedreht, Au-beschichtet) +│ ├── LT Ultra GmbH (Herstellung) +│ ├── Vertikal: Flachfläche für Doppelpass-Interferometer +│ ├── Horizontal: Kugelform für Tracking-Interferometer +│ ├── Oberflächenfehler < 300 nm, Rauigkeit ~1 nm +│ └── Sample direkt montiert (minimiert Dead-Path!) +│ +├── Kryosystem +│ ├── Cryo 1: Helitran LT3B (LN₂ oder LHe, offener Kreislauf, leise!) +│ │ └── O₂-freies Kupfergeflecht (514 mm) → Referenzspiegel +│ │ └── 4 × 16 mm² + 10 × 23 mm² (gestuft für Kraftreduktion) +│ ├── Cryo 2: LN₂-Rohrsystem → OSA (90K), Greifer, Parkstation, Therm. Schirm (108K) +│ │ └── Thermischer Schirm: SmarAct SLC-1750-S-UHV (kann weggefahren werden) +│ └── Aktive Temperaturregelung: Pt100 (Heraeus) + Heizwicklungen (Nichrom) +│ └── Stabilität bei LN₂: 90.000 K ± 3.3 mK (über Tage!) +│ +├── Interferometer-System (Zygo Inc.) +│ ├── Laser: Modell 7714, 3 mm Strahldurchmesser +│ ├── Karten: Modell 4004 + 2400 (VME-Bus) +│ ├── VME64-to-PCI Adapter: Abaco Systems Modell 810 +│ ├── 5 gemessene Größen: +│ │ ├── xy: Probe ↔ OSA (Tracking-IF für x, Flach für y) +│ │ ├── xy: OSA ↔ FZP (Doppeldurchgang-Flachspiegel) +│ │ └── Rotationswinkel um z des Probentisches +│ └── Tracking-Interferometer-Stage: +│ ├── y-Piezo: Dynamic Structures ZSA-400-PSI (400 µm, 200 Hz) +│ ├── z-Stepper + z-Piezo (50 µm): folgt Kugelspiegel-Wobble +│ └── Bremse: Piezoelektrisch (erhöht Steifigkeit der y-Stage) +│ +├── Elektronik (National Instruments) +│ ├── NI 6259: Analog In/Out (2 Kanäle) +│ ├── NI 6733: Analog Out (8 Kanäle, Delta-Scanner + FZP) +│ └── NI 6602: Counter/Encoder (attocube-Quadratur-Encoder) +│ +└── Load-Lock (Leica VCT100-basiert, modifiziert) + ├── Turbomolekularpumpe (nur Befüllung/Transfer) + ├── Bis 6 OMNY Pins pro Shuttle + └── Probengreifer für kryogenen Transfer +``` + +### OMNY Real-Time-Kontrollsystem (Fig. 7 im Paper) + +``` +Host-PC: Ubuntu Linux + RTAI 4.0 (Echtzeit-Kernel) +Schleifenfrequenz: 2.5 kHz +Frühere Kontrolle: SPEC (Certified Scientific Software) → jetzt: BEC! + +Vollständiger Signalfluss: + ┌─────────────────────────────────────────────────────────────┐ + │ 2.5 kHz Loop │ + │ │ + │ Zygo-IF ──→ Slew-Rate-Limiter ──→ PID Sample xy │ + │ │ │ │ │ + │ │ Trajektorie-Gen. Rotationsmatrix │ + │ │ │ │ + │ │ PID Delta xy ──→ VF-500 │ + │ │ │ SVR-150 │ + │ Attocube ──→ Delta PID ──→ Range Extension │ + │ │ │ + │ Zygo FZP ──→ PID FZP xy ──→ NPoint LC.403 │ + │ │ + │ PSD Tracking ──→ PID Tracking yz ──→ ZSA-400-PSI Piezo │ + │ │ + │ Position-Sampler: Mean + Std jeder Achse während Belichtung│ + └─────────────────────────────────────────────────────────────┘ + +Ethernet → SPEC/BEC (asynchron, nicht Echtzeit) +EPICS soft-IOC: Probenpositionen, Temperaturen, Vakuum-Status +``` + +### OMNY Mikroskop (X-Ray Eye) + +Das interne Mikroskop (X-Ray Eye) hat 3 Modi: + +1. **Weitwinkel** (Übersicht): Edmund Optics Objektiv + IDS UI-524xCP-C +2. **Röntgen-Ast**: Szintillator (LuAG:Ce, 0.1 mm) → Mitutoyo 20x → IDS UI-548xCP-M + - Optische Auflösung: 1 µm an der Szintillatorebene +3. **Sichtbar-Ast**: Leica Plan APO + Leica Z16 APO → IDS UI-548xCP-M + - Auflösung: 2.5 µm bei 400 nm, 66.4 mm vor Szintillator +Das X-Ray Eye ist die Basis des **`xray_eye`** BEC-Widgets in `csaxs_bec/bec_widgets/widgets/xray_eye/`! + +### Device-Namen in BEC (OMNY) + +| BEC-Device | Hardware | Beschreibung | +|---|---|---| +| `rtx` | Delta-Scanner X / RT-Controller | Interferometer-geregelt, Flyer-Start | +| `rty` | Delta-Scanner Y | | +| `rtz` | Delta-Scanner Z | Z-Richtung (Defokussierung) | +| `osamroy` | Rotationstisch | Tomographie-Winkel [Grad], 365° | +| `osamx` | Koarse-Motor X | Proben-Grobpositionierung | +| `rt_omny` | Sync-Flyer-Device | Überwacht Scan-Fortschritt | + +### OMNY Alignment-Prozedur (aus Paper) + +1. **Grob-Alignment**: X-Ray Eye Kamera + GUI (früher LabView, jetzt BEC) + - 5 Rotationswinkel gleichmäßig verteilt von 0° bis 180° +2. **Fein-Alignment**: 5 Ptychographie-Scans, sinusförmige Kurvenanpassung +3. **Affine-Korrektur**: Einmalig pro Run (Flipping-Experiment + FSC-Optimierung) +### OMNY Leistungsdaten + +| Parameter | Wert | +|---|---| +| Probe-Stabilität | < 10 nm std (in-position während Belichtung) | +| Positioniergenauigkeit | 8.1 nm (H), 4.7 nm (V) std residues | +| Beste 2D-Auflösung | 8.5 nm (FRC, 1-bit Threshold) | +| Beste 3D-Auflösung (IC) | 14.6 nm (Nature 2017) | +| Beste 3D-Auflösung (biologisch, hochstreuend) | 27 nm (Käferflügelschuppe) | +| Biologisch schwach streuend | 111 nm (Chlamydomonas in OMNY, LN₂) | +| Probengröße max | ~50.000 µm³ Volumen | +| Röntgenenergie | 6.2 keV typisch | +| Detektor-Abstand | 7.33 m (Pilatus 2M) | +| Temperaturstabilität | 90.000 K ± 3.3 mK (über Tage) | +| Experimentlaufzeit | 5–6 Wochen kryogen (ohne Aufwärmen) | + +--- + +## 3. FlOMNI – Fly-OMNY (Raumtemperatur, Fly-Scan) + +### Was ist FlOMNI? + +**FlOMNI** ist der **Raumtemperatur**-Nachfolger des ursprünglichen RT-Instruments (Holler 2012). Es ist als **Async Fly-Scan** (`AsyncFlyScanBase`) konzipiert: die Probe bewegt sich kontinuierlich, während die Detektoren laufend aufnehmen. Das 2012er Paper beschreibt den direkten Vorgänger. + +**Grundprinzip des Closed-Loop-Schemas (aus 2012er Paper):** +``` +1. Probe zu Zielposition bewegen (nur interne kapazitive Sensoren) +2. Interferometer-Feedback an FZP-Piezo EINSCHALTEN +3. Belichtung: FZP kompensiert verbleibende Drift (< 50 nm Amplitude) +4. Interferometer-Mittelwert → Input für Ptychographie-Rekonstruktion +5. Interferometer-Feedback AUSSCHALTEN, FZP-Piezo zurückzentrieren +6. Nächste Position +``` + +Bei FlOMNI ist dies für kontinuierliche Bewegung optimiert (Fly-Scan). + +### FlOMNI Besonderheiten + +| Merkmal | Wert/Beschreibung | +|---|---| +| Betrieb | Raumtemperatur, Atmosphäre | +| Scan-Modus | Async Fly-Scan | +| BEC-Basisklasse | `AsyncFlyScanBase` | +| Tracker | **Laser-Tracker** (aktive Strahlverfolgung) | +| Trigger | DDG1 (Delay-Generator 1), `EXT_RISING_EDGE` | +| Cleanup | DDG1 zurück auf `SINGLE_SHOT` nach Scan | +| Scan-Umkehr | Jeder 2. Scan reversed (Redis global var) | +| FOV X max | 200 µm | +| FOV Y max | 100 µm | +| Z-Shift max | ±100 µm | +| Probenmagazin | `flomni_sample_storage` – automatischer Probenwechsel! | +| Rotation | `fsamroy` (FlOMNI Rotationsmotor) | +| Flyer | `rt_positions` (AsyncFlyScanBase Monitor) | +| Temp-Monitoring | `flomni_temp_and_humidity.py` | + +### Laser-Tracker (FlOMNI-spezifisch) + +Im Gegensatz zu OMNY (klassisches Tracking-Interferometer) hat FlOMNI einen Laser-Tracker: + +```python +# Im Scan-Code (flomni_fermat_scan.py): +yield from self.stubs.send_rpc_and_wait("rtx", "controller.laser_tracker_on") +tracker_signal = yield from self.stubs.send_rpc_and_wait( + "rtx", "controller.laser_tracker_check_signalstrength" +) +# Ergebnis: "ok", "low", oder "toolow" + +if tracker_signal == "low": + # Alarm (WARNING), Scan kann weiterlaufen, Realignment empfohlen +elif tracker_signal == "toolow": + raise ScanAbortion("Laser-Tracker-Signal zu schwach – Realignment nötig!") + +# Nach Positionierung zum Scan-Zentrum: +yield from self.stubs.send_rpc_and_wait( + "rtx", "controller.move_samx_to_scan_region", self.cenx +) +``` + +### Device-Namen in BEC (FlOMNI) + +| BEC-Device | Beschreibung | +|---|---| +| `rtx` | RT-Controller X + Laser-Tracker-Interface | +| `rty` | RT-Controller Y | +| `rtz` | RT-Controller Z (Defokussierung) | +| `fsamroy` | FlOMNI Rotationsmotor [Grad] | +| `ddg1` | Delay-Generator 1 (Trigger, EPICS) | +| `rt_positions` | Async-Flyer-Device | + +--- + +## 4. Tracking-Interferometer – Gemeinsame Schlüsseltechnologie + +Das **Error-Motion-Compensating Tracking Interferometer** (Holler & Raabe, Opt. Eng. 2015, Patent WO 2012079875 A1) ist Grundlage für OMNY (und den RT-Vorgänger/FlOMNI): + +### Das Problem bei rotierenden Proben + +Bei Standard-Interferometern mit Kugelspiegel muss der Kugelspiegel präzise auf der Rotationsachse zentriert sein. Das ist in der Praxis nicht möglich → Kugelspiegel wackelt → Strahl trifft nicht mehr auf Kugelmitte → Signal verloren. + +**Lösung 1 (einfach):** Interferometer auf x/y-Stage, PSD misst Strahlposition → Tracking +**Problem:** Tracking-Stage selbst hat Fehlermotion (Kugeellager: ~200 µrad Winkelfehler → ~3 µm Messfehler) + +### Die optische Kompensation (Holler & Raabe 2015) + +``` +Aufbau (vier polarisierende Strahlteiler PBS1-PBS4, zwei externe Referenzspiegel R1, R2): + + Referenzstrahl: f1,f2 → PBS1 → R1 → PBS1 → PBS2 → R2 → PBS2 → HWP → PBS4 + Messstrahl: f1,f2 → PBS1 → PBS3 → Kugelspiegel → PBS3 → R2 → PBS4 + + Optische Wege: + Referenz: 2·(a + b) [a = PBS1 zu R1, b = PBS2 zu R2] + Messung: 2·(c + d) [c = PBS1 zu Sphäre, d = PBS3 zu R2] + + OPD = 2·(a+b) - 2·(c+d) + + Wenn Tracking-Stage sich um Δ bewegt (Translation): + a,c kürzen sich um Δ; b,d verlängern sich um Δ → OPD unverändert ✓ + + Wenn Tracking-Stage sich dreht (Rotation um Zentrum): + a,d kürzen sich; b,c verlängern sich → OPD unverändert ✓ + +R1 sitzt bei FZP-Position → direkte Differenzmessung Probe ↔ FZP! +``` + +**PSD misst Kugelposition in x/y:** +→ Closed-Loop zum Tracking → Kugel muss NICHT zentriert sein auf Rotationsachse! + +**Ergebnis:** +- Ohne Kompensation: ~3 µm Restfehler (Kugeellager-Winkelfehler) +- Mit Kompensation: ~15 nm Restfehler (Faktor 200 besser!) +- In OMNY verwendet: Positionsstabilität < 10 nm std +### Warum LamNI kein Tracking-Interferometer braucht + +Der LamNI-Scan ist 12 mm × 12 mm groß. Das überschreitet die praktikable Reichweite des Tracking-Systems mit Kugelspiegeln (begrenzt durch Tiefenschärfe des fokussierten Laserstrahls). Daher: Flachspiegel + Anti-Rotations-Stage als alternative Lösung. + +--- + +## 5. nPoint Piezo-Controller – Zwei verschiedene Rollen! + +| | Bei OMNY | Bei LamNI | +|---|---|---| +| **Modell** | NPXY100D | NPXY100-216 | +| **Steuert** | **FZP-Position** (Röntgenoptik!) | **Probe** (Piezo-Scanner) | +| **Controller** | NPoint LC.403 | im RT-Controller integriert | +| **Bereich** | klein (Optik-Stabilisierung) | 100 µm × 100 µm | +| **Zweck** | FZP in closed-loop zur Interferometermessung | Proben-Scanning für Ptychographie | + +**BEC-Driver:** `csaxs_bec/devices/npoint/npoint.py` +- Binary-Protokoll (LC.400/LC.403 Controller) +- `0xA0 [addr] 0x55` = Lesen; `0xA2 [addr] [data] 0x55` = Schreiben +- Positionsbereich: 0–100% → intern 0–1048574 Integerwert +- 3 Achsen pro Controller +--- + +## 6. RT-Controller (LamNI-spezifisch) – Socket-Protokoll + +Der `RtLamniController` kommuniziert über TCP/IP Socket (ASCII-Kommandos): + +| Kommando | Bedeutung | +|---|---| +| `J0` | Interferometer-Feedback deaktivieren | +| `J4` | Aktuelle Interferometerposition lesen (x, y, ...) | +| `J5` | Feedback aktivieren (ohne Reset der Zielposition) | +| `J6` | Feedback + Winkelinterferometer-Reset deaktivieren | +| `J2` | Feedback-Status + Interferometer-Signalstärke (SSI 0, SSI 1) | +| `J3` | SSI-Mittelung triggern (vor J2 aufrufen!) | +| `J7` | Winkel-Interferometer: Status, Position, Signalstärke | +| `sc` | Scan stoppen + Trajektorie löschen | +| `sd` | Punkt-für-Punkt-Scan starten | +| `sr` | Scan-Status: (Mode, geplante Pos., aktuelle Pos.) | +| `o` | Ist Achse am Ziel? (bool) | +| `s{x:.5f},{y:.5f},0` | Position zum Scan-Buffer hinzufügen | +| `a{angle_rad}` | Rotationswinkel setzen (rad, mit Mechanik-Offset) | +| `V{um_per_s}` | Achsengeschwindigkeit setzen | +| `V0` | Maximale Geschwindigkeit | +| `As` / `Ar` | Analog-Sampling starten/lesen | +| `Ss` / `Sr` | Positions-Sampling starten/lesen | +| `pa0,{x}` | Zielposition Achse 0 setzen (für Feedback-Enable) | +| `pa1,{y}` | Zielposition Achse 1 setzen | + +**Scan-Status-Modi:** +- Mode 0: Direkte Positionierung (kein Scan aktiv) +- Mode 2: RT Point-Scan läuft +- Mode 3: RT Point-Scan startet +**Interferometer-Achsen** (aus `show_signal_strength_interferometer`): +- Achse 0: "ST FZP horizontal" (Zonenplatte, horizontal) +- Achse 1: "ST FZP vertical" (Zonenplatte, vertikal) +- Achse 2: Winkel-Interferometer (Rotation) +--- + +## 7. Galil Motor-Controller (OMNY) + +**Typ:** Galil DMC-4080-D4140 (3 Einheiten für 19 Stages) +**Encoder:** Renishaw Tonic UHV + +Funktionen: +- Closed-loop zum Encoder +- Following-Error-Überwachung (Schritt- vs. Encoderposition) +- Motortemperatur-Überwachung +- Automatische Reichweitenerweiterung der Tracking-Piezo-Stages +BEC-Treiber: `csaxs_bec/devices/omny/galil/` + +--- + +## 8. Detektoren und Röntgenstrahl am cSAXS + +### Röntgenstrahl +- **Undulator:** In-Vakuum, 19 mm Periode +- **Vertikale Quellgröße:** 20 µm +- **Sekundärquelle:** Spalt 20 µm breit, 12.1 m downstream vom Undulator +- **Monochromator:** Si(111) Doppelkristall (fixed-exit) + Fused-Silica-Spiegel +- **Typische Energie:** 6.2 keV (λ = 0.2 nm) +### Detektoren + +| Detektor | Pixelgröße | Verwendung | Abstand (typisch) | +|---|---|---|---| +| Pilatus 2M | 172 µm | OMNY, LamNI | 7.33 m | +| Pilatus 300K | 172 µm | cSAXS Standard | variabel | +| Eiger 1.5M | 75 µm | Moderne OMNY/FlOMNI | variabel | +| JungFrauJoch (JFJ) | – | Hochrate | variabel | +| IDS UI-524xCP-C | – | OMNY Weitwinkel-Kamera | intern | +| IDS UI-548xCP-M | – | OMNY Röntgen-Auge + Sichtbar | intern | + +--- + +## 9. Gesamtvergleich der drei Setups + +| Eigenschaft | LamNI | OMNY | FlOMNI | +|---|---|---|---| +| Methode | Ptych. Laminographie | Ptych. Tomographie (kryogen) | Ptych. Tomographie (RT) | +| Rotationsachse | 61° geneigt | 90° (senkrecht) | 90° (senkrecht) | +| Temperatur | Raumtemperatur | 90 K (LN₂) / 10 K (LHe) | Raumtemperatur | +| Vakuum | Nein | UHV (10⁻⁸ mbar) | Nein | +| Scan-Modus | Step (HW-getriggert) | Sync Fly-Scan | Async Fly-Scan | +| BEC-Basisklasse | `ScanBase` | `SyncFlyScanBase` | `AsyncFlyScanBase` | +| FOV (Scanning) | 12 mm × 12 mm (Stitch) | ~50 µm × 50 µm | 200 × 100 µm | +| Piezo (Probe) | Npoint NPXY100-216 | Delta-Scanner (PSI) | RT-Scanner | +| Piezo (FZP) | xyz-Stages (Steinm.) | Npoint NPXY100D + LC.403 | – | +| Interferometer | Flachspiegel + Anti-Rotation | Tracking (Kugelspiegel) | Laser-Tracker | +| Positionsstabilität | 1.1 nm | < 10 nm | < 10 nm | +| Rotation BEC | `lsamrot` | `osamroy` | `fsamroy` | +| Flyer-Device | – | `rt_omny` | `rt_positions` | +| Gewicht | 1 Tonne | 4 Tonnen | ähnlich | +| Beste Auflösung | 18.9 nm 3D | 8.5 nm 2D / 14.6 nm 3D | ~15 nm | +| Probenform | Flach/großflächig | Zylindrisch (OMNY Pins) | Zylindrisch | +| Probenmagazin | Nein | Load-Lock (6 Pins/Shuttle) | `flomni_sample_storage` | +| Scan-Min. Punkte | 20 (ScanAbortion sonst) | 20 | 20 | + +--- + +## 10. Kritische Erkenntnisse für Softwareentwicklung + +1. **LamNI ist LAMINOGRAPHIE** (61° geneigt), nicht Tomographie – beeinflusst alle Positionsberechnungen +2. **Koordinatentransformation** im Code physikalisch begründet durch Stage-Verkippung (15°/60°) +3. **`rtx`/`rty`** = Piezo-Scanner in Interferometer-Koordinaten (µm), nicht Stage-Koordinaten +4. **`lsamx`/`lsamy`** = Koarse-Motoren (mm), werden auf read-only gesetzt bei aktivem Feedback +5. **nPoint** steuert bei OMNY die **FZP** (nicht Probe!), bei LamNI die **Probe** +6. **OMNY** hat einen eigenen Real-Time-Kernel (RTAI 4.0, 2.5 kHz) – kommuniziert über Ethernet +7. **Anti-Rotations-Stage** ist mechanische Besonderheit von LamNI (passiv, Hebelarm) +8. **Tracking-Interferometer** (Holler & Raabe 2015) ist Schlüsseltechnologie für OMNY/FlOMNI +9. **FlOMNI** hat Laser-Tracker (nicht klassisches Tracking-Interferometer) +10. **DDG1** muss bei FlOMNI vor und nach Scan umgeschaltet werden (EXT_RISING_EDGE / SINGLE_SHOT) +11. **Trajektorie-Umkehr** (FlOMNI + OMNY) wird in Redis persistent gespeichert +12. Die Drift-Korrektur in LamNI läuft in bis zu **2 Iterationen**, Abbruch bei Drift > 150 µm +13. **Delta-Scanner (OMNY)**: Tripod-Geometrie → Rotationsmatrix notwendig für xyz-Kontrolle +14. **Attocube-Faserinterferometer**: Lokale Metrologie im Delta-Scanner (für schnelle PID-Schleife) +15. **OMNY Affine-Korrektur**: Einmalig pro Experimentrun bestimmt (Flipping-Experiment + FSC) +16. **OMNY Tracking-Stage y-Piezo** (ZSA-400-PSI, 400 µm, 200 Hz): ähnliche Eigenschaften wie Delta-Scanner – daher geeignet für kontinuierliche Bewegung während Ptychographie +--- + +*Erstellt April 2026. Quellen: Quellcode csaxs_bec + 4 peer-reviewed Publikationen (Holler et al. 2012, 2015, 2018, 2020).* \ No newline at end of file diff --git a/docs/SIMULATED_ENDSTATIONS.md b/csaxs_bec/bec_ipython_client/plugins/flomni/AI_docs/SIMULATED_ENDSTATIONS.md similarity index 96% rename from docs/SIMULATED_ENDSTATIONS.md rename to csaxs_bec/bec_ipython_client/plugins/flomni/AI_docs/SIMULATED_ENDSTATIONS.md index 412a90f..9d0ae74 100644 --- a/docs/SIMULATED_ENDSTATIONS.md +++ b/csaxs_bec/bec_ipython_client/plugins/flomni/AI_docs/SIMULATED_ENDSTATIONS.md @@ -63,9 +63,10 @@ Protocol fidelity is grounded in the actual hardware-side sources: `fgalil.dmc`, `simulated_omny.yaml` (generated), `simulated_bl_endstation.yaml` (pre-existing) - `bin/generate_simulated_configs.py` — regenerates the LamNI/OMNY configs from `ptycho_lamni.yaml`/`ptycho_omny.yaml` (rerun after real-config changes) -- `tests/sim_flomni_harness.py`, `tests/sim_lamni_omny_harness.py` — offline harnesses - (no BEC deployment needed): 39 + 26 checks covering moves, referencing, feedback, - tracker, scans through the flyers, gripper transfers, storage, cameras +- `omny_e2e_tests/sim_flomni_harness.py`, `omny_e2e_tests/sim_lamni_omny_harness.py` — + offline harnesses (no BEC deployment needed): 39 + 26 checks covering moves, + referencing, feedback, tracker, scans through the flyers, gripper transfers, + storage, cameras ## Real-code fixes made along the way (separate commits) @@ -151,4 +152,4 @@ harness cannot see. `sim_stppermm`, `sim_encpermm`, `sim_referenced`, `sim_analog_inputs`, `sim_digital_inputs/outputs`, `sim_variables`, `sim_limit_low/high_active`, `sim_point_dwell_s`, `sim_noise_std`, `sim_samples`. -- Harnesses run standalone: `python tests/sim_flomni_harness.py` (repo on PYTHONPATH). +- Harnesses run standalone: `python omny_e2e_tests/sim_flomni_harness.py` (repo on PYTHONPATH). diff --git a/csaxs_bec/bec_ipython_client/plugins/flomni/AI_docs/TOMO_QUEUE_COMMAND_JOBS_PLAN.md b/csaxs_bec/bec_ipython_client/plugins/flomni/AI_docs/TOMO_QUEUE_COMMAND_JOBS_PLAN.md new file mode 100644 index 0000000..5a45ec9 --- /dev/null +++ b/csaxs_bec/bec_ipython_client/plugins/flomni/AI_docs/TOMO_QUEUE_COMMAND_JOBS_PLAN.md @@ -0,0 +1,752 @@ +# Plan: Command Jobs in the FlOMNI Tomo Queue + +**Status: Steps 1–3 are implemented and fully validated**, both by sim and by a +live click-through of the entire GUI checklist. **Step 2** (`flomni.py`: schema +type-tagging, action registry, `move` + `optimize_idgap` (stub) actions, +`tomo_queue_add_command`, `tomo_queue_show`/`tomo_queue_execute` branching, +`_run_command_job` resume policy, `tomo_queue_actions` global-var publishing, plus +the matching `flomni_webpage_generator.py` fix) is sim-tested: +`omny_e2e_tests/test_tomo_queue_command_jobs.py`, 6/6 passing (see +`TOMO_QUEUE_TESTING.md` §4D); real device movement exercised against `ftray` +(sim-testable stand-in), not `mokev`/`idgap` (real front-end only — see §10). Since +then: a CLI-level `tomo_queue_move(index, new_index)` alongside +`tomo_queue_add`/`_delete`/`_clear`, enforcing the same running-job floor as §6.4; +and a curated, session-only `at_each_angle_hook` registry (§3.4) with a GUI dropdown +and webpage display, sim-validated (CLI side) and click-tested (GUI side, both green +— see `TOMO_QUEUE_GUI_TESTING.md` sections 6b–6f). **Step 3** (the `TomoQueueDialog` +GUI work) — the command-job builder dialog (§6.5), command-job-aware queue +rendering, the status-keyed delete/clear guards from §6.3 (since extended with a +staleness-aware override, see below), a "Sort queue…" mode for mid-run reorder of +pending rows (§6.4 — up/down buttons, not drag-and-drop, see the note under §6.4 for +why), full-detail row tooltips, "Load into editor" (§6.8), and the +live-vs-unsaved-edit warnings on both "Add to queue" surfaces (§6.1) — **has been +fully click-tested against a live session** (`TOMO_QUEUE_GUI_TESTING.md`, items +1–55, all passing). Two real incidents were found and fixed along the way: a job +stuck at `running` forever with no live process behind it (§6.3's staleness note, +§3.4's declined-confirmation note), and the two-window live-vs-unsaved-edit +confusion (§6.1). Step 1 (executor rework) is implemented and its sim checklist has +passed (`TOMO_QUEUE_TESTING.md` §4, sections A–C). Remaining known gaps (scope cuts, +not bugs) are tracked in `TOMO_QUEUE_GUI_TESTING.md` §7. + +**Prerequisite (met):** the checklist in `TOMO_QUEUE_TESTING.md` has passed against +the simulated flOMNI. That document is the companion to this one: it covers *what +Step 1 changed and how to test it*; this one covers *what comes next*. The e2e test +suite for both steps lives in `omny_e2e_tests/` at the repo root (moved out of +`tests/e2e/` so it doesn't get swept into gitea's generic `tests/` CI discovery — +these tests need a live sim + Redis, not just `pytest`). + +> **This document supersedes the earlier version of the same name.** The earlier +> draft assumed the old index-based executor, single-action command jobs, and left +> the registry-distribution question open. All three have since been decided. + +**Goal:** Allow the tomo queue to hold, in addition to tomogram jobs, "command" jobs +that reconfigure the beamline between tomograms — move a device, change energy, move +optics in/out, optimize a parameter — so one queue can express *"tomogram A, then +reconfigure, then tomogram B"*, unattended. + +--- + +## 0. Chosen model, and what was rejected + +**Chosen: a named-action registry.** Command jobs name actions from a curated +allow-list, plus keyword arguments. Everything stays JSON-serializable, auditable in +`client.get_global_var("tomo_queue")`, and safe to persist across kernel restarts. + +**Rejected: storing Python source strings and `exec`-ing them later.** Not auditable; +fails at run time (hours into an overnight run) rather than at add time; breaks the +crash-resume model. If the registry ever can't express something, adding an entry is a +small, reviewed change — preferable to a permanent arbitrary-code channel on a live +beamline. + +**Also rejected: a code editor in the GUI with a syntax check.** Syntax-valid is nearly +worthless as a safety check — `umv(dev.fsamx, -1e9)` parses perfectly and drives the +stage into a hard stop. A syntax check gives the operator *false confidence*. The +structured builder (§4) is both safer and friendlier: there is nothing to type wrong. + +--- + +## 1. Current state + +### Step 1 — DONE (executor rework) + +`tomo_queue_execute()` is now an **id-based pick-next loop**: it re-reads the queue +from its global var at the start of every job and addresses jobs by a stable `id` +(not by list index). `_TomoQueueProxy` gained `update_by_id()` and `ensure_ids()`; +`tomo_queue_add()` stamps a uuid on each job. + +This is what makes it safe to **append, delete a pending job, and reorder the pending +tail while the queue runs** — the foundation the GUI work (§5) depends on. It also +introduced **resume-before-fresh** ordering: any `incomplete`/`running` job runs before +any fresh `pending` one, protecting the single shared `progress` global var. + +See `TOMO_QUEUE_TESTING.md` §1 for the full rationale and the invariants to preserve. + +### Job dict schema today + +```python +{ + "id": str, # uuid4 hex; stable across reorder/edit + "label": str, + "params": {<_TOMO_QUEUE_PARAM_NAMES>: value, ...}, + "status": "pending" | "running" | "incomplete" | "done", + "added_at": ISO datetime string, +} +``` + +Every job today is *implicitly* a tomogram. Adding command jobs = making the job +**type-tagged** and branching in the executor. + +### The GUI as it stands + +`TomoQueueDialog` (in `tomo_params.py`) reads/writes the same `tomo_queue` global var +directly, renders one row per job, colour-codes by status, and polls every 2 s. +Execution is CLI-only (the GUI server process can't run the blocking scan loop). + +--- + +## 2. Schema: type-tagged jobs + +Add a `"kind"` field. **Backward compatible:** a job with no `kind` is treated as +`"tomo"`, so existing persisted queues and existing GUI code keep working. + +### Tomo job (unchanged except the tag) +```python +{ + "kind": "tomo", # NEW; absent => "tomo" + "id": str, + "label": str, + "params": {...}, + "status": ..., + "added_at": ..., +} +``` + +### Command job (new) — a **list of steps**, not a single action +```python +{ + "kind": "command", + "id": str, + "label": str, + "steps": [ # ordered; run in sequence within the one job + {"action": "move", "kwargs": {"positions": {"mokev": 6.2}}}, + {"action": "optimize_idgap", "kwargs": {"search_range": 0.5}}, + ], + "idempotent": bool, # true iff EVERY step is idempotent + "status": ..., + "added_at": ..., +} +``` +(`foptics_out`/`foptics_in`-style steps aren't in the initial registry — see §3 — +this example only uses the two actions Step 2 actually ships with.) + +**Why a list.** "Between tomogram A and B: change energy, re-peak idgap" is one +*intent* and should be one queue row — it reads correctly, and it is atomic to reason +about. Composing it from three separate queue rows is worse on both counts. + +Notes: +- `kwargs` must be JSON-serializable. No device objects, no callables — actions resolve + device names to `dev.*` themselves at execution time. +- Steps within a job are **independent**: none of them consumes another's runtime + result. Anything result-dependent belongs *inside* a single action (§3.2). + +--- + +## 3. The action registry + +A class-level dict on `Flomni` mapping an action name to a spec. Callables are existing +`Flomni`/mixin methods or small reviewed wrappers, so the queue can only ever invoke +reviewed beamline operations. + +**Each registry entry has four things:** + +| field | meaning | +|---|---| +| `func` | name of the bound method to call | +| `idempotent` | default: safe to re-run after a crash? | +| `help` | short operator-facing description | +| `params` | **schema** of the action's arguments (possibly empty) | + +**Decided with Mirko: the initial registry is deliberately minimal — `move` and +`optimize_idgap` only.** No `foptics_in`/`foptics_out`/`feye_in`/`feye_out`/shutter +action for now, even though the first three already exist as callable methods — +Step 2 ships with move-on-allow-listed-devices plus one compound action; further +one-click actions are "dedicated scripts" to be written and added later as their own +reviewed changes, not part of this rollout. No dedicated energy-change wrapper either +— energy changes go through `move` on `mokev` (see §3.1), not a special action. + +```python +_TOMO_QUEUE_ACTIONS = { + "move": { + "func": "_queue_action_move", + "idempotent": True, # absolute moves re-run harmlessly + "help": "Move device(s) to absolute position(s).", + "params": { + "positions": {"type": "device_positions"}, # {device: target}, allow-listed + }, + }, + # compound actions — see §3.2 + "optimize_idgap": { + "func": "optimize_idgap", + "idempotent": True, + "help": "Scan idgap over a search range and move to the peak.", + "params": { + "search_range": { + "type": "float", "default": 0.5, "min": 0.0, "max": 2.0, + "unit": "mm", "label": "Search range", + }, + }, + }, +} +``` + +**Still open (confirm before/while implementing `optimize_idgap`):** which +signal/device the scan reads to locate "the peak" — deferred by Mirko to the +implementation session, not resolved yet. The `search_range` default/bounds above +are confirmed as "roughly right" placeholders; unit (`mm`) not explicitly +reconfirmed but not contradicted either. + +### 3.1 Curated device allow-list (NOT "any device in `dev`") + +The `move` action may only touch devices on an explicit, reviewed allow-list — the +things you actually reconfigure between tomograms. "Any device in `dev`" is the +difference between *a reconfiguration tool* and *a remote control for the whole +endstation*. + +**Decided with Mirko: `mokev` (energy) and `idgap` (undulator gap) — nothing else +yet.** Neither device is in the flomni sim config (`simulated_flomni.yaml`); both +live in `bl_frontend.yaml` (shared beamline front-end devices), so `move` on either +is only exercisable against the real front end, not the flomni sim used for +`TOMO_QUEUE_TESTING.md`. + +```python +_TOMO_QUEUE_MOVE_DEVICES = { + "mokev": {"label": "Energy", "unit": "keV"}, + "idgap": {"label": "Undulator gap", "unit": "mm"}, # unit unconfirmed, see above +} + +def _queue_action_move(self, positions: dict): + """positions: {device_name: absolute_target, ...}. Absolute umv only.""" + args = [] + for name, target in positions.items(): + if name not in self._TOMO_QUEUE_MOVE_DEVICES: + raise ValueError(f"Queue move: device '{name}' is not queue-movable.") + if name not in dev: + raise ValueError(f"Queue move: unknown device '{name}'.") + args += [dev[name], target] + umv(*args) +``` + +The GUI's device dropdown is built from this allow-list, so an operator cannot even +select a device that isn't sanctioned. + +### 3.2 Compound actions, and where result-dependent logic lives + +**Rule: anything where step B needs step A's *runtime result* is ONE named action, not +several queue steps.** + +The motivating case is "scan idgap, move to peak". That is not three primitives — it is +one compound operation with an internal result. It becomes a single registry entry +(`optimize_idgap`) whose method does the scan, finds the peak, and moves there. The +cleverness lives in reviewed Python; the queue only ever holds the *intent*: + +```python +{"action": "optimize_idgap", "kwargs": {"search_range": 0.5}} +``` + +Why this matters: the queue then **never has to carry runtime values between steps**, so +`kwargs` stays plain JSON and crash-resume stays honest. Trying to express "move to +wherever the peak turned out to be" as separate queue steps is exactly what would force +a code channel. + +**Compound actions may take parameters** (`search_range` above) — declared in the same +`params` schema as any other action. Parameters are *scalar inputs the operator sets at +add time*: numbers, bools, a choice from a fixed list, a device from the allow-list. The +schema is deliberately **not** expressive enough to encode logic, expressions, or +references to other jobs' results. That limitation is the point — a parameter is a value +you can validate at add time. + +### 3.3 Validation is two-layer + +- **Schema validation at add time** (GUI + `tomo_queue_add_command`): types, min/max, + device on the allow-list, OK disabled until valid. This is for *ergonomics* — catch + the typo now, not at 2am. +- **Method validation at execution time**: each action re-checks its own arguments + against live hardware. This is the *safety boundary*, because the schema and the + hardware can disagree (limits changed; a value arrived via the CLI bypassing the + form). A refusal here trips the executor's `incomplete`-and-pause path, which is the + correct outcome. + +### 3.4 A separate, session-only registry: `at_each_angle_hook` + +A different escape hatch predates the command-job registry: `Flomni._at_each_angle()` +already checks for a `flomni_at_each_angle` function in `builtins.__dict__` and, if +present, calls it instead of the default per-projection acquisition — the motivating +case being something like "record a projection, move a polarizer in, record again, +move it back out." That hook is arbitrary code, which is fine for a one-off +interactive run, but it isn't a snapshotted parameter, so a **queued** tomo job has no +way to say "use this hook for this job." Decided with Mirko: add it as a curated +registry, same spirit as `_TOMO_QUEUE_ACTIONS` but distinct from it — + +- **Curated here means "queue stores a name, never code," not "hardcoded in + flomni.py."** Hooks are registered from the IPython session at runtime via + `Flomni.register_at_each_angle_hook(name, func)` into a plain instance dict + (`self._at_each_angle_hooks`), *not* published as a class-level registry the way + `_TOMO_QUEUE_ACTIONS` is. This keeps the "any hook a job references is a Python + callable that was deliberately named and registered, never raw code sitting in the + queue" invariant, while still letting operators write ad hoc hooks per experiment + (unlike `move`/`optimize_idgap`, these aren't pre-reviewed methods shipped in + `flomni.py`). +- **`at_each_angle_hook` is a new entry in `_TOMO_SCAN_PARAM_NAMES`** (a + `_GlobalVarParam(None)`, defaulting to no hook) rather than a new job `kind` or + queue mechanism — it's just another snapshotted tomo parameter, so + `tomo_queue_add()`/`tomo_queue_execute()` restore it per job for free, same as + `fovx` or `tomo_countingtime`. +- **Session-only, and that's surfaced loudly.** Hooks live in memory, not a global + var (functions aren't JSON-serializable), so they don't survive a kernel restart. + `_at_each_angle()` raises a clear `FlomniError` — not a silent fallback to default + behaviour — if a job's `at_each_angle_hook` name isn't registered in the session + currently running the queue. +- **Found and fixed a real param-snapshot leak in the GUI while wiring this up.** + `tomo_params.py`'s `QUEUE_PARAM_NAMES`/`DEFAULTS` mirror `_TOMO_SCAN_PARAM_NAMES` + by hand (the same drift risk `tomo_queue_actions` was built to avoid for command + jobs — see section 7). Before adding `at_each_angle_hook` to that mirror, a + GUI-added job's `params` dict would have no `at_each_angle_hook` key at all, so + the executor's `for name, value in job["params"].items(): setattr(...)` loop would + never touch it — silently leaving whatever hook the *previous* queued job had + active in place for a job that never asked for one. Fixed by adding the key to + `QUEUE_PARAM_NAMES`/`DEFAULTS`, and by having `add_edited_to_queue()` explicitly + pass through the live value (there's no editable widget for it yet). +- **GUI widget added.** `Flomni._publish_at_each_angle_hooks()` republishes + registered names as `tomo_at_each_angle_hooks` on every register/unregister call + (reset to empty in `__init__`, since hooks are session-only), mirroring + `tomo_queue_actions`'s no-hardcoded-mirror approach (section 7). `TomoParamsWidget` + reads it into a "At-each-angle hook" dropdown, rebuilt on every populate so it + tracks CLI-side register/unregister calls live. The currently-set value is always + shown, even if it isn't in the published list (a different or since-restarted + session registered it), labeled "(not registered in this session)" instead of + silently vanishing. Special-cased in `_populate_fields()`/`_read_fields()` like + `tomo_type` — the generic `QComboBox` handling assumes integer combo data, which + doesn't fit a hook-name string or `None`. +- **Webpage display added.** `TQ_PARAM_DISPLAY`/`_CURRENT_PARAM_KEYS` are both + hardcoded param-key mirrors (queue job detail, and the live "Current measurement" + section respectively) — `at_each_angle_hook` didn't crash either path when absent, + but also didn't show up. Added to `_CURRENT_PARAM_KEYS` (Python, gated on the value + being set) and rendered as a conditional row in `buildParamRows()` (JS) — left out + of `TQ_PARAM_DISPLAY` deliberately, so a normal job doesn't grow a "-" row nobody + needs; it only appears when a hook is actually active. +- See `docs/user/ptychography/flomni.md`'s Tomography section for the full + operator-facing writeup (what a hook function looks like, how to register/activate/ + unregister/list one). + +**Sim-validated** (against the live simulated flOMNI, not just mocked): registration/ +list/unregister bookkeeping; the params snapshot carrying `at_each_angle_hook` per +job (and correctly resetting to `None` for the next one); the hook actually firing +during a real `tomo_queue_execute()` run; an unregistered hook's job ending up +`incomplete` and resuming cleanly once the hook is re-registered. Found and fixed one +real bug in the process, unrelated to the hook logic itself: `_tomo_scan_at_angle` is +wrapped in `@scan_repeat(max_repeats=10, default=True)`, which retried the new +`FlomniError` (unregistered hook) 10 times before giving up with a generic +`TooManyScanRestarts` — the actual error message never reached the operator. Fixed +with an `exc_handler` that skips the retry for any `FlomniError` (see the +`_retry_unless_flomni_error` docstring in `flomni.py`); this benefits every other +`FlomniError` the method can raise, not just the hook-registry one. + +Also sim-validated (a follow-up pass, adding the GUI/webpage wiring): the +`tomo_at_each_angle_hooks` global var updates correctly on register/unregister and +is sorted; the webpage generator's `current_params` payload includes +`at_each_angle_hook` when set and omits it (rather than sending `null`) when not. +**Not** sim/click-tested: the actual `QComboBox` in `TomoParamsWidget` (headless +environment, no `pytest-qt`) or the rendered webpage HTML/JS — see +`TOMO_QUEUE_GUI_TESTING.md` for the manual checklist covering both. + +**Second real incident, found at the beamline: a declined confirmation silently did +nothing.** A hook called `tomo_scan_projection(angle)` without `_internal=True` while +`single_point_instead_of_fermat_scan` was on. That combination hits +`tomo_scan_projection()`'s own "Run a fermat scan anyway?" confirmation (unrelated to +the hook mechanism itself, but only reachable in practice through a hook or a direct +CLI call) — at *every* projection angle, since the hook runs once per angle. +Declining used to just print "Aborted." and `return`, with no exception -- so nothing +signaled the executor that a projection was skipped. The eventual `Ctrl-C` (a +`KeyboardInterrupt`, not caught by `tomo_queue_execute()`'s `except Exception`) left +the job stuck at `running` forever, which is the same failure mode the row-lock note +in §6.3 now documents. Fixed at the source: declining now raises `FlomniError` +instead of returning silently, so — combined with the `scan_repeat` `exc_handler` fix +above — a decline correctly fails the job immediately and marks it `incomplete` +rather than either silently dropping data or leaving an untraceable stuck state. +Sim-verified: a direct declined call raises with a clear message; through the queue, +via a hook missing `_internal=True`, the job ends up `incomplete`, not stuck +`running` or silently `done`. + +--- + +## 4. New / changed CLI methods (`flomni.py`) + +### `tomo_queue_add_command(steps, label=None, idempotent=None)` + +Validate every step's `action` against `_TOMO_QUEUE_ACTIONS`; validate its `kwargs` +against the action's `params` schema (including the device allow-list for `move`); +confirm JSON-serializability (round-trip `json.dumps`); resolve `idempotent` +(explicit arg > **all steps idempotent**); append a `kind="command"` job with a fresh +uuid. Return the new index. Mirror the print style of `tomo_queue_add`. + +Convenience for the common single-step case is fine (`steps` may accept a single dict), +but the stored schema is always a list. + +```python +flomni.tomo_queue_add_command( + [{"action": "move", "kwargs": {"positions": {"mokev": 6.2}}}, + {"action": "optimize_idgap", "kwargs": {"search_range": 0.5}}], + label="reconfigure to 6.2 keV", +) +``` + +### `tomo_queue_add(...)` — unchanged behaviour +Keep as-is, but write `"kind": "tomo"` explicitly going forward (new jobs +self-describing; old ones without the field still default to tomo on read). + +### `tomo_queue_show()` — render both kinds +Branch on `kind`. Command rows: show the step sequence compactly + an idempotency +marker. Keep column alignment so the two kinds read as one list. + +``` +[2] pending reconfigure 6.2keV CMD move{mokev:6.2} > optimize_idgap{search_range:0.5} [idem] +``` + +### `tomo_queue_execute()` — branch on kind +Inside the existing **pick-next loop** (do not reintroduce an index loop), after +selecting the job and determining `resume_job`: + +```python +kind = job.get("kind", "tomo") +self._tomo_queue_proxy.update_by_id(job_id, status="running") +try: + if kind == "command": + self._run_command_job(job, resume_job) + else: # "tomo" + for name, value in job["params"].items(): + setattr(self, name, value) + if resume_job: + self.tomo_scan_resume() + else: + self.tomo_scan() +except Exception as exc: + self._tomo_queue_proxy.update_by_id(job_id, status="incomplete") + ... # existing pause/hint/raise +self._tomo_queue_proxy.update_by_id(job_id, status="done") +``` + +Note the tomo branch reads `job["params"]` — the command branch must never touch it +(command jobs have no `params` key). + +### `_run_command_job(job, resume_job)` +Apply the resume policy (§5), then run each step in order: resolve the action spec, +`getattr(self, spec["func"])`, call with `**step["kwargs"]`. + +--- + +## 5. Crash-resume semantics + +Tomo jobs resume mid-scan via `tomo_scan_resume()` because progress lives in global +vars. Command jobs have no such state. Policy: + +**No per-step resume cursor.** Deliberately: tracking which step of a multi-step job +completed means more persisted state and more edge cases than this warrants. + +**The job-level `idempotent` flag decides:** + +- `idempotent == True` (all steps idempotent — absolute moves, in/out, a scan-to-peak): + silently **re-run the whole step sequence from the top**. Re-running an absolute move + or an in-out is harmless. +- `idempotent == False` (any relative/stateful step): **do not silently re-run.** Stop + and prompt the operator (`OMNYTools.yesno`): *"Command job '