improvements in gui
This commit is contained in:
@@ -278,12 +278,7 @@ class XrayEyeAlign:
|
||||
self.gui.show_crosshair()
|
||||
|
||||
self.send_message(
|
||||
<<<<<<< Updated upstream
|
||||
"Submit height. Use arrows if far off."
|
||||
=======
|
||||
"Adjust sample height with the arrows if needed, then mark "
|
||||
"the sample and submit - height will be centered automatically"
|
||||
>>>>>>> Stashed changes
|
||||
)
|
||||
self.gui.enable_submit_button(True)
|
||||
self.movement_buttons_enabled(True, True)
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
from typing import Optional
|
||||
|
||||
from bec_lib import bec_logger
|
||||
from bec_widgets import BECWidget
|
||||
from bec_widgets.utils.rpc_decorator import rpc_timeout
|
||||
from qtpy.QtCore import Qt, QTimer
|
||||
from qtpy.QtCore import Signal
|
||||
from qtpy.QtGui import QKeySequence
|
||||
from qtpy.QtWidgets import (
|
||||
QButtonGroup,
|
||||
QDoubleSpinBox,
|
||||
QFormLayout,
|
||||
QFrame,
|
||||
QGridLayout,
|
||||
QGroupBox,
|
||||
@@ -92,6 +97,9 @@ class SlitControlWidget(BECWidget, QWidget):
|
||||
|
||||
PLUGIN = True
|
||||
|
||||
# carries fetched slit data from the background thread to the main thread
|
||||
_slit_data_ready = Signal(object)
|
||||
|
||||
USER_ACCESS = [
|
||||
"set_slit",
|
||||
"move_center",
|
||||
@@ -122,8 +130,14 @@ class SlitControlWidget(BECWidget, QWidget):
|
||||
self.step = self._STEP_DEFAULT
|
||||
self._ov_items: dict[tuple[int, int], QTableWidgetItem] = {}
|
||||
self._shortcuts: list[QShortcut] = []
|
||||
# rotating index through non-selected slits for slow background polling
|
||||
self._slow_poll_idx: int = 0
|
||||
self._fetch_thread: Optional[threading.Thread] = None
|
||||
self._slit_data_ready.connect(self._apply_slit_data)
|
||||
self._locked: bool = False
|
||||
self._move_btns: list[QPushButton] = [] # populated by _build_* methods
|
||||
self._lockout_timer = QTimer(self)
|
||||
self._lockout_timer.setSingleShot(True)
|
||||
self._lockout_timer.timeout.connect(self._release_lockout)
|
||||
self._build_ui()
|
||||
self._build_shortcuts() # created once, enabled/disabled by toggle
|
||||
self.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
|
||||
@@ -162,7 +176,28 @@ class SlitControlWidget(BECWidget, QWidget):
|
||||
|
||||
root.addWidget(_separator())
|
||||
|
||||
# 5. keyboard toggle + legend
|
||||
# 5. absolute target entry + stop button
|
||||
target_row = QHBoxLayout()
|
||||
target_row.addWidget(self._build_target_panel(), stretch=1)
|
||||
self._btn_stop = QPushButton("■ Stop")
|
||||
self._btn_stop.setToolTip("Stop all axes of the selected slit")
|
||||
self._btn_stop.clicked.connect(self._stop_slit)
|
||||
self._btn_stop.setMinimumWidth(70)
|
||||
target_row.addWidget(self._btn_stop, stretch=0)
|
||||
root.addLayout(target_row)
|
||||
|
||||
root.addWidget(_separator())
|
||||
|
||||
# 6. status label (move errors, limit hits)
|
||||
self._status_lbl = QLabel("")
|
||||
self._status_lbl.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
self._status_lbl.setMinimumHeight(18)
|
||||
root.addWidget(self._status_lbl)
|
||||
self._status_timer = QTimer(self)
|
||||
self._status_timer.setSingleShot(True)
|
||||
self._status_timer.timeout.connect(lambda: self._status_lbl.setText(""))
|
||||
|
||||
# 7. keyboard toggle + legend
|
||||
self._btn_kb = QPushButton("\u2328 Keyboard tweaking")
|
||||
self._btn_kb.setCheckable(True)
|
||||
self._btn_kb.toggled.connect(self._on_kb_toggled)
|
||||
@@ -172,12 +207,58 @@ class SlitControlWidget(BECWidget, QWidget):
|
||||
self._kb_legend.setVisible(False)
|
||||
root.addWidget(self._kb_legend)
|
||||
|
||||
def _build_target_panel(self) -> QGroupBox:
|
||||
"""Four spinboxes to move the selected slit to an absolute position."""
|
||||
box = QGroupBox("Move to absolute position (mm)")
|
||||
form = QFormLayout(box)
|
||||
form.setLabelAlignment(Qt.AlignmentFlag.AlignRight)
|
||||
|
||||
self._target_spins: dict[str, QDoubleSpinBox] = {}
|
||||
labels = {"xc": "x center", "xs": "x size", "yc": "y center", "ys": "y size"}
|
||||
|
||||
for suffix, label in labels.items():
|
||||
spin = QDoubleSpinBox()
|
||||
spin.setDecimals(4)
|
||||
spin.setRange(-500.0, 500.0)
|
||||
spin.setSingleStep(0.001)
|
||||
self._target_spins[suffix] = spin
|
||||
|
||||
go_btn = QPushButton("→")
|
||||
go_btn.setFixedWidth(28)
|
||||
go_btn.setToolTip(f"Move {label} to entered value")
|
||||
# capture suffix in closure
|
||||
go_btn.clicked.connect(lambda checked=False, s=suffix: self._move_absolute(s))
|
||||
spin.returnPressed = None # QDoubleSpinBox has no returnPressed
|
||||
|
||||
row_widget = QWidget()
|
||||
row_layout = QHBoxLayout(row_widget)
|
||||
row_layout.setContentsMargins(0, 0, 0, 0)
|
||||
row_layout.addWidget(spin)
|
||||
row_layout.addWidget(go_btn)
|
||||
form.addRow(f"{label}:", row_widget)
|
||||
|
||||
return box
|
||||
|
||||
def _build_overview(self) -> QGroupBox:
|
||||
box = QGroupBox("All slits (mm) – select to tweak")
|
||||
vbox = QVBoxLayout(box)
|
||||
|
||||
tbl = QTableWidget(len(self._SLIT_LABELS), 6)
|
||||
tbl.setHorizontalHeaderLabels(["", "Slit", "x center", "x size", "y center", "y size"])
|
||||
_R = Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter
|
||||
_L = Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter
|
||||
for col, (text, align) in enumerate(
|
||||
[
|
||||
("", _L),
|
||||
("Slit", _L),
|
||||
("x center", _R),
|
||||
("x size", _R),
|
||||
("y center", _R),
|
||||
("y size", _R),
|
||||
]
|
||||
):
|
||||
hdr_item = QTableWidgetItem(text)
|
||||
hdr_item.setTextAlignment(align)
|
||||
tbl.setHorizontalHeaderItem(col, hdr_item)
|
||||
# col 0 (radio) and col 1 (name) sized to content;
|
||||
# value cols 2-5 share remaining width equally → evenly distributed
|
||||
tbl.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeMode.ResizeToContents)
|
||||
@@ -243,6 +324,7 @@ class SlitControlWidget(BECWidget, QWidget):
|
||||
btn_shrink_y.clicked.connect(lambda: self.move_size("down"))
|
||||
btn_shrink_x.clicked.connect(lambda: self.move_size("left"))
|
||||
btn_grow_x.clicked.connect(lambda: self.move_size("right"))
|
||||
self._move_btns += [btn_grow_y, btn_shrink_y, btn_shrink_x, btn_grow_x]
|
||||
|
||||
grid.addWidget(btn_grow_y, 0, 1)
|
||||
grid.addWidget(btn_shrink_x, 1, 0)
|
||||
@@ -275,6 +357,7 @@ class SlitControlWidget(BECWidget, QWidget):
|
||||
btn_down.clicked.connect(lambda: self.move_center("down"))
|
||||
btn_left.clicked.connect(lambda: self.move_center("left"))
|
||||
btn_right.clicked.connect(lambda: self.move_center("right"))
|
||||
self._move_btns += [btn_up, btn_down, btn_left, btn_right]
|
||||
|
||||
grid.addWidget(btn_up, 0, 1)
|
||||
grid.addWidget(btn_left, 1, 0)
|
||||
@@ -374,40 +457,131 @@ class SlitControlWidget(BECWidget, QWidget):
|
||||
else:
|
||||
self._btn_kb.setText("\u2328 Keyboard tweaking")
|
||||
|
||||
def _engage_lockout(self, ms: int = 2000) -> None:
|
||||
"""Disable move buttons and keyboard move shortcuts for ``ms`` ms."""
|
||||
self._locked = True
|
||||
for btn in self._move_btns:
|
||||
btn.setEnabled(False)
|
||||
# disable only the movement shortcuts (indices 6+); keep slit-select (0-5)
|
||||
for sc in self._shortcuts[6:]:
|
||||
sc.setEnabled(False)
|
||||
self._lockout_timer.start(ms)
|
||||
|
||||
def _release_lockout(self) -> None:
|
||||
"""Re-enable move buttons and shortcuts (called by the lockout timer)."""
|
||||
self._locked = False
|
||||
for btn in self._move_btns:
|
||||
btn.setEnabled(True)
|
||||
# only re-enable movement shortcuts if keyboard mode is active
|
||||
if self._btn_kb.isChecked():
|
||||
for sc in self._shortcuts[6:]:
|
||||
sc.setEnabled(True)
|
||||
|
||||
def _populate_target_spins(self) -> None:
|
||||
"""Pre-fill target spinboxes with current device values."""
|
||||
for suffix, spin in self._target_spins.items():
|
||||
try:
|
||||
device = getattr(self.dev, self._dev_name(suffix))
|
||||
reading = device.read()
|
||||
value = float(reading[self._dev_name(suffix)]["value"])
|
||||
spin.setValue(value)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _move_absolute(self, axis_suffix: str) -> None:
|
||||
"""Move one axis to the value currently entered in its target spinbox."""
|
||||
if self._locked:
|
||||
return
|
||||
self._engage_lockout(3000) # absolute moves may cover more distance
|
||||
dev_name = self._dev_name(axis_suffix)
|
||||
target = self._target_spins[axis_suffix].value()
|
||||
try:
|
||||
device = getattr(self.dev, dev_name)
|
||||
device.move(target)
|
||||
except Exception as exc:
|
||||
msg = str(exc) or type(exc).__name__
|
||||
logger.warning(f"SlitControlWidget: absolute move {dev_name} failed: {msg}")
|
||||
self._show_status(f"{dev_name}: {msg}")
|
||||
|
||||
def _stop_slit(self) -> None:
|
||||
"""Stop all four axes of the currently selected slit."""
|
||||
for suffix in ("xc", "xs", "yc", "ys"):
|
||||
try:
|
||||
device = getattr(self.dev, self._dev_name(suffix))
|
||||
device.stop()
|
||||
except Exception as exc:
|
||||
msg = str(exc) or type(exc).__name__
|
||||
logger.warning(f"SlitControlWidget: stop {self._dev_name(suffix)} failed: {msg}")
|
||||
self._show_status(f"Stop failed: {msg}")
|
||||
|
||||
def _show_status(self, msg: str, error: bool = True) -> None:
|
||||
"""Show a brief status message below the controls (auto-clears after 4 s)."""
|
||||
color = "#e53935" if error else "#43a047"
|
||||
self._status_lbl.setText(f'<span style="color:{color}">{msg}</span>')
|
||||
self._status_timer.start(4000)
|
||||
|
||||
# ── overview polling ──────────────────────────────────────────────────────
|
||||
|
||||
def _refresh_slit_row(self, slit_num: int) -> None:
|
||||
"""Read all four axis values for one slit and update its table row."""
|
||||
row = list(self._SLIT_LABELS).index(slit_num)
|
||||
for col_off, suffix in enumerate(("xc", "xs", "yc", "ys")):
|
||||
dev_name = f"sl{slit_num}{suffix}"
|
||||
item = self._ov_items.get((row, col_off + 2))
|
||||
if item is None:
|
||||
continue
|
||||
try:
|
||||
device = getattr(self.dev, dev_name)
|
||||
reading = device.read()
|
||||
value = float(reading[dev_name]["value"])
|
||||
item.setText(f"{value:.4f}")
|
||||
except Exception:
|
||||
item.setText("---")
|
||||
# ── background polling ────────────────────────────────────────────────────
|
||||
|
||||
def _refresh_overview(self) -> None:
|
||||
"""
|
||||
Per-tick (2 s) refresh strategy:
|
||||
- Selected slit: every tick → updated every 2 s.
|
||||
- Other slits: one per tick in rotation → each updated every ~10 s
|
||||
(5 other slits × 2 s). At most 8 device.read() calls per tick.
|
||||
"""
|
||||
# always refresh the selected slit
|
||||
self._refresh_slit_row(self.slit)
|
||||
Kick off a background thread to fetch slit values without blocking the
|
||||
Qt main thread. device.read() on virtual slit devices triggers RPC
|
||||
calls to the device server; doing those in the main thread stalls the UI.
|
||||
The thread emits _slit_data_ready when done; _apply_slit_data updates
|
||||
the table and target spinboxes safely from the main thread.
|
||||
|
||||
# advance through the other slits one per tick
|
||||
Per-tick strategy (2 s timer):
|
||||
- Selected slit: every tick.
|
||||
- Other slits: one per tick in rotation (~10 s per non-selected slit).
|
||||
"""
|
||||
if self._fetch_thread is not None and self._fetch_thread.is_alive():
|
||||
return # previous fetch still running – skip this tick
|
||||
self._fetch_thread = threading.Thread(target=self._fetch_slit_data, daemon=True)
|
||||
self._fetch_thread.start()
|
||||
|
||||
def _fetch_slit_data(self) -> None:
|
||||
"""Runs in background thread – NO Qt widget access here."""
|
||||
data: dict[tuple[int, str], Optional[float]] = {}
|
||||
self._fetch_one_slit(self.slit, data)
|
||||
others = [n for n in self._SLIT_LABELS if n != self.slit]
|
||||
if others:
|
||||
self._slow_poll_idx = self._slow_poll_idx % len(others)
|
||||
self._refresh_slit_row(others[self._slow_poll_idx])
|
||||
self._fetch_one_slit(others[self._slow_poll_idx], data)
|
||||
self._slow_poll_idx += 1
|
||||
self._slit_data_ready.emit(data) # thread-safe: Qt queues it to main thread
|
||||
|
||||
def _fetch_one_slit(self, slit_num: int, data: dict[tuple[int, str], Optional[float]]) -> None:
|
||||
"""Fetch four axis values for one slit into data (background thread)."""
|
||||
for suffix in ("xc", "xs", "yc", "ys"):
|
||||
dev_name = f"sl{slit_num}{suffix}"
|
||||
try:
|
||||
device = getattr(self.dev, dev_name)
|
||||
reading = device.read()
|
||||
data[(slit_num, suffix)] = float(reading[dev_name]["value"])
|
||||
except Exception:
|
||||
data[(slit_num, suffix)] = None
|
||||
|
||||
def _apply_slit_data(self, data: dict) -> None:
|
||||
"""Update table and target spinboxes in the main thread (slot for signal)."""
|
||||
suffix_order = ("xc", "xs", "yc", "ys")
|
||||
for (slit_num, suffix), value in data.items():
|
||||
row = list(self._SLIT_LABELS).index(slit_num)
|
||||
col_off = suffix_order.index(suffix)
|
||||
item = self._ov_items.get((row, col_off + 2))
|
||||
if item is None:
|
||||
continue
|
||||
if value is not None:
|
||||
item.setText(f"{value:.3f}")
|
||||
if slit_num == self.slit and hasattr(self, "_target_spins"):
|
||||
spin = self._target_spins.get(suffix)
|
||||
if spin is not None and not spin.hasFocus():
|
||||
spin.blockSignals(True)
|
||||
spin.setValue(value)
|
||||
spin.blockSignals(False)
|
||||
else:
|
||||
item.setText("---")
|
||||
|
||||
def _update_row_bold(self):
|
||||
for row, (slit_num, _) in enumerate(self._SLIT_LABELS.items()):
|
||||
@@ -436,6 +610,7 @@ class SlitControlWidget(BECWidget, QWidget):
|
||||
btn.setChecked(True)
|
||||
self._tweaking_lbl.setText(f"Tweaking: {self._SLIT_LABELS[slit]}")
|
||||
self._update_row_bold()
|
||||
self._populate_target_spins()
|
||||
|
||||
def move_center(self, direction: str):
|
||||
"""Move the slit center. direction: up / down / left / right."""
|
||||
@@ -464,6 +639,9 @@ class SlitControlWidget(BECWidget, QWidget):
|
||||
self._move_relative(suffix, sign * self.step)
|
||||
|
||||
def _move_relative(self, axis_suffix: str, delta: float):
|
||||
if self._locked:
|
||||
return
|
||||
self._engage_lockout(2000)
|
||||
dev_name = self._dev_name(axis_suffix)
|
||||
try:
|
||||
device = getattr(self.dev, dev_name)
|
||||
@@ -471,7 +649,9 @@ class SlitControlWidget(BECWidget, QWidget):
|
||||
current = float(reading[dev_name]["value"])
|
||||
device.move(current + delta)
|
||||
except Exception as exc:
|
||||
logger.warning(f"SlitControlWidget: failed to move {dev_name}: {exc}")
|
||||
msg = str(exc) or type(exc).__name__
|
||||
logger.warning(f"SlitControlWidget: failed to move {dev_name}: {msg}")
|
||||
self._show_status(f"{dev_name}: {msg}")
|
||||
|
||||
def set_step(self, value: float):
|
||||
"""Set step size in mm (clamped to 0.005–2.0 mm)."""
|
||||
|
||||
@@ -292,14 +292,6 @@ class TomoParamsWidget(BECWidget, QWidget):
|
||||
decimals=3,
|
||||
)
|
||||
self._add_int(common_form, "frames_per_trigger", "Frames / trigger", min_=1, max_=100)
|
||||
self._add_double(
|
||||
common_form,
|
||||
"corridor_size",
|
||||
"Corridor size (µm, −1=off)",
|
||||
min_=-1.0,
|
||||
max_=200.0,
|
||||
decimals=2,
|
||||
)
|
||||
sp = self._add_bool(common_form, "single_point_instead_of_fermat_scan", "Single-point scan")
|
||||
sp.stateChanged.connect(self._on_single_point_changed)
|
||||
vbox.addLayout(common_form)
|
||||
@@ -642,8 +634,49 @@ class TomoParamsWidget(BECWidget, QWidget):
|
||||
params = self._load_params()
|
||||
self._populate_fields(params)
|
||||
|
||||
def _is_tomo_running(self) -> bool:
|
||||
"""
|
||||
Return True if a tomo scan is likely active.
|
||||
|
||||
Uses the ``tomo_progress["heartbeat"]`` timestamp written by
|
||||
``_tomo_scan_at_angle()`` at the start of every projection — this is
|
||||
updated whether the scan was started via the queue or directly via
|
||||
``flomni.tomo_scan()``, so it's more reliable than queue job status.
|
||||
A heartbeat fresher than 2 minutes indicates a scan in progress;
|
||||
older than that it's either done, or stuck long enough that editing
|
||||
parameters is intentional.
|
||||
"""
|
||||
import datetime
|
||||
|
||||
try:
|
||||
prog = self._gv_get("tomo_progress")
|
||||
if not isinstance(prog, dict):
|
||||
return False
|
||||
heartbeat_str = prog.get("heartbeat")
|
||||
if heartbeat_str is None:
|
||||
return False
|
||||
heartbeat = datetime.datetime.fromisoformat(heartbeat_str)
|
||||
age_s = (datetime.datetime.now() - heartbeat).total_seconds()
|
||||
return age_s < 120 # 2-minute window
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def submit_params(self) -> None:
|
||||
"""Validate, write to global vars, and re-lock fields."""
|
||||
if self._is_tomo_running():
|
||||
reply = QMessageBox.warning(
|
||||
self,
|
||||
"Scan in progress",
|
||||
"A tomo scan appears to be running.\n\n"
|
||||
"Parameters read via properties (e.g. counting time) take effect "
|
||||
"immediately on the next projection. Parameters used to build the "
|
||||
"scan trajectory (FOV, angle step) are already fixed for this run "
|
||||
"but will affect any subsequent queue job.\n\n"
|
||||
"Apply changes now?",
|
||||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.Cancel,
|
||||
)
|
||||
if reply != QMessageBox.StandardButton.Yes:
|
||||
return
|
||||
params = self._read_fields()
|
||||
error = self._validate(params)
|
||||
if error:
|
||||
|
||||
@@ -58,6 +58,7 @@ fheater:
|
||||
readOnly: false
|
||||
readoutPriority: baseline
|
||||
connectionTimeout: 20
|
||||
|
||||
foptx:
|
||||
description: Optics X
|
||||
deviceClass: csaxs_bec.devices.omny.galil.fgalil_ophyd.FlomniGalilMotor
|
||||
@@ -78,8 +79,12 @@ foptx:
|
||||
#170 micron, 60 nm
|
||||
#in: -13.831
|
||||
#250 micron, 30 nm, Abe structures
|
||||
in: -13.8809375
|
||||
# in: -13.8809375
|
||||
# out: -14.1809
|
||||
#250 micron, 30 nm, Tomas structures
|
||||
in: -14.5490625
|
||||
out: -14.1809
|
||||
|
||||
fopty:
|
||||
description: Optics Y
|
||||
deviceClass: csaxs_bec.devices.omny.galil.fgalil_ophyd.FlomniGalilMotor
|
||||
@@ -101,8 +106,12 @@ fopty:
|
||||
#in: 0.42
|
||||
#out: 0.57
|
||||
#250 micron, 30 nm, Abe structures
|
||||
in: 2.8299
|
||||
out: 2.8299
|
||||
# in: 2.8299
|
||||
# out: 2.8299
|
||||
#250 micron, 30 nm, Tomas structures
|
||||
in: 2.8419921875
|
||||
out: 2.8419921875
|
||||
|
||||
foptz:
|
||||
description: Optics Z
|
||||
deviceClass: csaxs_bec.devices.omny.galil.fgalil_ophyd.FlomniGalilMotor
|
||||
@@ -121,6 +130,7 @@ foptz:
|
||||
connectionTimeout: 20
|
||||
userParameter:
|
||||
in: 23
|
||||
|
||||
fsamroy:
|
||||
description: Sample rotation
|
||||
deviceClass: csaxs_bec.devices.omny.galil.fupr_ophyd.FuprGalilMotor
|
||||
@@ -154,7 +164,7 @@ fsamx:
|
||||
readoutPriority: baseline
|
||||
connectionTimeout: 20
|
||||
userParameter:
|
||||
in: -1.1
|
||||
in: -1.14
|
||||
fsamy:
|
||||
description: Sample coarse Y
|
||||
deviceClass: csaxs_bec.devices.omny.galil.fgalil_ophyd.FlomniGalilMotor
|
||||
@@ -314,8 +324,12 @@ fosax:
|
||||
# in: 8.731922
|
||||
# out: 5.1
|
||||
#250 micron, 30 nm, Abe structures
|
||||
in: 8.755141
|
||||
# in: 8.755141
|
||||
# out: 5.1
|
||||
#250 micron, 30 nm, Tomas structures
|
||||
in: 9.420798
|
||||
out: 5.1
|
||||
|
||||
fosay:
|
||||
description: OSA Y
|
||||
deviceClass: csaxs_bec.devices.smaract.smaract_ophyd.SmaractMotor
|
||||
@@ -338,7 +352,9 @@ fosay:
|
||||
#170 micron, 60 nm, 7.6 kev
|
||||
#in: -0.0422
|
||||
#250 micron, 30 nm, Abe structures
|
||||
in: -2.357436
|
||||
# in: -2.357436
|
||||
#250 micron, 30 nm, Tomas structures
|
||||
in: -2.383993
|
||||
fosaz:
|
||||
description: OSA Z
|
||||
deviceClass: csaxs_bec.devices.smaract.smaract_ophyd.SmaractMotor
|
||||
@@ -364,7 +380,7 @@ fosaz:
|
||||
# out: 6
|
||||
# micron, 30 nm, 7.9 kev, foptz 32 //abe's fzp's
|
||||
in: -2
|
||||
out: -5
|
||||
out: -5
|
||||
|
||||
############################################################
|
||||
#################### flOMNI RT motors ######################
|
||||
|
||||
Reference in New Issue
Block a user