tweak: stacked keys, Qt/ipywidgets panels, detector recording
- Tweak.tweak(): 1 adjustable arrows, 2 x/y arrows, 3-4 stacked keyboard rows (j k l ; / u i o p / 7 8 9 0, plus m , . / for four), each row neg dir, step*2, step/2, pos dir; stacked=True forces it for 1-2. - Tweak panels (eco.widgets.tweak_panel, _qt, _ipy) with everything the terminal tweak does for 1-4 axes, plus a 'keypress control' checkbox (on by default) that takes the same keys and shows the key manual. Motor records drive their own TWV/TWF/TWR fields. Opened by ._widget_tweak() / Tweak.widget(), and by .tweak() wherever there is no terminal: notebooks get ipywidgets, eco desktop consoles get Qt (subprocess kernels are marked with ECO_QTCONSOLE_KERNEL). Replaces the three old notebook tweak UIs and their page-wide JS key handler. - detectors= on Tweak, tweak() and _widget_tweak(): values recorded per tweak step, averaged while the positions are constant, and plotted live: against the position for one adjustable, as a stack over the step number for several. Embedded in the panels, separate Qt window for the terminal tweak; data via .to_dataframe() on the recorder. - Tweak.wait() now waits for all changers, not only the last one.
This commit is contained in:
1 parent
d64dffe14f
commit
9bc4f8d01e
9 files changed
+2298
-628
No files matched your search
+63
-205
@@ -145,185 +145,59 @@ def _is_notebook():
|
||||
return False
|
||||
|
||||
|
||||
def _tweak_ioc_notebook(self, step_value=None):
|
||||
try:
|
||||
from IPython.display import display, Javascript
|
||||
import ipywidgets as widgets
|
||||
except Exception as exc:
|
||||
raise RuntimeError(
|
||||
"Notebook tweak mode requires IPython and ipywidgets."
|
||||
) from exc
|
||||
def _widget_tweak_ioc(
|
||||
self, step_value=None, backend=None, display=False, detectors=None
|
||||
):
|
||||
"""Tweak panel (Qt or ipywidgets) driving the motor record's own tweak
|
||||
fields (TWV/TWF/TWR), with the same keys as the terminal tweak under
|
||||
"keypress control" -- see eco.widgets.tweak_panel. `detectors` are
|
||||
recorded and plotted along the tweak (eco.widgets.tweak_recorder)."""
|
||||
from eco.widgets.tweak_panel import IocTweakAxis, tweak_panel
|
||||
|
||||
if hasattr(self, "_motor") and hasattr(self._motor, "get_pv"):
|
||||
pv = self._motor.get_pv("TWV")
|
||||
pvf = self._motor.get_pv("TWF")
|
||||
pvr = self._motor.get_pv("TWR")
|
||||
else:
|
||||
pv = PV(self.pvname + ":TWV")
|
||||
pvf = PV(self.pvname + ":TWF.PROC")
|
||||
pvr = PV(self.pvname + ":TWR.PROC")
|
||||
if step_value is None:
|
||||
step_value = pv.get()
|
||||
try:
|
||||
step_value = float(step_value)
|
||||
except Exception:
|
||||
step_value = 1.0
|
||||
|
||||
def _format_value(value):
|
||||
try:
|
||||
return f"{value:1.6g}"
|
||||
except Exception:
|
||||
return str(value)
|
||||
|
||||
target_value = self.get_current_value()
|
||||
current_label = widgets.HTML(
|
||||
value=f"<b>Current position:</b> {_format_value(target_value)}"
|
||||
return tweak_panel(
|
||||
[IocTweakAxis(self, step_value, go_to_current_value_first=True)],
|
||||
backend=backend,
|
||||
display=display,
|
||||
detectors=detectors,
|
||||
)
|
||||
status_label = widgets.HTML(value=f"<b>Step size:</b> {_format_value(step_value)}")
|
||||
help_label = widgets.HTML(
|
||||
value=(
|
||||
"<b>Controls:</b> Stepsize *2, Stepsize /2, Down, Up, Go abs, Reset offset, Exit"
|
||||
|
||||
|
||||
def _tweak_ioc_entry(self, *args, detectors=None, **kwargs):
|
||||
"""Interactive tweak through the motor record's tweak fields.
|
||||
|
||||
detectors: optional Detector or list of Detectors, recorded for every
|
||||
tweak step (averaged while the position stays constant) and plotted
|
||||
against the position; the data stays in ._tweak_recorder."""
|
||||
if detectors is None:
|
||||
return self._tweak_ioc(*args, **kwargs)
|
||||
from eco.widgets.tweak_panel import IocTweakAxis, frontend
|
||||
from eco.widgets.tweak_recorder import terminal_recording
|
||||
|
||||
step_value = args[0] if args else kwargs.get("step_value")
|
||||
if frontend() != "terminal":
|
||||
self._tweak_panel = _widget_tweak_ioc(
|
||||
self, step_value, display=True, detectors=detectors
|
||||
)
|
||||
)
|
||||
step_input = widgets.FloatText(
|
||||
value=step_value,
|
||||
description="Step:",
|
||||
layout=widgets.Layout(width="220px"),
|
||||
)
|
||||
go_input = widgets.FloatText(
|
||||
value=target_value,
|
||||
description="Go:",
|
||||
layout=widgets.Layout(width="220px"),
|
||||
)
|
||||
set_input = widgets.FloatText(
|
||||
value=target_value,
|
||||
description="Set:",
|
||||
layout=widgets.Layout(width="220px"),
|
||||
)
|
||||
self._tweak_recorder = self._tweak_panel.recorder
|
||||
return
|
||||
with terminal_recording(
|
||||
[IocTweakAxis(self, step_value)],
|
||||
detectors,
|
||||
owner=self,
|
||||
label=f"{self.name}._tweak_recorder",
|
||||
):
|
||||
return self._tweak_ioc(*args, **kwargs)
|
||||
|
||||
def _refresh_current(value=None):
|
||||
if value is None:
|
||||
value = self.get_current_value()
|
||||
current_label.value = f"<b>Current position:</b> {_format_value(value)}"
|
||||
status_label.value = f"<b>Step size:</b> {_format_value(step_input.value)}"
|
||||
|
||||
def _update_callback(**kwargs):
|
||||
if "value" in kwargs:
|
||||
_refresh_current(kwargs["value"])
|
||||
else:
|
||||
_refresh_current()
|
||||
def _tweak_panel_instead(self, step_value):
|
||||
"""Where there is no terminal to read keys from (notebook, eco desktop
|
||||
console), show the tweak panel instead. True if it did."""
|
||||
from eco.widgets.tweak_panel import frontend
|
||||
|
||||
callback_id = self.add_value_callback(_update_callback)
|
||||
|
||||
def _set_step(factor):
|
||||
step_input.value = float(step_input.value) * factor
|
||||
pv.put(step_input.value)
|
||||
status_label.value = f"<b>Step size:</b> {_format_value(step_input.value)}"
|
||||
|
||||
def _click_up(_):
|
||||
_set_step(2.0)
|
||||
|
||||
def _click_down(_):
|
||||
_set_step(0.5)
|
||||
|
||||
def _click_left(_):
|
||||
pvr.put(1)
|
||||
status_label.value = "<b>Command:</b> left"
|
||||
|
||||
def _click_right(_):
|
||||
pvf.put(1)
|
||||
status_label.value = "<b>Command:</b> right"
|
||||
|
||||
def _click_go(_):
|
||||
try:
|
||||
self.set_target_value(float(go_input.value), check=True).wait()
|
||||
_refresh_current()
|
||||
status_label.value = f"<b>Moved to:</b> {_format_value(go_input.value)}"
|
||||
except Exception as exc:
|
||||
status_label.value = f"<b>Error:</b> {exc}"
|
||||
|
||||
def _click_set(_):
|
||||
try:
|
||||
self.reset_current_value_to(float(set_input.value))
|
||||
_refresh_current()
|
||||
status_label.value = (
|
||||
f"<b>Offset reset to:</b> {_format_value(set_input.value)}"
|
||||
)
|
||||
except Exception as exc:
|
||||
status_label.value = f"<b>Error:</b> {exc}"
|
||||
|
||||
def _shutdown(_=None):
|
||||
self.clear_value_callback(index=callback_id)
|
||||
for ctl in [
|
||||
step_input,
|
||||
go_input,
|
||||
set_input,
|
||||
btn_up,
|
||||
btn_down,
|
||||
btn_left,
|
||||
btn_right,
|
||||
btn_go,
|
||||
btn_set,
|
||||
btn_exit,
|
||||
]:
|
||||
ctl.disabled = True
|
||||
status_label.value = "<b>Tweak UI closed.</b>"
|
||||
|
||||
btn_up = widgets.Button(description="Stepsize *2", button_style="success")
|
||||
btn_down = widgets.Button(description="Stepsize /2", button_style="warning")
|
||||
btn_left = widgets.Button(description="Down", button_style="info")
|
||||
btn_right = widgets.Button(description="Up", button_style="info")
|
||||
btn_go = widgets.Button(description="Go abs", button_style="primary")
|
||||
btn_set = widgets.Button(description="Reset offset", button_style="primary")
|
||||
btn_exit = widgets.Button(description="Exit", button_style="danger")
|
||||
|
||||
btn_up.on_click(_click_up)
|
||||
btn_down.on_click(_click_down)
|
||||
btn_left.on_click(_click_left)
|
||||
btn_right.on_click(_click_right)
|
||||
btn_go.on_click(_click_go)
|
||||
btn_set.on_click(_click_set)
|
||||
btn_exit.on_click(_shutdown)
|
||||
|
||||
controls = widgets.HBox(
|
||||
[btn_up, btn_down, btn_left, btn_right, btn_go, btn_set, btn_exit]
|
||||
)
|
||||
inputs = widgets.HBox([step_input, go_input, set_input])
|
||||
ui = widgets.VBox([current_label, status_label, help_label, inputs, controls])
|
||||
|
||||
js = f"""
|
||||
(function() {{
|
||||
const mapping = {{
|
||||
u: '{btn_up._model_id}',
|
||||
d: '{btn_down._model_id}',
|
||||
l: '{btn_left._model_id}',
|
||||
r: '{btn_right._model_id}',
|
||||
g: '{btn_go._model_id}',
|
||||
s: '{btn_set._model_id}',
|
||||
q: '{btn_exit._model_id}'
|
||||
}};
|
||||
window.__eco_tweak_keys = window.__eco_tweak_keys || {{}};
|
||||
window.__eco_tweak_keys = Object.assign(window.__eco_tweak_keys, mapping);
|
||||
if (!window.__eco_tweak_key_handler) {{
|
||||
window.__eco_tweak_key_handler = function(event) {{
|
||||
const key = event.key.toLowerCase();
|
||||
const targetId = window.__eco_tweak_keys[key];
|
||||
if (!targetId) return;
|
||||
const root = document.querySelector('[data-widget-id="' + targetId + '"]');
|
||||
if (!root) return;
|
||||
const btn = root.querySelector('button');
|
||||
if (btn && !btn.disabled) {{
|
||||
btn.click();
|
||||
event.preventDefault();
|
||||
}}
|
||||
}};
|
||||
document.addEventListener('keydown', window.__eco_tweak_key_handler);
|
||||
}}
|
||||
}})();
|
||||
"""
|
||||
display(ui)
|
||||
display(Javascript(js))
|
||||
return ui
|
||||
if frontend() == "terminal":
|
||||
return False
|
||||
self._tweak_panel = _widget_tweak_ioc(self, step_value, display=True)
|
||||
return True
|
||||
|
||||
|
||||
@spec_convenience
|
||||
@@ -614,14 +488,8 @@ class SmaractStreamdevice(Assembly):
|
||||
self._currentChange = self.set_target_value(value)
|
||||
|
||||
def _tweak_ioc(self, step_value=None):
|
||||
if _is_notebook():
|
||||
try:
|
||||
return _tweak_ioc_notebook(self, step_value=step_value)
|
||||
except Exception as exc:
|
||||
print(
|
||||
"Notebook tweak UI failed; falling back to terminal mode:",
|
||||
exc,
|
||||
)
|
||||
if _tweak_panel_instead(self, step_value):
|
||||
return
|
||||
|
||||
pv = PV(self.pvname + ":TWV")
|
||||
pvf = PV(self.pvname + ":TWF.PROC")
|
||||
@@ -700,8 +568,8 @@ class SmaractStreamdevice(Assembly):
|
||||
print(f"final position: {self.get_current_value()}")
|
||||
print(f"final tweak step: {pv.get()}")
|
||||
|
||||
def tweak(self, *args, **kwargs):
|
||||
return self._tweak_ioc(*args, **kwargs)
|
||||
tweak = _tweak_ioc_entry
|
||||
_widget_tweak = _widget_tweak_ioc
|
||||
|
||||
def gui(self):
|
||||
num = ""
|
||||
@@ -1874,14 +1742,8 @@ class MotorRecord(Assembly):
|
||||
def _tweak_ioc(self, step_value=None, go_to_current_value_first=True):
|
||||
if go_to_current_value_first:
|
||||
self.set_target_value(self.get_current_value()).wait()
|
||||
if _is_notebook():
|
||||
try:
|
||||
return _tweak_ioc_notebook(self, step_value=step_value)
|
||||
except Exception as exc:
|
||||
print(
|
||||
"Notebook tweak UI failed; falling back to terminal mode:",
|
||||
exc,
|
||||
)
|
||||
if _tweak_panel_instead(self, step_value):
|
||||
return
|
||||
|
||||
pv = self._motor.get_pv("TWV")
|
||||
pvf = self._motor.get_pv("TWF")
|
||||
@@ -1965,8 +1827,8 @@ class MotorRecord(Assembly):
|
||||
print(f"final tweak step: {pv.get()}")
|
||||
# print('\033[K',"the info",sep='',flush=True)
|
||||
|
||||
def tweak(self, *args, **kwargs):
|
||||
return self._tweak_ioc(*args, **kwargs)
|
||||
tweak = _tweak_ioc_entry
|
||||
_widget_tweak = _widget_tweak_ioc
|
||||
|
||||
|
||||
MotorRecord = MotorRecord
|
||||
@@ -2506,14 +2368,8 @@ class SmaractRecord(Assembly):
|
||||
def _tweak_ioc(self, step_value=None, go_to_current_value_first=True):
|
||||
if go_to_current_value_first:
|
||||
self.set_target_value(self.get_current_value()).wait()
|
||||
if _is_notebook():
|
||||
try:
|
||||
return _tweak_ioc_notebook(self, step_value=step_value)
|
||||
except Exception as exc:
|
||||
print(
|
||||
"Notebook tweak UI failed; falling back to terminal mode:",
|
||||
exc,
|
||||
)
|
||||
if _tweak_panel_instead(self, step_value):
|
||||
return
|
||||
|
||||
pv = self._motor.get_pv("TWV")
|
||||
pvf = self._motor.get_pv("TWF")
|
||||
@@ -2589,8 +2445,8 @@ class SmaractRecord(Assembly):
|
||||
print(f"final position: {self.get_current_value()}")
|
||||
print(f"final tweak step: {pv.get()}")
|
||||
|
||||
def tweak(self, *args, **kwargs):
|
||||
return self._tweak_ioc(*args, **kwargs)
|
||||
tweak = _tweak_ioc_entry
|
||||
_widget_tweak = _widget_tweak_ioc
|
||||
|
||||
|
||||
@spec_convenience
|
||||
@@ -2909,6 +2765,8 @@ class SmaractRecord_old(Assembly):
|
||||
self._currentChange = self.set_target_value(value)
|
||||
|
||||
def _tweak_ioc(self, step_value=None):
|
||||
if _tweak_panel_instead(self, step_value):
|
||||
return
|
||||
pv = self._motor.get_pv("TWV")
|
||||
pvf = self._motor.get_pv("TWF")
|
||||
pvr = self._motor.get_pv("TWR")
|
||||
@@ -2983,8 +2841,8 @@ class SmaractRecord_old(Assembly):
|
||||
print(f"final position: {self.get_current_value()}")
|
||||
print(f"final tweak step: {pv.get()}")
|
||||
|
||||
def tweak(self, *args, **kwargs):
|
||||
return self._tweak_ioc(*args, **kwargs)
|
||||
tweak = _tweak_ioc_entry
|
||||
_widget_tweak = _widget_tweak_ioc
|
||||
|
||||
|
||||
flag_names_smaract_record = {
|
||||
|
||||
+188
-423
@@ -53,11 +53,21 @@ class AdjustableError(Exception):
|
||||
|
||||
|
||||
def tweak_option(Obj):
|
||||
def tweak(self, interval, *args, **kwargs):
|
||||
self._tweak_instance = Tweak((self, interval))
|
||||
self._tweak_instance.single_adjustable_tweak()
|
||||
def tweak(self, interval, *args, detectors=None, **kwargs):
|
||||
"""Interactive tweak; `detectors` (a Detector or a list) are recorded
|
||||
and plotted along the tweak, see Tweak."""
|
||||
self._tweak_instance = Tweak((self, interval), detectors=detectors)
|
||||
self._tweak_instance._recorder_label = f"{self.name}._tweak_instance.recorder"
|
||||
self._tweak_instance.tweak()
|
||||
|
||||
def _widget_tweak(self, interval=1.0, backend=None, display=False, detectors=None):
|
||||
"""Tweak panel (Qt or ipywidgets) with keypress control, see
|
||||
eco.widgets.tweak_panel."""
|
||||
self._tweak_instance = Tweak((self, interval), detectors=detectors)
|
||||
return self._tweak_instance.widget(backend=backend, display=display)
|
||||
|
||||
Obj.tweak = tweak
|
||||
Obj._widget_tweak = _widget_tweak
|
||||
return Obj
|
||||
|
||||
|
||||
@@ -1190,8 +1200,16 @@ class AdjustableEnum:
|
||||
return self._base.set_target_value(value, hold=hold)
|
||||
|
||||
class Tweak:
|
||||
def __init__(self, *args):
|
||||
"""usage: Tweak((adj0,startstepsize0),(adj1,startstepsize1))"""
|
||||
def __init__(self, *args, detectors=None):
|
||||
"""usage: Tweak((adj0,startstepsize0),(adj1,startstepsize1))
|
||||
|
||||
detectors: optional Detector or list of Detectors. Their values are
|
||||
recorded for every tweak step (averaged while the positions stay
|
||||
constant) and live-plotted -- against the position for one
|
||||
adjustable, as a stack over the step number for several (see
|
||||
eco.widgets.tweak_recorder). The data stays in .recorder."""
|
||||
self.detectors = detectors
|
||||
self.recorder = None
|
||||
self.adjs = []
|
||||
startsteps = []
|
||||
for adj, startstep in args:
|
||||
@@ -1246,14 +1264,8 @@ class Tweak:
|
||||
if len(self.adjs) != 2:
|
||||
raise AdjustableError("xy_adjustable_tweak requires exactly two adjustables")
|
||||
|
||||
if _is_notebook():
|
||||
try:
|
||||
return self._xy_adjustable_tweak_notebook()
|
||||
except Exception as exc:
|
||||
print(
|
||||
"Notebook tweak UI failed; falling back to terminal mode:",
|
||||
exc,
|
||||
)
|
||||
if self._open_panel_instead():
|
||||
return
|
||||
|
||||
x_adj, y_adj = self.adjs
|
||||
i_x, i_y = 0, 1
|
||||
@@ -1333,12 +1345,169 @@ class Tweak:
|
||||
|
||||
k.waitkey()
|
||||
|
||||
# Keyboard rows for stacked_adjustable_tweak, bottom to top. Within a row
|
||||
# the keys mirror the arrow layout of the single tweak:
|
||||
# (left = neg dir, up = step*2, down = step/2, right = pos dir).
|
||||
STACKED_KEY_ROWS = (
|
||||
("m", ",", ".", "/"),
|
||||
("j", "k", "l", ";"),
|
||||
("u", "i", "o", "p"),
|
||||
("7", "8", "9", "0"),
|
||||
)
|
||||
|
||||
def tweak(self, stacked=None):
|
||||
"""Interactive keyboard tweak of all adjustables of this Tweak.
|
||||
|
||||
1 adjustable: arrow keys, 2: arrow keys in x/y, 3-4: stacked keyboard
|
||||
rows (see stacked_adjustable_tweak). stacked=True forces the stacked
|
||||
rows for 1 or 2 adjustables too. Outside a terminal (notebook, eco
|
||||
desktop console) this opens the tweak panel instead."""
|
||||
n = len(self.adjs)
|
||||
if stacked is None:
|
||||
stacked = n > 2
|
||||
if self._open_panel_instead(stacked=stacked):
|
||||
return
|
||||
if not stacked and n > 2:
|
||||
raise AdjustableError(f"cannot tweak {n} adjustables without stacked keys")
|
||||
from eco.widgets.tweak_panel import axes_from_tweak
|
||||
from eco.widgets.tweak_recorder import terminal_recording
|
||||
|
||||
with terminal_recording(
|
||||
axes_from_tweak(self) if self.detectors else [],
|
||||
self.detectors,
|
||||
label=getattr(self, "_recorder_label", "<tweak>.recorder"),
|
||||
) as recorder:
|
||||
if recorder is not None:
|
||||
self.recorder = recorder
|
||||
if stacked:
|
||||
return self.stacked_adjustable_tweak()
|
||||
if n == 1:
|
||||
return self.single_adjustable_tweak()
|
||||
return self.xy_adjustable_tweak()
|
||||
|
||||
def widget(self, stacked=None, backend=None, display=False, **kwargs):
|
||||
"""Tweak panel for these adjustables (Qt or ipywidgets), with the same
|
||||
keys as the terminal tweak under "keypress control" -- see
|
||||
eco.widgets.tweak_panel."""
|
||||
from eco.widgets.tweak_panel import axes_from_tweak, tweak_panel
|
||||
|
||||
panel = tweak_panel(
|
||||
axes_from_tweak(self),
|
||||
stacked=stacked,
|
||||
backend=backend,
|
||||
display=display,
|
||||
detectors=self.detectors,
|
||||
**kwargs,
|
||||
)
|
||||
if getattr(panel, "recorder", None) is not None:
|
||||
self.recorder = panel.recorder
|
||||
return panel
|
||||
|
||||
def _open_panel_instead(self, stacked=None):
|
||||
"""Where there is no terminal to read keys from (notebook, eco desktop
|
||||
console), show the tweak panel instead. True if it did."""
|
||||
from eco.widgets.tweak_panel import frontend
|
||||
|
||||
if frontend() == "terminal":
|
||||
return False
|
||||
self._panel = self.widget(stacked=stacked, display=True)
|
||||
return True
|
||||
|
||||
def _stacked_key_rows(self):
|
||||
n = len(self.adjs)
|
||||
if not 1 <= n <= len(self.STACKED_KEY_ROWS):
|
||||
raise AdjustableError(
|
||||
f"stacked tweak supports 1 to {len(self.STACKED_KEY_ROWS)} adjustables, got {n}"
|
||||
)
|
||||
rows = self.STACKED_KEY_ROWS
|
||||
return list(rows[:4] if n == 4 else rows[1 : 1 + n])
|
||||
|
||||
def stacked_adjustable_tweak(self):
|
||||
"""Tweak up to 4 adjustables, one keyboard row per adjustable.
|
||||
|
||||
Per row: 1st key = neg dir, 2nd = step*2, 3rd = step/2, 4th = pos dir.
|
||||
Rows used: 1 -> j k l ; 2 -> + u i o p 3 -> + 7 8 9 0
|
||||
4 -> m , . / j k l ; u i o p 7 8 9 0 (first adjustable lowest)."""
|
||||
rows = self._stacked_key_rows()
|
||||
keymap = {}
|
||||
for i_adj, (neg, dbl, half, pos) in enumerate(rows):
|
||||
keymap[neg] = (i_adj, "move", -1)
|
||||
keymap[dbl] = (i_adj, "step", 2.0)
|
||||
keymap[half] = (i_adj, "step", 0.5)
|
||||
keymap[pos] = (i_adj, "move", +1)
|
||||
|
||||
names = [str(getattr(adj, "name", adj)) for adj in self.adjs]
|
||||
width = max(len(name) for name in names)
|
||||
print("tweaking " + ", ".join(names))
|
||||
print("q = exit; s = all axes back to start values")
|
||||
for i_adj in reversed(range(len(rows))):
|
||||
neg, dbl, half, pos = rows[i_adj]
|
||||
print(
|
||||
f" {names[i_adj]:<{width}} : {neg} = neg dir, {dbl} = step*2, "
|
||||
f"{half} = step/2, {pos} = pos dir"
|
||||
)
|
||||
print(
|
||||
"Starting at "
|
||||
+ ", ".join(
|
||||
f"{name}={value}" for name, value in zip(names, self.target_positions[0])
|
||||
)
|
||||
)
|
||||
k = KeyPress()
|
||||
cll = colorama.ansi.clear_line()
|
||||
|
||||
def _fmt(value):
|
||||
try:
|
||||
return f"{value:1.5g}"
|
||||
except Exception:
|
||||
return str(value)
|
||||
|
||||
class Printer:
|
||||
def __init__(self, tweak=self):
|
||||
self.tweak = tweak
|
||||
self.thread = None
|
||||
|
||||
def print(self):
|
||||
if self.thread and self.thread.is_alive():
|
||||
return
|
||||
else:
|
||||
self.thread = Thread(target=self.print_foo)
|
||||
self.thread.daemon = True
|
||||
self.thread.start()
|
||||
|
||||
def _line(self, values):
|
||||
return "; ".join(
|
||||
f"{name}: {_fmt(value)} (step {_fmt(step)})"
|
||||
for name, value, step in zip(names, values, self.tweak.step_sizes)
|
||||
)
|
||||
|
||||
def print_foo(self, **kwargs):
|
||||
if self.tweak._changers:
|
||||
print(cll + "changing ...", end="\r")
|
||||
self.tweak.wait()
|
||||
print(cll + self._line(self.tweak.get_current_values()), end="\r")
|
||||
|
||||
p = Printer()
|
||||
print(" ")
|
||||
p.print()
|
||||
while k.isq() is False:
|
||||
action = keymap.get(k.last_key)
|
||||
if action is not None:
|
||||
i_adj, kind, factor = action
|
||||
if kind == "step":
|
||||
self.set_step_size((i_adj, self.step_sizes[i_adj] * factor))
|
||||
else:
|
||||
self.set_target_step_increment((i_adj, factor))
|
||||
p.print()
|
||||
elif k.iskey("s"):
|
||||
self.change_to_targets(self.startpositions)
|
||||
p.print()
|
||||
|
||||
k.waitkey()
|
||||
print("")
|
||||
|
||||
def wait(self, sleeptime=0.02):
|
||||
if self._changers:
|
||||
changing = True
|
||||
while changing:
|
||||
for changer in self._changers:
|
||||
changing = changer.is_alive()
|
||||
while any(changer.is_alive() for changer in self._changers):
|
||||
time.sleep(sleeptime)
|
||||
|
||||
def set_step_size(self, *args):
|
||||
@@ -1356,413 +1525,9 @@ class Tweak:
|
||||
new_steps[index] = stepsize
|
||||
self.step_sizes = new_steps
|
||||
|
||||
def _single_adjustable_tweak_notebook(self):
|
||||
try:
|
||||
from IPython.display import display, Javascript
|
||||
import ipywidgets as widgets
|
||||
except Exception as exc:
|
||||
raise RuntimeError(
|
||||
"Notebook tweak mode requires IPython and ipywidgets."
|
||||
) from exc
|
||||
|
||||
i_adj = 0
|
||||
adj = self.adjs[i_adj]
|
||||
|
||||
def _format_value(value):
|
||||
try:
|
||||
return f"{value:1.6g}"
|
||||
except Exception:
|
||||
return str(value)
|
||||
|
||||
current_value = adj.get_current_value()
|
||||
step_value = self.step_sizes[i_adj]
|
||||
current_label = widgets.HTML(
|
||||
value=f"<b>Current position:</b> {_format_value(current_value)}"
|
||||
)
|
||||
status_label = widgets.HTML(
|
||||
value=f"<b>Step size:</b> {_format_value(step_value)}"
|
||||
)
|
||||
help_label = widgets.HTML(
|
||||
value=(
|
||||
"<b>Controls:</b> Stepsize *2, Stepsize /2, Down, Up, Go abs, Reset offset, Exit"
|
||||
)
|
||||
)
|
||||
step_input = widgets.FloatText(
|
||||
value=step_value,
|
||||
description="Step:",
|
||||
layout=widgets.Layout(width="220px"),
|
||||
)
|
||||
go_input = widgets.FloatText(
|
||||
value=current_value,
|
||||
description="Go:",
|
||||
layout=widgets.Layout(width="220px"),
|
||||
)
|
||||
set_input = widgets.FloatText(
|
||||
value=current_value,
|
||||
description="Set:",
|
||||
layout=widgets.Layout(width="220px"),
|
||||
)
|
||||
|
||||
def _refresh_current(value=None):
|
||||
if value is None:
|
||||
value = adj.get_current_value()
|
||||
current_label.value = f"<b>Current position:</b> {_format_value(value)}"
|
||||
status_label.value = f"<b>Step size:</b> {_format_value(step_input.value)}"
|
||||
go_input.value = value
|
||||
set_input.value = value
|
||||
|
||||
def _update_callback(**kwargs):
|
||||
if "value" in kwargs:
|
||||
_refresh_current(kwargs["value"])
|
||||
else:
|
||||
_refresh_current()
|
||||
|
||||
callback_id = None
|
||||
if hasattr(adj, "add_value_callback"):
|
||||
try:
|
||||
callback_id = adj.add_value_callback(_update_callback)
|
||||
except Exception:
|
||||
callback_id = None
|
||||
|
||||
def _set_step(factor):
|
||||
new_step = float(step_input.value) * factor
|
||||
step_input.value = new_step
|
||||
self.set_step_size((adj, new_step))
|
||||
status_label.value = f"<b>Step size:</b> {_format_value(new_step)}"
|
||||
|
||||
def _click_up(_):
|
||||
_set_step(2.0)
|
||||
|
||||
def _click_down(_):
|
||||
_set_step(0.5)
|
||||
|
||||
def _click_left(_):
|
||||
self.set_target_step_increment((adj, -1))
|
||||
_refresh_current()
|
||||
|
||||
def _click_right(_):
|
||||
self.set_target_step_increment((adj, +1))
|
||||
_refresh_current()
|
||||
|
||||
def _click_go(_):
|
||||
try:
|
||||
value = float(go_input.value)
|
||||
changer = adj.set_target_value(value)
|
||||
if hasattr(changer, "wait"):
|
||||
changer.wait()
|
||||
_refresh_current(value)
|
||||
except Exception as exc:
|
||||
status_label.value = f"<b>Error:</b> {exc}"
|
||||
|
||||
def _click_set(_):
|
||||
try:
|
||||
value = float(set_input.value)
|
||||
if hasattr(adj, "reset_current_value_to"):
|
||||
adj.reset_current_value_to(value)
|
||||
else:
|
||||
changer = adj.set_target_value(value)
|
||||
if hasattr(changer, "wait"):
|
||||
changer.wait()
|
||||
_refresh_current(value)
|
||||
except Exception as exc:
|
||||
status_label.value = f"<b>Error:</b> {exc}"
|
||||
|
||||
def _shutdown(_=None):
|
||||
if callback_id is not None and hasattr(adj, "clear_value_callback"):
|
||||
try:
|
||||
adj.clear_value_callback(index=callback_id)
|
||||
except TypeError:
|
||||
adj.clear_value_callback()
|
||||
except Exception:
|
||||
pass
|
||||
for ctl in [
|
||||
step_input,
|
||||
go_input,
|
||||
set_input,
|
||||
btn_up,
|
||||
btn_down,
|
||||
btn_left,
|
||||
btn_right,
|
||||
btn_go,
|
||||
btn_set,
|
||||
btn_exit,
|
||||
]:
|
||||
ctl.disabled = True
|
||||
status_label.value = "<b>Tweak UI closed.</b>"
|
||||
|
||||
btn_up = widgets.Button(description="Stepsize *2", button_style="success")
|
||||
btn_down = widgets.Button(description="Stepsize /2", button_style="warning")
|
||||
btn_left = widgets.Button(description="Down", button_style="info")
|
||||
btn_right = widgets.Button(description="Up", button_style="info")
|
||||
btn_go = widgets.Button(description="Go abs", button_style="primary")
|
||||
btn_set = widgets.Button(description="Reset offset", button_style="primary")
|
||||
btn_exit = widgets.Button(description="Exit", button_style="danger")
|
||||
|
||||
btn_up.on_click(_click_up)
|
||||
btn_down.on_click(_click_down)
|
||||
btn_left.on_click(_click_left)
|
||||
btn_right.on_click(_click_right)
|
||||
btn_go.on_click(_click_go)
|
||||
btn_set.on_click(_click_set)
|
||||
btn_exit.on_click(_shutdown)
|
||||
|
||||
controls = widgets.HBox(
|
||||
[btn_up, btn_down, btn_left, btn_right, btn_go, btn_set, btn_exit]
|
||||
)
|
||||
inputs = widgets.HBox([step_input, go_input, set_input])
|
||||
ui = widgets.VBox([current_label, status_label, help_label, inputs, controls])
|
||||
|
||||
js = f"""
|
||||
(function() {{
|
||||
const mapping = {{
|
||||
u: '{btn_up._model_id}',
|
||||
d: '{btn_down._model_id}',
|
||||
l: '{btn_left._model_id}',
|
||||
r: '{btn_right._model_id}',
|
||||
g: '{btn_go._model_id}',
|
||||
s: '{btn_set._model_id}',
|
||||
q: '{btn_exit._model_id}'
|
||||
}};
|
||||
window.__eco_tweak_keys = window.__eco_tweak_keys || {{}};
|
||||
window.__eco_tweak_keys = Object.assign(window.__eco_tweak_keys, mapping);
|
||||
if (!window.__eco_tweak_key_handler) {{
|
||||
window.__eco_tweak_key_handler = function(event) {{
|
||||
const key = event.key.toLowerCase();
|
||||
const targetId = window.__eco_tweak_keys[key];
|
||||
if (!targetId) return;
|
||||
const root = document.querySelector('[data-widget-id="' + targetId + '"]');
|
||||
if (!root) return;
|
||||
const btn = root.querySelector('button');
|
||||
if (btn && !btn.disabled) {{
|
||||
btn.click();
|
||||
event.preventDefault();
|
||||
}}
|
||||
}};
|
||||
document.addEventListener('keydown', window.__eco_tweak_key_handler);
|
||||
}}
|
||||
}})();
|
||||
"""
|
||||
display(ui)
|
||||
display(Javascript(js))
|
||||
return ui
|
||||
|
||||
def _xy_adjustable_tweak_notebook(self):
|
||||
try:
|
||||
from IPython.display import display, Javascript
|
||||
import ipywidgets as widgets
|
||||
except Exception as exc:
|
||||
raise RuntimeError(
|
||||
"Notebook tweak mode requires IPython and ipywidgets."
|
||||
) from exc
|
||||
|
||||
x_adj, y_adj = self.adjs
|
||||
i_x, i_y = 0, 1
|
||||
|
||||
def _format_value(value):
|
||||
try:
|
||||
return f"{value:1.6g}"
|
||||
except Exception:
|
||||
return str(value)
|
||||
|
||||
def _refresh_current(value=None):
|
||||
if value is None:
|
||||
current = self.get_current_values()
|
||||
else:
|
||||
current = value
|
||||
x_current = current[i_x]
|
||||
y_current = current[i_y]
|
||||
x_label.value = f"<b>x ({x_adj.name}):</b> {_format_value(x_current)}"
|
||||
y_label.value = f"<b>y ({y_adj.name}):</b> {_format_value(y_current)}"
|
||||
status_label.value = (
|
||||
f"<b>Step sizes:</b> x={_format_value(x_step_input.value)}, y={_format_value(y_step_input.value)}"
|
||||
)
|
||||
|
||||
def _update_callback(**kwargs):
|
||||
_refresh_current()
|
||||
|
||||
callback_id_x = None
|
||||
callback_id_y = None
|
||||
if hasattr(x_adj, "add_value_callback"):
|
||||
try:
|
||||
callback_id_x = x_adj.add_value_callback(_update_callback)
|
||||
except Exception:
|
||||
callback_id_x = None
|
||||
if hasattr(y_adj, "add_value_callback"):
|
||||
try:
|
||||
callback_id_y = y_adj.add_value_callback(_update_callback)
|
||||
except Exception:
|
||||
callback_id_y = None
|
||||
|
||||
x_step_input = widgets.FloatText(
|
||||
value=self.step_sizes[i_x],
|
||||
description="X step:",
|
||||
layout=widgets.Layout(width="220px"),
|
||||
)
|
||||
y_step_input = widgets.FloatText(
|
||||
value=self.step_sizes[i_y],
|
||||
description="Y step:",
|
||||
layout=widgets.Layout(width="220px"),
|
||||
)
|
||||
|
||||
x_label = widgets.HTML(
|
||||
value=f"<b>x ({x_adj.name}):</b> {_format_value(x_adj.get_current_value())}"
|
||||
)
|
||||
y_label = widgets.HTML(
|
||||
value=f"<b>y ({y_adj.name}):</b> {_format_value(y_adj.get_current_value())}"
|
||||
)
|
||||
status_label = widgets.HTML(
|
||||
value=(
|
||||
f"<b>Step sizes:</b> x={_format_value(x_step_input.value)}, y={_format_value(y_step_input.value)}"
|
||||
)
|
||||
)
|
||||
help_label = widgets.HTML(
|
||||
value=(
|
||||
"<b>Controls:</b> x-/x+, y-/y+, X step *2, X step /2, Y step *2, Y step /2, Reset origin, Exit"
|
||||
)
|
||||
)
|
||||
|
||||
def _set_step(adj, index, factor, ctl):
|
||||
new_step = float(ctl.value) * factor
|
||||
ctl.value = new_step
|
||||
self.set_step_size((adj, new_step))
|
||||
status_label.value = (
|
||||
f"<b>Step sizes:</b> x={_format_value(x_step_input.value)}, y={_format_value(y_step_input.value)}"
|
||||
)
|
||||
|
||||
def _click_x_minus(_):
|
||||
self.set_target_step_increment((x_adj, -1))
|
||||
_refresh_current()
|
||||
|
||||
def _click_x_plus(_):
|
||||
self.set_target_step_increment((x_adj, +1))
|
||||
_refresh_current()
|
||||
|
||||
def _click_y_minus(_):
|
||||
self.set_target_step_increment((y_adj, -1))
|
||||
_refresh_current()
|
||||
|
||||
def _click_y_plus(_):
|
||||
self.set_target_step_increment((y_adj, +1))
|
||||
_refresh_current()
|
||||
|
||||
def _click_x_step_up(_):
|
||||
_set_step(x_adj, i_x, 2.0, x_step_input)
|
||||
|
||||
def _click_x_step_down(_):
|
||||
_set_step(x_adj, i_x, 0.5, x_step_input)
|
||||
|
||||
def _click_y_step_up(_):
|
||||
_set_step(y_adj, i_y, 2.0, y_step_input)
|
||||
|
||||
def _click_y_step_down(_):
|
||||
_set_step(y_adj, i_y, 0.5, y_step_input)
|
||||
|
||||
def _click_origin(_):
|
||||
self.change_to_targets(self.startpositions)
|
||||
_refresh_current()
|
||||
|
||||
def _shutdown(_=None):
|
||||
if callback_id_x is not None and hasattr(x_adj, "clear_value_callback"):
|
||||
try:
|
||||
x_adj.clear_value_callback(index=callback_id_x)
|
||||
except TypeError:
|
||||
x_adj.clear_value_callback()
|
||||
except Exception:
|
||||
pass
|
||||
if callback_id_y is not None and hasattr(y_adj, "clear_value_callback"):
|
||||
try:
|
||||
y_adj.clear_value_callback(index=callback_id_y)
|
||||
except TypeError:
|
||||
y_adj.clear_value_callback()
|
||||
except Exception:
|
||||
pass
|
||||
for ctl in [
|
||||
x_step_input,
|
||||
y_step_input,
|
||||
btn_x_minus,
|
||||
btn_x_plus,
|
||||
btn_y_minus,
|
||||
btn_y_plus,
|
||||
btn_x_step_up,
|
||||
btn_x_step_down,
|
||||
btn_y_step_up,
|
||||
btn_y_step_down,
|
||||
btn_origin,
|
||||
btn_exit,
|
||||
]:
|
||||
ctl.disabled = True
|
||||
status_label.value = "<b>Tweak UI closed.</b>"
|
||||
|
||||
btn_x_minus = widgets.Button(description="X -", button_style="info")
|
||||
btn_x_plus = widgets.Button(description="X +", button_style="info")
|
||||
btn_y_minus = widgets.Button(description="Y -", button_style="info")
|
||||
btn_y_plus = widgets.Button(description="Y +", button_style="info")
|
||||
btn_x_step_up = widgets.Button(description="X step *2", button_style="success")
|
||||
btn_x_step_down = widgets.Button(description="X step /2", button_style="warning")
|
||||
btn_y_step_up = widgets.Button(description="Y step *2", button_style="success")
|
||||
btn_y_step_down = widgets.Button(description="Y step /2", button_style="warning")
|
||||
btn_origin = widgets.Button(description="Reset origin", button_style="primary")
|
||||
btn_exit = widgets.Button(description="Exit", button_style="danger")
|
||||
|
||||
btn_x_minus.on_click(_click_x_minus)
|
||||
btn_x_plus.on_click(_click_x_plus)
|
||||
btn_y_minus.on_click(_click_y_minus)
|
||||
btn_y_plus.on_click(_click_y_plus)
|
||||
btn_x_step_up.on_click(_click_x_step_up)
|
||||
btn_x_step_down.on_click(_click_x_step_down)
|
||||
btn_y_step_up.on_click(_click_y_step_up)
|
||||
btn_y_step_down.on_click(_click_y_step_down)
|
||||
btn_origin.on_click(_click_origin)
|
||||
btn_exit.on_click(_shutdown)
|
||||
|
||||
row1 = widgets.HBox([btn_x_minus, btn_x_plus, btn_y_minus, btn_y_plus])
|
||||
row2 = widgets.HBox([btn_x_step_up, btn_x_step_down, btn_y_step_up, btn_y_step_down])
|
||||
row3 = widgets.HBox([btn_origin, btn_exit])
|
||||
inputs = widgets.HBox([x_step_input, y_step_input])
|
||||
ui = widgets.VBox([x_label, y_label, status_label, help_label, inputs, row1, row2, row3])
|
||||
|
||||
js = f"""
|
||||
(function() {{
|
||||
const mapping = {{
|
||||
l: '{btn_x_minus._model_id}',
|
||||
r: '{btn_x_plus._model_id}',
|
||||
u: '{btn_y_plus._model_id}',
|
||||
d: '{btn_y_minus._model_id}',
|
||||
s: '{btn_origin._model_id}',
|
||||
q: '{btn_exit._model_id}'
|
||||
}};
|
||||
window.__eco_tweak_keys = window.__eco_tweak_keys || {{}};
|
||||
window.__eco_tweak_keys = Object.assign(window.__eco_tweak_keys, mapping);
|
||||
if (!window.__eco_tweak_key_handler) {{
|
||||
window.__eco_tweak_key_handler = function(event) {{
|
||||
const key = event.key.toLowerCase();
|
||||
const targetId = window.__eco_tweak_keys[key];
|
||||
if (!targetId) return;
|
||||
const root = document.querySelector('[data-widget-id="' + targetId + '"]');
|
||||
if (!root) return;
|
||||
const btn = root.querySelector('button');
|
||||
if (btn && !btn.disabled) {{
|
||||
btn.click();
|
||||
event.preventDefault();
|
||||
}}
|
||||
}};
|
||||
document.addEventListener('keydown', window.__eco_tweak_key_handler);
|
||||
}}
|
||||
}})();
|
||||
"""
|
||||
display(ui)
|
||||
display(Javascript(js))
|
||||
return ui
|
||||
|
||||
def single_adjustable_tweak(self):
|
||||
if _is_notebook():
|
||||
try:
|
||||
return self._single_adjustable_tweak_notebook()
|
||||
except Exception as exc:
|
||||
print(
|
||||
"Notebook tweak UI failed; falling back to terminal mode:",
|
||||
exc,
|
||||
)
|
||||
if self._open_panel_instead():
|
||||
return
|
||||
i_adj = 0
|
||||
adj = self.adjs[i_adj]
|
||||
# step_value = float(self.step_sizes[0])
|
||||
|
||||
@@ -141,6 +141,9 @@ def _subprocess_env():
|
||||
import sys
|
||||
|
||||
env = dict(os.environ)
|
||||
# a qtconsole can't render ipywidgets: lets interactive code (e.g.
|
||||
# eco.widgets.tweak_panel.frontend) pick a Qt window instead
|
||||
env["ECO_QTCONSOLE_KERNEL"] = "1"
|
||||
existing = env.get("PYTHONPATH", "")
|
||||
env["PYTHONPATH"] = os.pathsep.join(sys.path) + (os.pathsep + existing if existing else "")
|
||||
return env
|
||||
|
||||
@@ -0,0 +1,445 @@
|
||||
"""
|
||||
Backend-independent core of the tweak panels (Qt: eco.widgets.tweak_panel_qt,
|
||||
ipywidgets: eco.widgets.tweak_panel_ipy).
|
||||
|
||||
The panels offer everything the terminal tweak does -- step *2 / step /2,
|
||||
neg/pos step, back to start, go to an absolute value, reset the current value
|
||||
-- for 1 to 4 axes, plus a "keypress control" mode that uses the same keys as
|
||||
the terminal:
|
||||
|
||||
- 1 axis: left = neg dir, up = step*2, down = step/2, right = pos dir
|
||||
- 2 axes: arrows move x (left/right) and y (up/down), ctrl+arrows change
|
||||
the step of x (ctrl+right *2, ctrl+left /2) and y (ctrl+up *2,
|
||||
ctrl+down /2) -- as in Tweak.xy_adjustable_tweak
|
||||
- stacked (automatic for 3-4 axes, optional for 1-2): one keyboard row per
|
||||
axis, (neg dir, step*2, step/2, pos dir), rows as in
|
||||
Tweak.STACKED_KEY_ROWS
|
||||
- always: s = all axes back to start, Esc = stop, q = leave keypress control
|
||||
- 1 axis, arrow mode only: g / r jump into the "go to" / "reset to" field
|
||||
|
||||
Nothing here imports Qt or ipywidgets, so the key logic is testable on its own.
|
||||
|
||||
Two kinds of axis, same interface:
|
||||
- SoftTweakAxis drives one adjustable of an eco.elements.adjustable.Tweak via
|
||||
set_target_value(), stepping from the last *target* like the terminal tweak
|
||||
does (fast repeated keypresses accumulate instead of being lost).
|
||||
- IocTweakAxis drives a motor record's own tweak fields (TWV/TWF/TWR), as the
|
||||
MotorRecord/Smaract _tweak_ioc terminal tweaks do.
|
||||
"""
|
||||
|
||||
import threading
|
||||
|
||||
|
||||
def _fmt(value):
|
||||
try:
|
||||
return f"{value:1.6g}"
|
||||
except Exception:
|
||||
return str(value)
|
||||
|
||||
|
||||
def _name_of(obj):
|
||||
try:
|
||||
if hasattr(obj, "alias") and hasattr(obj.alias, "get_full_name"):
|
||||
return obj.alias.get_full_name()
|
||||
except Exception:
|
||||
pass
|
||||
return str(getattr(obj, "name", obj))
|
||||
|
||||
|
||||
class _AxisBase:
|
||||
"""Common parts; subclasses implement _do_move/_do_set_step/_do_go."""
|
||||
|
||||
def __init__(self, obj, step):
|
||||
self.obj = obj
|
||||
self.name = _name_of(obj)
|
||||
self.step = float(step)
|
||||
self.start_value = obj.get_current_value()
|
||||
self._changer = None
|
||||
|
||||
def get_value(self):
|
||||
return self.obj.get_current_value()
|
||||
|
||||
def current_step(self):
|
||||
"""The step in effect right now, also if changed elsewhere (terminal
|
||||
keys, the IOC); used by the recorder's settle tolerance."""
|
||||
return self.step
|
||||
|
||||
def scale_step(self, factor):
|
||||
self.set_step(self.step * factor)
|
||||
|
||||
def set_step(self, step):
|
||||
self.step = float(step)
|
||||
self._do_set_step(self.step)
|
||||
|
||||
def _do_set_step(self, step):
|
||||
pass
|
||||
|
||||
def back_to_start(self):
|
||||
self.go(self.start_value)
|
||||
|
||||
def reset_current_value_to(self, value):
|
||||
obj = self.obj
|
||||
if hasattr(obj, "reset_current_value_to"):
|
||||
obj.reset_current_value_to(value)
|
||||
else:
|
||||
changer = obj.set_target_value(value)
|
||||
if hasattr(changer, "wait"):
|
||||
changer.wait()
|
||||
|
||||
def stop(self):
|
||||
for target in (self._changer, self.obj):
|
||||
if target is not None and callable(getattr(target, "stop", None)):
|
||||
try:
|
||||
target.stop()
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def add_value_callback(self, callback):
|
||||
"""Register callback(value) on value changes; returns an undo function,
|
||||
or None if the object has no callback support (panels then poll)."""
|
||||
obj = self.obj
|
||||
if not callable(getattr(obj, "add_value_callback", None)):
|
||||
return None
|
||||
try:
|
||||
index = obj.add_value_callback(
|
||||
lambda **kw: callback(kw["value"]) if "value" in kw else callback(None)
|
||||
)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def remove():
|
||||
try:
|
||||
obj.clear_value_callback(index=index)
|
||||
except TypeError:
|
||||
obj.clear_value_callback()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return remove
|
||||
|
||||
|
||||
class SoftTweakAxis(_AxisBase):
|
||||
"""Axis `index` of an eco.elements.adjustable.Tweak."""
|
||||
|
||||
def __init__(self, tweak, index):
|
||||
self.tweak = tweak
|
||||
self.index = index
|
||||
super().__init__(tweak.adjs[index], tweak.step_sizes[index])
|
||||
self.start_value = tweak.startpositions[index]
|
||||
|
||||
def _do_set_step(self, step):
|
||||
self.tweak.set_step_size((self.index, step))
|
||||
|
||||
def current_step(self):
|
||||
return self.tweak.step_sizes[self.index]
|
||||
|
||||
def _target(self):
|
||||
return self.tweak.target_positions[-1][self.index]
|
||||
|
||||
def _record_target(self, value):
|
||||
targets = list(self.tweak.target_positions[-1])
|
||||
targets[self.index] = value
|
||||
self.tweak.target_positions.append(targets)
|
||||
|
||||
def move(self, sign):
|
||||
self.go(self._target() + sign * self.step)
|
||||
|
||||
def go(self, value):
|
||||
# only the touched axis is commanded (the terminal tweak re-sends all
|
||||
# targets; for a panel that would yank back an axis moved elsewhere)
|
||||
self._record_target(value)
|
||||
self._changer = self.obj.set_target_value(value)
|
||||
|
||||
def reset_current_value_to(self, value):
|
||||
super().reset_current_value_to(value)
|
||||
self._record_target(value)
|
||||
|
||||
|
||||
class IocTweakAxis(_AxisBase):
|
||||
"""A motor record (MotorRecord, SmaractRecord, SmaractStreamdevice, ...)
|
||||
tweaked through its own TWV/TWF/TWR fields."""
|
||||
|
||||
def __init__(self, motor, step=None, go_to_current_value_first=False):
|
||||
if hasattr(motor, "_motor") and hasattr(motor._motor, "get_pv"):
|
||||
self._pv_step = motor._motor.get_pv("TWV")
|
||||
self._pv_fwd = motor._motor.get_pv("TWF")
|
||||
self._pv_rev = motor._motor.get_pv("TWR")
|
||||
else:
|
||||
from epics import PV
|
||||
|
||||
self._pv_step = PV(motor.pvname + ":TWV")
|
||||
self._pv_fwd = PV(motor.pvname + ":TWF.PROC")
|
||||
self._pv_rev = PV(motor.pvname + ":TWR.PROC")
|
||||
if not step:
|
||||
step = self._pv_step.get()
|
||||
try:
|
||||
step = float(step)
|
||||
except Exception:
|
||||
step = 1.0
|
||||
super().__init__(motor, step)
|
||||
self._pv_step.put(self.step)
|
||||
if go_to_current_value_first:
|
||||
# as _tweak_ioc does: sync VAL to RBV before the first TWF/TWR,
|
||||
# off the GUI thread since it waits for the motor
|
||||
def _sync():
|
||||
try:
|
||||
motor.set_target_value(motor.get_current_value()).wait()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
threading.Thread(target=_sync, daemon=True).start()
|
||||
|
||||
def _do_set_step(self, step):
|
||||
self._pv_step.put(step)
|
||||
|
||||
def current_step(self):
|
||||
try:
|
||||
value = self._pv_step.get()
|
||||
if value is not None:
|
||||
return float(value)
|
||||
except Exception:
|
||||
pass
|
||||
return self.step
|
||||
|
||||
def move(self, sign):
|
||||
(self._pv_fwd if sign > 0 else self._pv_rev).put(1)
|
||||
|
||||
def go(self, value):
|
||||
self._changer = self.obj.set_target_value(value)
|
||||
|
||||
|
||||
# >>> key bindings >>>
|
||||
|
||||
# Normalized key names used by both backends: "Left"/"Right"/"Up"/"Down",
|
||||
# "Ctrl+Left" etc., "Escape", and plain lower-case characters.
|
||||
ARROWS = ("Left", "Up", "Down", "Right")
|
||||
|
||||
|
||||
def _stacked_rows(n):
|
||||
from eco.elements.adjustable import Tweak
|
||||
|
||||
rows = Tweak.STACKED_KEY_ROWS
|
||||
if not 1 <= n <= len(rows):
|
||||
raise ValueError(f"stacked tweak supports 1 to {len(rows)} axes, got {n}")
|
||||
return list(rows[:4] if n == 4 else rows[1 : 1 + n])
|
||||
|
||||
|
||||
def use_stacked(n, stacked=None):
|
||||
if n > 4:
|
||||
raise ValueError(f"tweak panels support 1 to 4 axes, got {n}")
|
||||
return n > 2 if stacked is None else bool(stacked)
|
||||
|
||||
|
||||
def key_bindings(n, stacked=None):
|
||||
"""{normalized key: action} for n axes. Actions:
|
||||
("move", i, +1/-1), ("step", i, factor), ("start",), ("stop",),
|
||||
("release",), ("focus_go", i), ("focus_reset", i)."""
|
||||
b = {"s": ("start",), "q": ("release",), "Escape": ("stop",)}
|
||||
if use_stacked(n, stacked):
|
||||
for i, (neg, dbl, half, pos) in enumerate(_stacked_rows(n)):
|
||||
b[neg] = ("move", i, -1)
|
||||
b[dbl] = ("step", i, 2.0)
|
||||
b[half] = ("step", i, 0.5)
|
||||
b[pos] = ("move", i, +1)
|
||||
elif n == 1:
|
||||
b.update(
|
||||
{
|
||||
"Left": ("move", 0, -1),
|
||||
"Up": ("step", 0, 2.0),
|
||||
"Down": ("step", 0, 0.5),
|
||||
"Right": ("move", 0, +1),
|
||||
"g": ("focus_go", 0),
|
||||
"r": ("focus_reset", 0),
|
||||
}
|
||||
)
|
||||
elif n == 2:
|
||||
b.update(
|
||||
{
|
||||
"Left": ("move", 0, -1),
|
||||
"Right": ("move", 0, +1),
|
||||
"Down": ("move", 1, -1),
|
||||
"Up": ("move", 1, +1),
|
||||
"Ctrl+Right": ("step", 0, 2.0),
|
||||
"Ctrl+Left": ("step", 0, 0.5),
|
||||
"Ctrl+Up": ("step", 1, 2.0),
|
||||
"Ctrl+Down": ("step", 1, 0.5),
|
||||
}
|
||||
)
|
||||
else:
|
||||
raise ValueError(f"{n} axes need stacked keys")
|
||||
return b
|
||||
|
||||
|
||||
def key_manual(names, stacked=None):
|
||||
"""The key manual as a list of (keys, meaning) rows."""
|
||||
n = len(names)
|
||||
rows = []
|
||||
if use_stacked(n, stacked):
|
||||
key_rows = _stacked_rows(n)
|
||||
for i in reversed(range(n)): # top keyboard row first
|
||||
neg, dbl, half, pos = key_rows[i]
|
||||
rows.append(
|
||||
(
|
||||
f"{neg} {dbl} {half} {pos}",
|
||||
f"{names[i]}: {neg} neg dir, {dbl} step*2, {half} step/2, {pos} pos dir",
|
||||
)
|
||||
)
|
||||
elif n == 1:
|
||||
rows += [
|
||||
("← →", f"{names[0]}: neg dir / pos dir"),
|
||||
("↑ ↓", "step*2 / step/2"),
|
||||
("g / r", "type a go-to / reset-to value (Enter applies)"),
|
||||
]
|
||||
else:
|
||||
rows += [
|
||||
("← →", f"x = {names[0]}: neg dir / pos dir"),
|
||||
("↓ ↑", f"y = {names[1]}: neg dir / pos dir"),
|
||||
("ctrl+→ ctrl+←", "x step*2 / step/2"),
|
||||
("ctrl+↑ ctrl+↓", "y step*2 / step/2"),
|
||||
]
|
||||
rows += [
|
||||
("s", "all axes back to start values"),
|
||||
("Esc", "stop"),
|
||||
("q", "leave keypress control"),
|
||||
]
|
||||
return rows
|
||||
|
||||
|
||||
class TweakController:
|
||||
"""The axes of one panel, and what a key/button does to them.
|
||||
|
||||
`notify(text)` is how errors and the like are reported back to the
|
||||
panel; panels replace it."""
|
||||
|
||||
def __init__(self, axes, stacked=None):
|
||||
self.axes = list(axes)
|
||||
self.stacked = use_stacked(len(self.axes), stacked)
|
||||
self.bindings = key_bindings(len(self.axes), self.stacked)
|
||||
self.notify = lambda text: None
|
||||
|
||||
@property
|
||||
def names(self):
|
||||
return [axis.name for axis in self.axes]
|
||||
|
||||
def manual(self):
|
||||
return key_manual(self.names, self.stacked)
|
||||
|
||||
def _guard(self, what, func, *args):
|
||||
try:
|
||||
func(*args)
|
||||
return True
|
||||
except Exception as exc:
|
||||
self.notify(f"{what} failed: {exc}")
|
||||
return False
|
||||
|
||||
def move(self, i, sign):
|
||||
return self._guard(f"{self.axes[i].name} move", self.axes[i].move, sign)
|
||||
|
||||
def scale_step(self, i, factor):
|
||||
return self._guard(
|
||||
f"{self.axes[i].name} step", self.axes[i].scale_step, factor
|
||||
)
|
||||
|
||||
def set_step(self, i, step):
|
||||
return self._guard(f"{self.axes[i].name} step", self.axes[i].set_step, step)
|
||||
|
||||
def go(self, i, value):
|
||||
return self._guard(f"{self.axes[i].name} go", self.axes[i].go, value)
|
||||
|
||||
def reset_current_value_to(self, i, value):
|
||||
# may wait for a set; run off the calling (GUI) thread
|
||||
def _run():
|
||||
self._guard(
|
||||
f"{self.axes[i].name} reset",
|
||||
self.axes[i].reset_current_value_to,
|
||||
value,
|
||||
)
|
||||
|
||||
threading.Thread(target=_run, daemon=True).start()
|
||||
|
||||
def back_to_start(self):
|
||||
for axis in self.axes:
|
||||
self._guard(f"{axis.name} back to start", axis.back_to_start)
|
||||
|
||||
def stop(self):
|
||||
for axis in self.axes:
|
||||
axis.stop()
|
||||
|
||||
def handle_key(self, key):
|
||||
"""Apply the action bound to `key`. Returns the action tuple (so the
|
||||
panel can handle the UI-only ones: release, focus_go, focus_reset),
|
||||
or None if the key isn't bound."""
|
||||
action = self.bindings.get(key)
|
||||
if action is None:
|
||||
return None
|
||||
kind = action[0]
|
||||
if kind == "move":
|
||||
self.move(action[1], action[2])
|
||||
elif kind == "step":
|
||||
self.scale_step(action[1], action[2])
|
||||
elif kind == "start":
|
||||
self.back_to_start()
|
||||
elif kind == "stop":
|
||||
self.stop()
|
||||
return action
|
||||
|
||||
|
||||
def axes_from_tweak(tweak):
|
||||
return [SoftTweakAxis(tweak, i) for i in range(len(tweak.adjs))]
|
||||
|
||||
|
||||
# >>> front-end selection >>>
|
||||
|
||||
|
||||
def frontend():
|
||||
"""'qt', 'ipy' or 'terminal': where an interactive tweak should show up.
|
||||
|
||||
- eco desktop console, in-process kernel (InProcessInteractiveShell; the
|
||||
terminal KeyPress loop has no tty there) -> 'qt'
|
||||
- eco desktop console, subprocess kernel (marked by ECO_QTCONSOLE_KERNEL,
|
||||
set in eco.widgets.console_kernel; qtconsole can't render ipywidgets)
|
||||
-> 'qt'
|
||||
- any other Jupyter kernel (notebook, lab, voila) -> 'ipy'
|
||||
- terminal IPython / plain python -> 'terminal'"""
|
||||
import os
|
||||
|
||||
try:
|
||||
from IPython import get_ipython
|
||||
|
||||
shell = get_ipython()
|
||||
except Exception:
|
||||
shell = None
|
||||
cls = shell.__class__.__name__ if shell is not None else ""
|
||||
if cls == "InProcessInteractiveShell":
|
||||
return "qt"
|
||||
if cls == "ZMQInteractiveShell":
|
||||
return "qt" if os.environ.get("ECO_QTCONSOLE_KERNEL") else "ipy"
|
||||
return "terminal"
|
||||
|
||||
|
||||
def tweak_panel(axes, stacked=None, backend=None, display=False, detectors=None, **kwargs):
|
||||
"""A tweak panel for `axes` (SoftTweakAxis/IocTweakAxis objects).
|
||||
|
||||
backend: 'qt', 'ipy' or None (ipywidgets in a Jupyter kernel that isn't
|
||||
an eco desktop console, Qt otherwise). A Qt panel always opens its own
|
||||
window; an ipywidgets panel is returned for the cell to show, or shown
|
||||
right away with display=True.
|
||||
|
||||
detectors: a Detector or a list of them, recorded along the tweak and
|
||||
plotted in the panel (see eco.widgets.tweak_recorder); the recorder is
|
||||
the panel's .recorder."""
|
||||
controller = TweakController(axes, stacked=stacked)
|
||||
if detectors is not None:
|
||||
from eco.widgets.tweak_recorder import TweakRecorder
|
||||
|
||||
kwargs["recorder"] = TweakRecorder(controller.axes, detectors)
|
||||
if backend is None:
|
||||
backend = "ipy" if frontend() == "ipy" else "qt"
|
||||
if backend == "ipy":
|
||||
from eco.widgets.tweak_panel_ipy import tweak_panel_ipy
|
||||
|
||||
return tweak_panel_ipy(controller, display=display, **kwargs)
|
||||
from eco.widgets.tweak_panel_qt import show_tweak_panel_qt
|
||||
|
||||
return show_tweak_panel_qt(controller, **kwargs)
|
||||
@@ -0,0 +1,317 @@
|
||||
"""
|
||||
ipywidgets tweak panel, the notebook/lab/voila counterpart of
|
||||
eco.widgets.tweak_panel_qt: same rows, buttons and key bindings (logic in
|
||||
eco.widgets.tweak_panel).
|
||||
|
||||
Keypress control uses ipyevents on the box that shows the key manual: keys act
|
||||
while the mouse pointer is over that box (ipyevents' rule), so typing anywhere
|
||||
else in the notebook never moves anything. Without ipyevents installed the
|
||||
panel still works by buttons, and says so.
|
||||
"""
|
||||
|
||||
import threading
|
||||
|
||||
import ipywidgets as widgets
|
||||
|
||||
from eco.widgets.tweak_panel import _fmt
|
||||
|
||||
_ARROW_KEYS = {
|
||||
"ArrowLeft": "Left",
|
||||
"ArrowUp": "Up",
|
||||
"ArrowDown": "Down",
|
||||
"ArrowRight": "Right",
|
||||
}
|
||||
|
||||
|
||||
def normalize_key_event(event):
|
||||
"""ipyevents keydown dict -> eco.widgets.tweak_panel normalized key."""
|
||||
key = event.get("key", "")
|
||||
if key in _ARROW_KEYS:
|
||||
name = _ARROW_KEYS[key]
|
||||
return "Ctrl+" + name if event.get("ctrlKey") else name
|
||||
if key == "Escape":
|
||||
return "Escape"
|
||||
if len(key) == 1:
|
||||
return key.lower()
|
||||
return None
|
||||
|
||||
|
||||
def _manual_html(rows):
|
||||
body = "".join(
|
||||
f"<tr><td style='padding-right:12px'><b><code>{keys}</code></b></td><td>{meaning}</td></tr>"
|
||||
for keys, meaning in rows
|
||||
)
|
||||
return (
|
||||
"<table>" + body + "</table>"
|
||||
"<i>keys act while the mouse pointer is over this box</i>"
|
||||
)
|
||||
|
||||
|
||||
def render_png(recorder):
|
||||
"""The recorder's plot as PNG bytes (Agg, no pyplot: safe off the main
|
||||
thread)."""
|
||||
import io
|
||||
|
||||
from matplotlib.backends.backend_agg import FigureCanvasAgg
|
||||
from matplotlib.figure import Figure
|
||||
|
||||
from eco.widgets.tweak_recorder import draw_tweak_plot, figure_size
|
||||
|
||||
fig = Figure(figsize=figure_size(recorder), dpi=90)
|
||||
FigureCanvasAgg(fig)
|
||||
draw_tweak_plot(fig, recorder)
|
||||
buf = io.BytesIO()
|
||||
fig.savefig(buf, format="png")
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
class TweakPanelIpy:
|
||||
def __init__(
|
||||
self, controller, title=None, poll_interval=0.3, keypress=True, recorder=None
|
||||
):
|
||||
self.controller = controller
|
||||
self.recorder = recorder
|
||||
self._stop_event = threading.Event()
|
||||
self.status = widgets.HTML("")
|
||||
controller.notify = self._message
|
||||
|
||||
key_of = {action: key for key, action in controller.bindings.items()}
|
||||
btn_layout = widgets.Layout(width="40px")
|
||||
num_layout = widgets.Layout(width="100px")
|
||||
|
||||
self._value_labels = []
|
||||
self._step_inputs = []
|
||||
self._go_inputs = []
|
||||
self._reset_inputs = []
|
||||
rows = [
|
||||
widgets.HTML(
|
||||
f"<b>{title or 'tweak ' + ', '.join(controller.names)}</b>"
|
||||
)
|
||||
]
|
||||
for i, axis in enumerate(controller.axes):
|
||||
value_label = widgets.HTML("…", layout=widgets.Layout(width="110px"))
|
||||
self._value_labels.append(value_label)
|
||||
|
||||
buttons = []
|
||||
for text, tip, action in (
|
||||
("◀", "neg dir", ("move", i, -1)),
|
||||
("×2", "step*2", ("step", i, 2.0)),
|
||||
("÷2", "step/2", ("step", i, 0.5)),
|
||||
("▶", "pos dir", ("move", i, +1)),
|
||||
):
|
||||
key = key_of.get(action)
|
||||
btn = widgets.Button(
|
||||
description=text,
|
||||
tooltip=f"{tip} (key: {key})" if key else tip,
|
||||
layout=btn_layout,
|
||||
)
|
||||
btn.on_click(lambda _b, a=action: self._do(a))
|
||||
buttons.append(btn)
|
||||
|
||||
step_input = widgets.FloatText(
|
||||
value=axis.step, layout=num_layout, continuous_update=False
|
||||
)
|
||||
step_input.observe(
|
||||
lambda change, i=i: self._set_step(i, change["new"]), names="value"
|
||||
)
|
||||
self._step_inputs.append(step_input)
|
||||
go_input = widgets.FloatText(layout=num_layout)
|
||||
go_btn = widgets.Button(description="Go", layout=widgets.Layout(width="45px"))
|
||||
go_btn.on_click(
|
||||
lambda _b, i=i: self.controller.go(i, self._go_inputs[i].value)
|
||||
)
|
||||
self._go_inputs.append(go_input)
|
||||
reset_input = widgets.FloatText(layout=num_layout)
|
||||
reset_btn = widgets.Button(
|
||||
description="Set",
|
||||
tooltip="redefine the current position as this value",
|
||||
layout=widgets.Layout(width="45px"),
|
||||
)
|
||||
reset_btn.on_click(
|
||||
lambda _b, i=i: self.controller.reset_current_value_to(
|
||||
i, self._reset_inputs[i].value
|
||||
)
|
||||
)
|
||||
self._reset_inputs.append(reset_input)
|
||||
|
||||
rows.append(
|
||||
widgets.HBox(
|
||||
[
|
||||
widgets.HTML(f"<b>{axis.name}</b>", layout=widgets.Layout(width="160px")),
|
||||
value_label,
|
||||
*buttons,
|
||||
widgets.HTML(" step"),
|
||||
step_input,
|
||||
widgets.HTML(" go to"),
|
||||
go_input,
|
||||
go_btn,
|
||||
widgets.HTML(" reset to"),
|
||||
reset_input,
|
||||
reset_btn,
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
start_btn = widgets.Button(
|
||||
description="Back to start",
|
||||
tooltip="all axes back to "
|
||||
+ ", ".join(_fmt(a.start_value) for a in controller.axes)
|
||||
+ " (key: s)",
|
||||
)
|
||||
start_btn.on_click(lambda _b: self.controller.back_to_start())
|
||||
stop_btn = widgets.Button(
|
||||
description="Stop", button_style="danger", tooltip="stop all axes (key: Esc)"
|
||||
)
|
||||
stop_btn.on_click(lambda _b: self.controller.stop())
|
||||
close_btn = widgets.Button(description="Close")
|
||||
close_btn.on_click(lambda _b: self.close())
|
||||
self.keypress_box = widgets.Checkbox(
|
||||
value=keypress, description="keypress control", indent=False
|
||||
)
|
||||
rows.append(widgets.HBox([start_btn, stop_btn, self.keypress_box, close_btn]))
|
||||
|
||||
self.manual = widgets.HTML(_manual_html(controller.manual()))
|
||||
self.key_area = widgets.Box(
|
||||
[self.manual],
|
||||
layout=widgets.Layout(
|
||||
border="2px solid #2e9b45", padding="4px 8px", width="fit-content"
|
||||
),
|
||||
)
|
||||
self._key_event = None
|
||||
try:
|
||||
from ipyevents import Event
|
||||
|
||||
self._key_event = Event(
|
||||
source=self.key_area,
|
||||
watched_events=["keydown"],
|
||||
prevent_default_action=True,
|
||||
)
|
||||
self._key_event.on_dom_event(self._on_key)
|
||||
except Exception:
|
||||
self.manual.value += (
|
||||
"<br><b>keypress control needs the ipyevents package, "
|
||||
"which is not available - use the buttons</b>"
|
||||
)
|
||||
self.keypress_box.observe(
|
||||
lambda change: self._keypress_toggled(change["new"]), names="value"
|
||||
)
|
||||
rows += [self.key_area, self.status]
|
||||
self.plot = None
|
||||
self._plotted_version = None
|
||||
if recorder is not None:
|
||||
recorder.start()
|
||||
self.plot = widgets.Image(format="png")
|
||||
self.ui = widgets.HBox([widgets.VBox(rows), self.plot])
|
||||
else:
|
||||
self.ui = widgets.VBox(rows)
|
||||
self._keypress_toggled(keypress)
|
||||
|
||||
self._poll_interval = poll_interval
|
||||
threading.Thread(target=self._poll_loop, daemon=True).start()
|
||||
|
||||
def _do(self, action):
|
||||
kind, i, arg = action
|
||||
if kind == "move":
|
||||
self.controller.move(i, arg)
|
||||
elif self.controller.scale_step(i, arg):
|
||||
self._show_step(i)
|
||||
|
||||
def _show_step(self, i):
|
||||
step_input = self._step_inputs[i]
|
||||
step = self.controller.axes[i].step
|
||||
if step_input.value != step:
|
||||
step_input.value = step # observer: set_step with the same value
|
||||
|
||||
def _set_step(self, i, value):
|
||||
if value != self.controller.axes[i].step and value:
|
||||
self.controller.set_step(i, value)
|
||||
|
||||
def _keypress_toggled(self, on):
|
||||
self.key_area.layout.display = None if on else "none"
|
||||
|
||||
def _on_key(self, event):
|
||||
if not self.keypress_box.value:
|
||||
return
|
||||
key = normalize_key_event(event)
|
||||
action = self.controller.handle_key(key) if key else None
|
||||
if action is None:
|
||||
return
|
||||
kind = action[0]
|
||||
if kind == "step":
|
||||
self._show_step(action[1])
|
||||
elif kind == "release":
|
||||
self.keypress_box.value = False
|
||||
elif kind in ("focus_go", "focus_reset"):
|
||||
inputs = self._go_inputs if kind == "focus_go" else self._reset_inputs
|
||||
try:
|
||||
inputs[action[1]].focus()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _poll_loop(self):
|
||||
first = True
|
||||
while not self._stop_event.is_set():
|
||||
for i, axis in enumerate(self.controller.axes):
|
||||
try:
|
||||
value = axis.get_value()
|
||||
text = _fmt(value)
|
||||
except Exception as exc:
|
||||
value, text = None, f"error: {exc}"
|
||||
if self._value_labels[i].value != text:
|
||||
self._value_labels[i].value = text
|
||||
if first and value is not None:
|
||||
try:
|
||||
self._go_inputs[i].value = value
|
||||
self._reset_inputs[i].value = value
|
||||
except Exception:
|
||||
pass
|
||||
first = False
|
||||
self._refresh_plot()
|
||||
self._stop_event.wait(self._poll_interval)
|
||||
|
||||
def _refresh_plot(self):
|
||||
rec = self.recorder
|
||||
if rec is None or self._plotted_version == rec.version:
|
||||
return
|
||||
self._plotted_version = rec.version
|
||||
try:
|
||||
self.plot.value = render_png(rec)
|
||||
except Exception as exc:
|
||||
self._message(f"plot failed: {exc}")
|
||||
|
||||
def _message(self, text):
|
||||
self.status.value = f"<b>{text}</b>"
|
||||
|
||||
def close(self):
|
||||
self._stop_event.set()
|
||||
if self.recorder is not None:
|
||||
self.recorder.stop()
|
||||
if self._key_event is not None:
|
||||
try:
|
||||
self._key_event.close()
|
||||
except Exception:
|
||||
pass
|
||||
self.ui.close()
|
||||
|
||||
def _ipython_display_(self):
|
||||
from IPython.display import display
|
||||
|
||||
display(self.ui)
|
||||
|
||||
|
||||
def tweak_panel_ipy(
|
||||
controller, title=None, poll_interval=0.3, keypress=True, display=False, recorder=None
|
||||
):
|
||||
"""display=False returns the panel for the caller/cell to show."""
|
||||
panel = TweakPanelIpy(
|
||||
controller,
|
||||
title=title,
|
||||
poll_interval=poll_interval,
|
||||
keypress=keypress,
|
||||
recorder=recorder,
|
||||
)
|
||||
if display:
|
||||
from IPython.display import display as _display
|
||||
|
||||
_display(panel.ui)
|
||||
return panel
|
||||
@@ -0,0 +1,427 @@
|
||||
"""
|
||||
Qt tweak panel for 1-4 axes: per axis neg/pos step, step *2 / /2, editable
|
||||
step, go-to and reset-to fields; back-to-start and stop for all; and a
|
||||
"keypress control" checkbox (on by default) that makes the panel take the same
|
||||
keys as the terminal tweak and shows the key manual. Logic and key bindings
|
||||
live in eco.widgets.tweak_panel.
|
||||
|
||||
Keys only reach the panel while it has keyboard focus (click on it); the
|
||||
frame turns green while it does. Buttons never take focus, so a key always
|
||||
means the bound action, except while typing in one of the number fields
|
||||
(Enter applies the value and hands the keys back to the panel, Esc leaves the
|
||||
field without applying).
|
||||
|
||||
Usage:
|
||||
from eco.widgets.tweak_panel import tweak_panel, IocTweakAxis
|
||||
tweak_panel([IocTweakAxis(mot)], backend="qt")
|
||||
or through Tweak.widget() / an adjustable's ._widget_tweak().
|
||||
"""
|
||||
|
||||
import threading
|
||||
|
||||
from qtpy import QtCore, QtWidgets
|
||||
|
||||
from eco.widgets.tweak_panel import _fmt
|
||||
|
||||
_open_panels = set() # strong refs: a parentless widget is otherwise collected
|
||||
_app_ref = None
|
||||
|
||||
_QT_ARROWS = {
|
||||
QtCore.Qt.Key_Left: "Left",
|
||||
QtCore.Qt.Key_Up: "Up",
|
||||
QtCore.Qt.Key_Down: "Down",
|
||||
QtCore.Qt.Key_Right: "Right",
|
||||
}
|
||||
|
||||
_FRAME_ACTIVE = "QFrame#tweakFrame { border: 2px solid #2e9b45; border-radius: 4px; }"
|
||||
_FRAME_IDLE = "QFrame#tweakFrame { border: 2px solid transparent; border-radius: 4px; }"
|
||||
|
||||
|
||||
def normalize_key_event(event):
|
||||
"""Qt key event -> eco.widgets.tweak_panel normalized key name, or None."""
|
||||
key = event.key()
|
||||
if key in _QT_ARROWS:
|
||||
name = _QT_ARROWS[key]
|
||||
if event.modifiers() & QtCore.Qt.ControlModifier:
|
||||
return "Ctrl+" + name
|
||||
return name
|
||||
if key == QtCore.Qt.Key_Escape:
|
||||
return "Escape"
|
||||
text = event.text()
|
||||
if len(text) == 1 and text.isprintable():
|
||||
return text.lower()
|
||||
return None
|
||||
|
||||
|
||||
class _Bridge(QtCore.QObject):
|
||||
value = QtCore.Signal(int, str)
|
||||
message = QtCore.Signal(str)
|
||||
|
||||
|
||||
class _ValueEdit(QtWidgets.QLineEdit):
|
||||
"""Number field: Enter applies (via `applied`) and returns focus to the
|
||||
panel, Esc returns focus without applying."""
|
||||
|
||||
applied = QtCore.Signal(float)
|
||||
|
||||
def __init__(self, panel, width=90):
|
||||
super().__init__()
|
||||
self._panel = panel
|
||||
self.setFixedWidth(width)
|
||||
self.returnPressed.connect(self._apply)
|
||||
|
||||
def _apply(self):
|
||||
try:
|
||||
value = float(self.text())
|
||||
except ValueError:
|
||||
self._panel._message(f"not a number: {self.text()!r}")
|
||||
return
|
||||
self.applied.emit(value)
|
||||
self._panel.setFocus()
|
||||
|
||||
def keyPressEvent(self, event):
|
||||
if event.key() == QtCore.Qt.Key_Escape:
|
||||
self._panel.setFocus()
|
||||
return
|
||||
super().keyPressEvent(event)
|
||||
|
||||
|
||||
class TweakPlotQt(QtWidgets.QWidget):
|
||||
"""Live plot of a TweakRecorder (eco.widgets.tweak_recorder), with the
|
||||
matplotlib toolbar; redraws only when the recorder has new data."""
|
||||
|
||||
def __init__(self, recorder, refresh_interval=0.5, parent=None):
|
||||
super().__init__(parent)
|
||||
from matplotlib.backends.backend_qtagg import (
|
||||
FigureCanvasQTAgg,
|
||||
NavigationToolbar2QT,
|
||||
)
|
||||
from matplotlib.figure import Figure
|
||||
|
||||
from eco.widgets.tweak_recorder import figure_size
|
||||
|
||||
self.recorder = recorder
|
||||
self.figure = Figure(figsize=figure_size(recorder), dpi=90)
|
||||
self.canvas = FigureCanvasQTAgg(self.figure)
|
||||
self.canvas.setFocusPolicy(QtCore.Qt.NoFocus)
|
||||
toolbar = NavigationToolbar2QT(self.canvas, self)
|
||||
toolbar.setFocusPolicy(QtCore.Qt.NoFocus)
|
||||
layout = QtWidgets.QVBoxLayout(self)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.addWidget(toolbar)
|
||||
layout.addWidget(self.canvas)
|
||||
self._drawn_version = None
|
||||
self._timer = QtCore.QTimer(self)
|
||||
self._timer.timeout.connect(self.refresh)
|
||||
self._timer.start(int(refresh_interval * 1000))
|
||||
self.refresh()
|
||||
|
||||
def refresh(self, force=False):
|
||||
if not force and self._drawn_version == self.recorder.version:
|
||||
return
|
||||
from eco.widgets.tweak_recorder import draw_tweak_plot
|
||||
|
||||
self._drawn_version = self.recorder.version
|
||||
draw_tweak_plot(self.figure, self.recorder)
|
||||
self.canvas.draw_idle()
|
||||
|
||||
def closeEvent(self, event):
|
||||
self._timer.stop()
|
||||
_open_panels.discard(self)
|
||||
super().closeEvent(event)
|
||||
|
||||
|
||||
class TweakPanelQt(QtWidgets.QFrame):
|
||||
def __init__(
|
||||
self,
|
||||
controller,
|
||||
title=None,
|
||||
poll_interval=0.2,
|
||||
keypress=True,
|
||||
recorder=None,
|
||||
parent=None,
|
||||
):
|
||||
super().__init__(parent)
|
||||
self.controller = controller
|
||||
self.recorder = recorder
|
||||
self.setObjectName("tweakFrame")
|
||||
self.setWindowTitle(title or "tweak " + ", ".join(controller.names))
|
||||
self.setAttribute(QtCore.Qt.WA_QuitOnClose, False)
|
||||
self.setFocusPolicy(QtCore.Qt.StrongFocus)
|
||||
|
||||
self._bridge = _Bridge()
|
||||
self._bridge.value.connect(self._set_value_label)
|
||||
self._bridge.message.connect(self._show_message)
|
||||
controller.notify = self._bridge.message.emit
|
||||
|
||||
key_of = {action: key for key, action in controller.bindings.items()}
|
||||
|
||||
main = QtWidgets.QHBoxLayout(self)
|
||||
outer = QtWidgets.QVBoxLayout()
|
||||
main.addLayout(outer)
|
||||
grid = QtWidgets.QGridLayout()
|
||||
for col, text in enumerate(
|
||||
("axis", "current", "", "", "", "", "step", "go to", "reset to")
|
||||
):
|
||||
if text:
|
||||
grid.addWidget(QtWidgets.QLabel(f"<b>{text}</b>"), 0, col)
|
||||
|
||||
self._value_labels = []
|
||||
self._step_edits = []
|
||||
self._go_edits = []
|
||||
self._reset_edits = []
|
||||
for i, axis in enumerate(controller.axes):
|
||||
r = i + 1
|
||||
grid.addWidget(QtWidgets.QLabel(axis.name), r, 0)
|
||||
value_label = QtWidgets.QLabel("…")
|
||||
value_label.setMinimumWidth(100)
|
||||
value_label.setTextInteractionFlags(QtCore.Qt.TextSelectableByMouse)
|
||||
grid.addWidget(value_label, r, 1)
|
||||
self._value_labels.append(value_label)
|
||||
|
||||
buttons = (
|
||||
("◀", "neg dir", ("move", i, -1), lambda _=False, i=i: self._move(i, -1)),
|
||||
("×2", "step*2", ("step", i, 2.0), lambda _=False, i=i: self._scale(i, 2.0)),
|
||||
("÷2", "step/2", ("step", i, 0.5), lambda _=False, i=i: self._scale(i, 0.5)),
|
||||
("▶", "pos dir", ("move", i, +1), lambda _=False, i=i: self._move(i, +1)),
|
||||
)
|
||||
for col, (text, tip, action, slot) in enumerate(buttons, start=2):
|
||||
btn = QtWidgets.QPushButton(text)
|
||||
btn.setFixedWidth(36)
|
||||
btn.setFocusPolicy(QtCore.Qt.NoFocus)
|
||||
key = key_of.get(action)
|
||||
btn.setToolTip(f"{tip} (key: {_key_label(key)})" if key else tip)
|
||||
btn.clicked.connect(slot)
|
||||
grid.addWidget(btn, r, col)
|
||||
|
||||
step_edit = _ValueEdit(self, width=80)
|
||||
step_edit.setText(_fmt(axis.step))
|
||||
step_edit.applied.connect(lambda v, i=i: self._set_step(i, v))
|
||||
go_edit = _ValueEdit(self)
|
||||
go_edit.applied.connect(lambda v, i=i: self.controller.go(i, v))
|
||||
reset_edit = _ValueEdit(self)
|
||||
reset_edit.setToolTip("redefine the current position as this value")
|
||||
reset_edit.applied.connect(
|
||||
lambda v, i=i: self.controller.reset_current_value_to(i, v)
|
||||
)
|
||||
for col, edit in ((6, step_edit), (7, go_edit), (8, reset_edit)):
|
||||
grid.addWidget(edit, r, col)
|
||||
self._step_edits.append(step_edit)
|
||||
self._go_edits.append(go_edit)
|
||||
self._reset_edits.append(reset_edit)
|
||||
outer.addLayout(grid)
|
||||
|
||||
bottom = QtWidgets.QHBoxLayout()
|
||||
start_btn = QtWidgets.QPushButton("Back to start")
|
||||
start_btn.setToolTip(
|
||||
"all axes back to "
|
||||
+ ", ".join(_fmt(a.start_value) for a in controller.axes)
|
||||
+ " (key: s)"
|
||||
)
|
||||
start_btn.clicked.connect(lambda _=False: self.controller.back_to_start())
|
||||
stop_btn = QtWidgets.QPushButton("Stop")
|
||||
stop_btn.setToolTip("stop all axes (key: Esc)")
|
||||
stop_btn.clicked.connect(lambda _=False: self.controller.stop())
|
||||
self.keypress_box = QtWidgets.QCheckBox("keypress control")
|
||||
self.keypress_box.setChecked(keypress)
|
||||
self.keypress_box.toggled.connect(self._keypress_toggled)
|
||||
for w in (start_btn, stop_btn, self.keypress_box):
|
||||
w.setFocusPolicy(QtCore.Qt.NoFocus)
|
||||
bottom.addWidget(w)
|
||||
bottom.addStretch(1)
|
||||
outer.addLayout(bottom)
|
||||
|
||||
self.manual = QtWidgets.QLabel(_manual_html(controller.manual()))
|
||||
self.manual.setTextFormat(QtCore.Qt.RichText)
|
||||
outer.addWidget(self.manual)
|
||||
self.status = QtWidgets.QLabel("")
|
||||
self.status.setWordWrap(True)
|
||||
outer.addWidget(self.status)
|
||||
outer.addStretch(1)
|
||||
self._keypress_toggled(keypress)
|
||||
|
||||
self.plot = None
|
||||
if recorder is not None:
|
||||
recorder.start()
|
||||
self.plot = TweakPlotQt(recorder)
|
||||
main.addWidget(self.plot, 1)
|
||||
|
||||
self._stop_event = threading.Event()
|
||||
self._poll_interval = poll_interval
|
||||
self._poll_thread = threading.Thread(target=self._poll_loop, daemon=True)
|
||||
self._poll_thread.start()
|
||||
|
||||
# --- actions ---
|
||||
def _move(self, i, sign):
|
||||
self.controller.move(i, sign)
|
||||
|
||||
def _scale(self, i, factor):
|
||||
if self.controller.scale_step(i, factor):
|
||||
self._step_edits[i].setText(_fmt(self.controller.axes[i].step))
|
||||
|
||||
def _set_step(self, i, value):
|
||||
if self.controller.set_step(i, value):
|
||||
self._step_edits[i].setText(_fmt(self.controller.axes[i].step))
|
||||
|
||||
# --- keys ---
|
||||
def keypress_active(self):
|
||||
return self.keypress_box.isChecked()
|
||||
|
||||
def _keypress_toggled(self, on):
|
||||
self.manual.setVisible(on)
|
||||
self._update_frame()
|
||||
if on:
|
||||
self.setFocus()
|
||||
|
||||
def _update_frame(self):
|
||||
self.setStyleSheet(
|
||||
_FRAME_ACTIVE if self.keypress_active() and self.hasFocus() else _FRAME_IDLE
|
||||
)
|
||||
|
||||
def focusInEvent(self, event):
|
||||
self._update_frame()
|
||||
super().focusInEvent(event)
|
||||
|
||||
def focusOutEvent(self, event):
|
||||
self._update_frame()
|
||||
super().focusOutEvent(event)
|
||||
|
||||
def mousePressEvent(self, event):
|
||||
self.setFocus()
|
||||
super().mousePressEvent(event)
|
||||
|
||||
def keyPressEvent(self, event):
|
||||
if not self.keypress_active():
|
||||
return super().keyPressEvent(event)
|
||||
key = normalize_key_event(event)
|
||||
action = self.controller.handle_key(key) if key else None
|
||||
if action is None:
|
||||
return super().keyPressEvent(event)
|
||||
kind = action[0]
|
||||
if kind == "step":
|
||||
self._step_edits[action[1]].setText(_fmt(self.controller.axes[action[1]].step))
|
||||
elif kind == "release":
|
||||
self.keypress_box.setChecked(False)
|
||||
elif kind in ("focus_go", "focus_reset"):
|
||||
edits = self._go_edits if kind == "focus_go" else self._reset_edits
|
||||
edit = edits[action[1]]
|
||||
edit.setText(self._value_labels[action[1]].text())
|
||||
edit.setFocus()
|
||||
edit.selectAll()
|
||||
event.accept()
|
||||
|
||||
# --- values / messages ---
|
||||
def _poll_loop(self):
|
||||
while not self._stop_event.is_set():
|
||||
for i, axis in enumerate(self.controller.axes):
|
||||
try:
|
||||
text = _fmt(axis.get_value())
|
||||
except Exception as exc:
|
||||
text = f"error: {exc}"
|
||||
self._bridge.value.emit(i, text)
|
||||
self._stop_event.wait(self._poll_interval)
|
||||
|
||||
def _set_value_label(self, i, text):
|
||||
label = self._value_labels[i]
|
||||
if label.text() != text:
|
||||
label.setText(text)
|
||||
|
||||
def _message(self, text):
|
||||
self._bridge.message.emit(text)
|
||||
|
||||
def _show_message(self, text):
|
||||
self.status.setText(text)
|
||||
QtCore.QTimer.singleShot(8000, lambda: self.status.setText(""))
|
||||
|
||||
def closeEvent(self, event):
|
||||
self._stop_event.set()
|
||||
if self.recorder is not None:
|
||||
self.recorder.stop()
|
||||
_open_panels.discard(self)
|
||||
super().closeEvent(event)
|
||||
|
||||
|
||||
def _key_label(key):
|
||||
return key.replace("Left", "←").replace("Right", "→").replace("Up", "↑").replace(
|
||||
"Down", "↓"
|
||||
).replace("Ctrl+", "ctrl+")
|
||||
|
||||
|
||||
def _manual_html(rows):
|
||||
body = "".join(
|
||||
f"<tr><td style='padding-right:12px'><b><tt>{keys}</tt></b></td><td>{meaning}</td></tr>"
|
||||
for keys, meaning in rows
|
||||
)
|
||||
return (
|
||||
"<table>" + body + "</table>"
|
||||
"<i>keys act while this panel has focus (green frame) - click it to give it focus</i>"
|
||||
)
|
||||
|
||||
|
||||
def _ensure_qt_app():
|
||||
"""A QApplication pumped by IPython's event loop integration if there is
|
||||
an IPython shell (enabling `%gui qt` if no loop is active yet). Returns
|
||||
True if the caller must run the event loop itself (plain python)."""
|
||||
global _app_ref
|
||||
from eco.widgets.tweak_recorder import display_available
|
||||
|
||||
if QtWidgets.QApplication.instance() is None and not display_available():
|
||||
raise RuntimeError("no display available for a Qt window")
|
||||
try:
|
||||
from IPython import get_ipython
|
||||
|
||||
ip = get_ipython()
|
||||
except Exception:
|
||||
ip = None
|
||||
if QtWidgets.QApplication.instance() is None:
|
||||
_app_ref = QtWidgets.QApplication([])
|
||||
if ip is None:
|
||||
return True
|
||||
if getattr(ip, "active_eventloop", None) is None and ip.__class__.__name__ != (
|
||||
"InProcessInteractiveShell"
|
||||
):
|
||||
try:
|
||||
ip.enable_gui("qt")
|
||||
except Exception:
|
||||
pass
|
||||
return False
|
||||
|
||||
|
||||
def show_tweak_plot_qt(recorder, title=None):
|
||||
"""Standalone live-plot window (used by the terminal tweaks); never
|
||||
blocks -- it is pumped by IPython's event loop integration, also while
|
||||
the terminal tweak waits for keys."""
|
||||
_ensure_qt_app()
|
||||
window = TweakPlotQt(recorder)
|
||||
window.setAttribute(QtCore.Qt.WA_QuitOnClose, False)
|
||||
window.setWindowTitle(
|
||||
title
|
||||
or "tweak "
|
||||
+ ", ".join(recorder.axis_names)
|
||||
+ " - "
|
||||
+ ", ".join(recorder.detector_names)
|
||||
)
|
||||
_open_panels.add(window)
|
||||
window.show()
|
||||
return window
|
||||
|
||||
|
||||
def show_tweak_panel_qt(
|
||||
controller, title=None, poll_interval=0.2, keypress=True, recorder=None
|
||||
):
|
||||
blocking = _ensure_qt_app()
|
||||
panel = TweakPanelQt(
|
||||
controller,
|
||||
title=title,
|
||||
poll_interval=poll_interval,
|
||||
keypress=keypress,
|
||||
recorder=recorder,
|
||||
)
|
||||
_open_panels.add(panel)
|
||||
panel.show()
|
||||
panel.raise_()
|
||||
panel.activateWindow()
|
||||
panel.setFocus()
|
||||
if blocking:
|
||||
QtWidgets.QApplication.instance().exec_()
|
||||
return panel
|
||||
@@ -0,0 +1,286 @@
|
||||
"""
|
||||
Record detector values along a tweak, and plot them.
|
||||
|
||||
TweakRecorder samples the tweak axes and the detectors in a background thread.
|
||||
Each plateau of constant axis positions becomes one point: detector values are
|
||||
averaged only while the positions stay put, so readings taken during a move
|
||||
never mix in. A new point starts as soon as the positions settle somewhere
|
||||
else -- whatever moved them (panel, terminal keys, IOC tweak, someone else).
|
||||
|
||||
"Constant" means: all axes within `settle_fraction` x their current step size
|
||||
of the previous reading, for `settle_samples` consecutive readings.
|
||||
|
||||
draw_tweak_plot(fig, recorder) draws the points into a matplotlib Figure:
|
||||
- 1 axis: one x/y plot per detector, detector vs axis position
|
||||
- 2+ axes: a vertical stack sharing "step" as x, detectors on top, then one
|
||||
plot per axis position
|
||||
|
||||
Used by the Qt/ipywidgets tweak panels (embedded plot) and by the terminal
|
||||
tweaks (separate Qt window, see terminal_recording).
|
||||
"""
|
||||
|
||||
import contextlib
|
||||
import math
|
||||
import threading
|
||||
import time
|
||||
|
||||
import numpy as np
|
||||
|
||||
from eco.widgets.tweak_panel import _name_of
|
||||
|
||||
|
||||
def _as_float(value):
|
||||
"""Scalar -> float; array-like -> mean of its elements; else NaN."""
|
||||
if value is None:
|
||||
return math.nan
|
||||
try:
|
||||
return float(value)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
arr = np.asarray(value, dtype=float)
|
||||
return float(np.nanmean(arr)) if np.isfinite(arr).any() else math.nan
|
||||
except Exception:
|
||||
return math.nan
|
||||
|
||||
|
||||
def _as_detector_list(detectors):
|
||||
if detectors is None:
|
||||
return []
|
||||
if isinstance(detectors, (list, tuple)):
|
||||
return list(detectors)
|
||||
return [detectors]
|
||||
|
||||
|
||||
class TweakRecorder:
|
||||
def __init__(
|
||||
self,
|
||||
axes,
|
||||
detectors,
|
||||
interval=0.1,
|
||||
settle_fraction=0.1,
|
||||
settle_samples=2,
|
||||
):
|
||||
self.axes = list(axes)
|
||||
self.detectors = _as_detector_list(detectors)
|
||||
self.axis_names = [a.name for a in self.axes]
|
||||
self.detector_names = [_name_of(d) for d in self.detectors]
|
||||
self.interval = interval
|
||||
self.settle_fraction = settle_fraction
|
||||
self.settle_samples = settle_samples
|
||||
self.version = 0 # bumped on every change, for cheap redraw checks
|
||||
self._points = [] # dicts, see points
|
||||
self._lock = threading.Lock()
|
||||
self._stop_event = threading.Event()
|
||||
self._thread = None
|
||||
self._last_positions = None
|
||||
self._stable_count = 0
|
||||
|
||||
# --- running ---
|
||||
def start(self):
|
||||
if self._thread is None:
|
||||
self._stop_event.clear()
|
||||
self._thread = threading.Thread(target=self._run, daemon=True)
|
||||
self._thread.start()
|
||||
return self
|
||||
|
||||
def stop(self):
|
||||
self._stop_event.set()
|
||||
self._thread = None
|
||||
|
||||
def _run(self):
|
||||
while not self._stop_event.is_set():
|
||||
t0 = time.time()
|
||||
try:
|
||||
self.sample()
|
||||
except Exception:
|
||||
pass
|
||||
self._stop_event.wait(max(0.0, self.interval - (time.time() - t0)))
|
||||
|
||||
# --- sampling ---
|
||||
def _tolerances(self):
|
||||
tols = []
|
||||
for axis in self.axes:
|
||||
try:
|
||||
step = axis.current_step()
|
||||
except Exception:
|
||||
step = getattr(axis, "step", 0.0)
|
||||
tols.append(self.settle_fraction * abs(step or 0.0))
|
||||
return tols
|
||||
|
||||
@staticmethod
|
||||
def _same(a, b, tols):
|
||||
return all(abs(x - y) <= t for x, y, t in zip(a, b, tols))
|
||||
|
||||
def sample(self):
|
||||
"""Take one reading (called by the thread; public for tests)."""
|
||||
positions = [_as_float(a.get_value()) for a in self.axes]
|
||||
if any(math.isnan(p) for p in positions):
|
||||
return
|
||||
tols = self._tolerances()
|
||||
last, self._last_positions = self._last_positions, positions
|
||||
if last is not None and self._same(positions, last, tols):
|
||||
self._stable_count += 1
|
||||
else:
|
||||
self._stable_count = 0
|
||||
if self._stable_count < self.settle_samples - 1:
|
||||
return # moving, or not yet settled
|
||||
|
||||
values = []
|
||||
for det in self.detectors:
|
||||
try:
|
||||
values.append(_as_float(det.get_current_value()))
|
||||
except Exception:
|
||||
values.append(math.nan)
|
||||
|
||||
with self._lock:
|
||||
current = self._points[-1] if self._points else None
|
||||
if current is None or not self._same(
|
||||
positions, current["_position_sums"] / current["n_samples"], tols
|
||||
):
|
||||
current = {
|
||||
"step": len(self._points),
|
||||
"time": time.time(),
|
||||
"n_samples": 0,
|
||||
"_position_sums": np.zeros(len(positions)),
|
||||
"_values": [[] for _ in self.detectors],
|
||||
}
|
||||
self._points.append(current)
|
||||
current["n_samples"] += 1
|
||||
current["_position_sums"] += positions
|
||||
for store, value in zip(current["_values"], values):
|
||||
if not math.isnan(value):
|
||||
store.append(value)
|
||||
self.version += 1
|
||||
|
||||
# --- results ---
|
||||
@property
|
||||
def points(self):
|
||||
"""One dict per plateau: step, time, n_samples, positions (mean per
|
||||
axis), values (mean per detector), stds (per detector)."""
|
||||
out = []
|
||||
with self._lock:
|
||||
for p in self._points:
|
||||
vals = [np.asarray(v) for v in p["_values"]]
|
||||
out.append(
|
||||
{
|
||||
"step": p["step"],
|
||||
"time": p["time"],
|
||||
"n_samples": p["n_samples"],
|
||||
"positions": [float(v) for v in p["_position_sums"] / p["n_samples"]],
|
||||
"values": [float(v.mean()) if v.size else math.nan for v in vals],
|
||||
"stds": [float(v.std()) if v.size else math.nan for v in vals],
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
def arrays(self):
|
||||
"""(steps, positions[n_points, n_axes], values[n_points, n_detectors])"""
|
||||
pts = self.points
|
||||
steps = np.array([p["step"] for p in pts])
|
||||
pos = np.array([p["positions"] for p in pts]).reshape(len(pts), len(self.axes))
|
||||
val = np.array([p["values"] for p in pts]).reshape(len(pts), len(self.detectors))
|
||||
return steps, pos, val
|
||||
|
||||
def to_dataframe(self):
|
||||
import pandas as pd
|
||||
|
||||
rows = []
|
||||
for p in self.points:
|
||||
row = {"step": p["step"], "time": p["time"], "n_samples": p["n_samples"]}
|
||||
row.update(zip(self.axis_names, p["positions"]))
|
||||
row.update(zip(self.detector_names, p["values"]))
|
||||
row.update((f"{n}_std", s) for n, s in zip(self.detector_names, p["stds"]))
|
||||
rows.append(row)
|
||||
return pd.DataFrame(rows)
|
||||
|
||||
|
||||
def plot_rows(recorder):
|
||||
return len(recorder.detectors) if len(recorder.axes) == 1 else (
|
||||
len(recorder.detectors) + len(recorder.axes)
|
||||
)
|
||||
|
||||
|
||||
def draw_tweak_plot(fig, recorder):
|
||||
"""(Re)draw the recorder's points into matplotlib Figure `fig`."""
|
||||
fig.clear()
|
||||
steps, pos, val = recorder.arrays()
|
||||
n_rows = max(plot_rows(recorder), 1)
|
||||
axs = fig.subplots(n_rows, 1, sharex=True, squeeze=False)[:, 0]
|
||||
style = dict(marker="o", markersize=4, linewidth=0.8, color="C0")
|
||||
last = dict(marker="o", markersize=7, linestyle="none", color="C3")
|
||||
|
||||
if len(recorder.axes) == 1:
|
||||
x = pos[:, 0] if len(steps) else []
|
||||
for ax, i in zip(axs, range(len(recorder.detectors))):
|
||||
if len(steps):
|
||||
ax.plot(x, val[:, i], **style)
|
||||
ax.plot(x[-1:], val[-1:, i], **last)
|
||||
ax.set_ylabel(recorder.detector_names[i])
|
||||
axs[-1].set_xlabel(recorder.axis_names[0])
|
||||
else:
|
||||
series = [(n, val[:, i] if len(steps) else []) for i, n in enumerate(recorder.detector_names)]
|
||||
series += [(n, pos[:, i] if len(steps) else []) for i, n in enumerate(recorder.axis_names)]
|
||||
for ax, (name, y) in zip(axs, series):
|
||||
if len(steps):
|
||||
ax.plot(steps, y, **style)
|
||||
ax.plot(steps[-1:], y[-1:], **last)
|
||||
ax.set_ylabel(name)
|
||||
axs[-1].set_xlabel("step")
|
||||
for ax in axs:
|
||||
ax.grid(True, alpha=0.3)
|
||||
ax.yaxis.label.set_fontsize(8)
|
||||
ax.tick_params(labelsize=7)
|
||||
fig.tight_layout()
|
||||
|
||||
|
||||
def figure_size(recorder):
|
||||
return (5.0, max(2.8, 1.7 * plot_rows(recorder)))
|
||||
|
||||
|
||||
def display_available():
|
||||
"""False where creating a QApplication would abort the process (no X /
|
||||
Wayland display, e.g. a plain ssh session)."""
|
||||
import os
|
||||
import sys
|
||||
|
||||
if os.environ.get("QT_QPA_PLATFORM"):
|
||||
return True
|
||||
if not sys.platform.startswith("linux"):
|
||||
return True
|
||||
return bool(os.environ.get("DISPLAY") or os.environ.get("WAYLAND_DISPLAY"))
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def terminal_recording(axes, detectors, owner=None, label=None):
|
||||
"""Record (and, where a display is available, live-plot in a Qt window)
|
||||
`detectors` during a terminal tweak of `axes`. The recorder is left on
|
||||
`owner._tweak_recorder` (if given) for later access, `label` says where
|
||||
in the final message; the plot window stays open after the tweak ends."""
|
||||
detectors = _as_detector_list(detectors)
|
||||
if not detectors:
|
||||
yield None
|
||||
return
|
||||
recorder = TweakRecorder(axes, detectors).start()
|
||||
if owner is not None:
|
||||
try:
|
||||
owner._tweak_recorder = recorder
|
||||
except Exception:
|
||||
pass
|
||||
if display_available():
|
||||
try:
|
||||
from eco.widgets.tweak_panel_qt import show_tweak_plot_qt
|
||||
|
||||
recorder._plot_window = show_tweak_plot_qt(recorder)
|
||||
except Exception as exc:
|
||||
print(f"tweak: no live plot ({exc}); still recording")
|
||||
else:
|
||||
print("tweak: no display for a live plot; still recording")
|
||||
try:
|
||||
yield recorder
|
||||
finally:
|
||||
recorder.stop()
|
||||
print(
|
||||
f"\ntweak recorded {len(recorder.points)} points "
|
||||
f"({label or 'recorder'}.to_dataframe() for the data)"
|
||||
)
|
||||
@@ -0,0 +1,329 @@
|
||||
"""Tweak panels (eco.widgets.tweak_panel[_qt|_ipy]): key bindings, controller,
|
||||
the soft (Tweak) axis, and both panels driven by key events."""
|
||||
|
||||
import os
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
from eco.widgets import tweak_panel as tp
|
||||
|
||||
|
||||
class FakeAxis:
|
||||
def __init__(self, name, value=0.0, step=1.0):
|
||||
self.name = name
|
||||
self.value = value
|
||||
self.step = step
|
||||
self.start_value = value
|
||||
self.log = []
|
||||
|
||||
def get_value(self):
|
||||
return self.value
|
||||
|
||||
def move(self, sign):
|
||||
self.value += sign * self.step
|
||||
self.log.append(("move", sign))
|
||||
|
||||
def scale_step(self, factor):
|
||||
self.step *= factor
|
||||
|
||||
def set_step(self, step):
|
||||
self.step = step
|
||||
|
||||
def go(self, value):
|
||||
self.value = value
|
||||
self.log.append(("go", value))
|
||||
|
||||
def back_to_start(self):
|
||||
self.go(self.start_value)
|
||||
|
||||
def reset_current_value_to(self, value):
|
||||
self.value = value
|
||||
self.log.append(("reset", value))
|
||||
|
||||
def stop(self):
|
||||
self.log.append(("stop",))
|
||||
|
||||
|
||||
def axes(n):
|
||||
return [FakeAxis(f"a{i}") for i in range(n)]
|
||||
|
||||
|
||||
# --- bindings ---
|
||||
|
||||
|
||||
def test_single_axis_uses_arrows_like_the_terminal():
|
||||
b = tp.key_bindings(1)
|
||||
assert b["Left"] == ("move", 0, -1)
|
||||
assert b["Up"] == ("step", 0, 2.0)
|
||||
assert b["Down"] == ("step", 0, 0.5)
|
||||
assert b["Right"] == ("move", 0, +1)
|
||||
assert b["s"] == ("start",) and b["q"] == ("release",)
|
||||
|
||||
|
||||
def test_two_axes_use_xy_arrows_and_ctrl_arrows_for_steps():
|
||||
b = tp.key_bindings(2)
|
||||
assert b["Left"] == ("move", 0, -1) and b["Right"] == ("move", 0, +1)
|
||||
assert b["Down"] == ("move", 1, -1) and b["Up"] == ("move", 1, +1)
|
||||
assert b["Ctrl+Right"] == ("step", 0, 2.0) and b["Ctrl+Left"] == ("step", 0, 0.5)
|
||||
assert b["Ctrl+Up"] == ("step", 1, 2.0) and b["Ctrl+Down"] == ("step", 1, 0.5)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"n, rows",
|
||||
[
|
||||
(1, ["jkl;"]),
|
||||
(2, ["jkl;", "uiop"]),
|
||||
(3, ["jkl;", "uiop", "7890"]),
|
||||
(4, ["m,./", "jkl;", "uiop", "7890"]),
|
||||
],
|
||||
)
|
||||
def test_stacked_rows(n, rows):
|
||||
b = tp.key_bindings(n, stacked=True)
|
||||
for i, (neg, dbl, half, pos) in enumerate(rows):
|
||||
assert b[neg] == ("move", i, -1)
|
||||
assert b[dbl] == ("step", i, 2.0)
|
||||
assert b[half] == ("step", i, 0.5)
|
||||
assert b[pos] == ("move", i, +1)
|
||||
assert "Left" not in b
|
||||
|
||||
|
||||
def test_stacked_is_automatic_above_two_axes_and_limited_to_four():
|
||||
assert not tp.use_stacked(1) and not tp.use_stacked(2)
|
||||
assert tp.use_stacked(3) and tp.use_stacked(4)
|
||||
with pytest.raises(ValueError):
|
||||
tp.key_bindings(5)
|
||||
|
||||
|
||||
def test_manual_names_every_key_and_lists_the_top_row_first():
|
||||
manual = tp.key_manual(["x", "y", "z", "w"])
|
||||
keys = [k for k, _ in manual]
|
||||
assert keys[:4] == ["7 8 9 0", "u i o p", "j k l ;", "m , . /"]
|
||||
assert manual[0][1].startswith("w:")
|
||||
assert {"s", "Esc", "q"} <= set(keys)
|
||||
|
||||
|
||||
# --- controller ---
|
||||
|
||||
|
||||
def test_controller_applies_keys_to_the_right_axis():
|
||||
a = axes(3)
|
||||
c = tp.TweakController(a)
|
||||
c.handle_key(";")
|
||||
c.handle_key("i")
|
||||
c.handle_key("p")
|
||||
c.handle_key("7")
|
||||
assert a[0].value == 1 and a[1].step == 2 and a[1].value == 2 and a[2].value == -1
|
||||
assert c.handle_key("x") is None
|
||||
assert c.handle_key("q") == ("release",)
|
||||
c.handle_key("s")
|
||||
assert [x.value for x in a] == [0, 0, 0]
|
||||
c.handle_key("Escape")
|
||||
assert all(x.log[-1] == ("stop",) for x in a)
|
||||
|
||||
|
||||
def test_controller_reports_errors_instead_of_raising():
|
||||
a = axes(1)
|
||||
a[0].move = lambda sign: 1 / 0
|
||||
c = tp.TweakController(a)
|
||||
messages = []
|
||||
c.notify = messages.append
|
||||
c.handle_key("Right")
|
||||
assert messages and "a0 move failed" in messages[0]
|
||||
|
||||
|
||||
# --- soft axis on a real Tweak ---
|
||||
|
||||
|
||||
class Changer:
|
||||
def is_alive(self):
|
||||
return False
|
||||
|
||||
def wait(self):
|
||||
pass
|
||||
|
||||
|
||||
class Adj:
|
||||
def __init__(self, name, value=0.0):
|
||||
self.name = name
|
||||
self.value = value
|
||||
self.sets = []
|
||||
|
||||
def get_current_value(self):
|
||||
return self.value
|
||||
|
||||
def set_target_value(self, value):
|
||||
self.sets.append(value)
|
||||
self.value = value
|
||||
return Changer()
|
||||
|
||||
def reset_current_value_to(self, value):
|
||||
self.value = value
|
||||
|
||||
|
||||
def test_soft_axis_steps_from_the_target_and_commands_only_its_adjustable():
|
||||
from eco.elements.adjustable import Tweak
|
||||
|
||||
x, y = Adj("x"), Adj("y", 5.0)
|
||||
t = Tweak((x, 1.0), (y, 1.0))
|
||||
ax, ay = tp.axes_from_tweak(t)
|
||||
x.value = 0.4 # still moving: the next step goes from the target
|
||||
ax.move(+1)
|
||||
ax.move(+1)
|
||||
assert x.sets == [1.0, 2.0] and y.sets == []
|
||||
ax.scale_step(0.5)
|
||||
assert t.step_sizes == [0.5, 1.0]
|
||||
ax.move(-1)
|
||||
assert x.sets[-1] == 1.5
|
||||
ay.go(7.0)
|
||||
ax.reset_current_value_to(10.0)
|
||||
ax.move(+1)
|
||||
assert x.sets[-1] == 10.5 # relative to the redefined position
|
||||
ax.back_to_start()
|
||||
assert x.sets[-1] == 0.0 and y.sets == [7.0]
|
||||
|
||||
|
||||
# --- front-end selection ---
|
||||
|
||||
|
||||
def test_frontend(monkeypatch):
|
||||
import IPython
|
||||
|
||||
class InProcessInteractiveShell:
|
||||
pass
|
||||
|
||||
class ZMQInteractiveShell:
|
||||
pass
|
||||
|
||||
monkeypatch.delenv("ECO_QTCONSOLE_KERNEL", raising=False)
|
||||
monkeypatch.setattr(IPython, "get_ipython", lambda: None)
|
||||
assert tp.frontend() == "terminal"
|
||||
monkeypatch.setattr(IPython, "get_ipython", lambda: InProcessInteractiveShell())
|
||||
assert tp.frontend() == "qt"
|
||||
monkeypatch.setattr(IPython, "get_ipython", lambda: ZMQInteractiveShell())
|
||||
assert tp.frontend() == "ipy"
|
||||
monkeypatch.setenv("ECO_QTCONSOLE_KERNEL", "1")
|
||||
assert tp.frontend() == "qt"
|
||||
|
||||
|
||||
# --- Qt panel ---
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def qt_panel():
|
||||
pytest.importorskip("qtpy")
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
from qtpy import QtWidgets
|
||||
|
||||
from eco.widgets.tweak_panel_qt import TweakPanelQt
|
||||
|
||||
app = QtWidgets.QApplication.instance() or QtWidgets.QApplication([])
|
||||
made = []
|
||||
|
||||
def make(n, **kwargs):
|
||||
a = axes(n)
|
||||
panel = TweakPanelQt(tp.TweakController(a, **kwargs), poll_interval=0.05)
|
||||
made.append(panel)
|
||||
return panel, a
|
||||
|
||||
yield make
|
||||
for panel in made:
|
||||
panel.close()
|
||||
app.processEvents()
|
||||
|
||||
|
||||
def test_qt_keys_act_like_the_terminal(qt_panel):
|
||||
from qtpy import QtCore
|
||||
from qtpy.QtTest import QTest
|
||||
|
||||
panel, a = qt_panel(1)
|
||||
assert panel.keypress_active() and not panel.manual.isHidden()
|
||||
QTest.keyClick(panel, QtCore.Qt.Key_Right)
|
||||
QTest.keyClick(panel, QtCore.Qt.Key_Up)
|
||||
QTest.keyClick(panel, QtCore.Qt.Key_Right)
|
||||
assert a[0].value == 3 and panel._step_edits[0].text() == "2"
|
||||
QTest.keyClick(panel, QtCore.Qt.Key_Q)
|
||||
assert not panel.keypress_active() and panel.manual.isHidden()
|
||||
QTest.keyClick(panel, QtCore.Qt.Key_Right)
|
||||
assert a[0].value == 3 # keypress control off
|
||||
|
||||
|
||||
def test_qt_ctrl_arrows_and_stacked_keys(qt_panel):
|
||||
from qtpy import QtCore
|
||||
from qtpy.QtTest import QTest
|
||||
|
||||
panel, a = qt_panel(2)
|
||||
QTest.keyClick(panel, QtCore.Qt.Key_Up, QtCore.Qt.ControlModifier)
|
||||
QTest.keyClick(panel, QtCore.Qt.Key_Up)
|
||||
assert a[1].step == 2 and a[1].value == 2 and a[0].value == 0
|
||||
|
||||
panel, a = qt_panel(4)
|
||||
for key in (QtCore.Qt.Key_Slash, QtCore.Qt.Key_0, QtCore.Qt.Key_Semicolon):
|
||||
QTest.keyClick(panel, key)
|
||||
assert [x.value for x in a] == [1, 1, 0, 1]
|
||||
|
||||
|
||||
def test_qt_buttons_and_fields(qt_panel):
|
||||
from qtpy import QtCore, QtWidgets
|
||||
from qtpy.QtTest import QTest
|
||||
|
||||
panel, a = qt_panel(1)
|
||||
buttons = [b for b in panel.findChildren(QtWidgets.QPushButton) if b.text() == "▶"]
|
||||
assert buttons[0].focusPolicy() == QtCore.Qt.NoFocus
|
||||
assert "key: →" in buttons[0].toolTip()
|
||||
buttons[0].click()
|
||||
assert a[0].value == 1
|
||||
go = panel._go_edits[0]
|
||||
go.setText("4.5")
|
||||
QTest.keyClick(go, QtCore.Qt.Key_Return)
|
||||
assert a[0].log[-1] == ("go", 4.5)
|
||||
go.setText("nonsense")
|
||||
QTest.keyClick(go, QtCore.Qt.Key_Return)
|
||||
assert a[0].log[-1] == ("go", 4.5)
|
||||
step = panel._step_edits[0]
|
||||
step.setText("0.25")
|
||||
QTest.keyClick(step, QtCore.Qt.Key_Return)
|
||||
assert a[0].step == 0.25
|
||||
|
||||
|
||||
def test_qt_value_labels_are_polled(qt_panel):
|
||||
from qtpy import QtWidgets
|
||||
|
||||
panel, a = qt_panel(1)
|
||||
a[0].value = 1.25
|
||||
app = QtWidgets.QApplication.instance()
|
||||
for _ in range(50):
|
||||
app.processEvents()
|
||||
if panel._value_labels[0].text() == "1.25":
|
||||
break
|
||||
time.sleep(0.02)
|
||||
assert panel._value_labels[0].text() == "1.25"
|
||||
|
||||
|
||||
# --- ipywidgets panel ---
|
||||
|
||||
|
||||
def test_ipy_keys_and_buttons():
|
||||
pytest.importorskip("ipywidgets")
|
||||
from eco.widgets.tweak_panel_ipy import TweakPanelIpy, normalize_key_event
|
||||
|
||||
assert normalize_key_event({"key": "ArrowUp", "ctrlKey": True}) == "Ctrl+Up"
|
||||
assert normalize_key_event({"key": "J"}) == "j"
|
||||
assert normalize_key_event({"key": "Shift"}) is None
|
||||
|
||||
a = axes(3)
|
||||
panel = TweakPanelIpy(tp.TweakController(a), poll_interval=0.05)
|
||||
try:
|
||||
panel._on_key({"key": ";"})
|
||||
panel._on_key({"key": "i"})
|
||||
assert a[0].value == 1 and a[1].step == 2
|
||||
assert panel._step_inputs[1].value == 2
|
||||
panel._step_inputs[2].value = 0.5
|
||||
assert a[2].step == 0.5
|
||||
panel._on_key({"key": "q"})
|
||||
assert panel.keypress_box.value is False
|
||||
assert panel.key_area.layout.display == "none"
|
||||
panel._on_key({"key": ";"})
|
||||
assert a[0].value == 1
|
||||
finally:
|
||||
panel.close()
|
||||
@@ -0,0 +1,240 @@
|
||||
"""Detector recording along a tweak (eco.widgets.tweak_recorder) and its plots
|
||||
in the Qt/ipywidgets tweak panels."""
|
||||
|
||||
import math
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
from eco.widgets import tweak_panel as tp
|
||||
from eco.widgets.tweak_recorder import TweakRecorder, draw_tweak_plot, plot_rows
|
||||
|
||||
|
||||
class Axis:
|
||||
def __init__(self, name, value=0.0, step=1.0):
|
||||
self.name = name
|
||||
self.value = value
|
||||
self.step = step
|
||||
self.start_value = value
|
||||
|
||||
def get_value(self):
|
||||
return self.value
|
||||
|
||||
def current_step(self):
|
||||
return self.step
|
||||
|
||||
def move(self, sign):
|
||||
self.value += sign * self.step
|
||||
|
||||
def scale_step(self, factor):
|
||||
self.step *= factor
|
||||
|
||||
def set_step(self, step):
|
||||
self.step = step
|
||||
|
||||
def go(self, value):
|
||||
self.value = value
|
||||
|
||||
def back_to_start(self):
|
||||
self.value = self.start_value
|
||||
|
||||
def reset_current_value_to(self, value):
|
||||
self.value = value
|
||||
|
||||
def stop(self):
|
||||
pass
|
||||
|
||||
|
||||
class Det:
|
||||
def __init__(self, name, values):
|
||||
self.name = name
|
||||
self.values = list(values)
|
||||
|
||||
def get_current_value(self):
|
||||
return self.values.pop(0)
|
||||
|
||||
|
||||
def test_values_are_averaged_per_plateau_and_moves_are_skipped():
|
||||
x = Axis("x")
|
||||
det = Det("diode", [1, 3, 99, 10, 20, 30])
|
||||
rec = TweakRecorder([x], det, settle_samples=2)
|
||||
rec.sample() # first reading: not settled yet, detector not read
|
||||
rec.sample() # settled -> 1
|
||||
rec.sample() # -> 3
|
||||
x.value = 0.5 # moving
|
||||
rec.sample() # position changed: not read
|
||||
x.value = 1.0
|
||||
rec.sample() # moved again: not read
|
||||
rec.sample() # settled at 1.0 -> 99
|
||||
rec.sample() # -> 10
|
||||
pts = rec.points
|
||||
assert [p["positions"] for p in pts] == [[0.0], [1.0]]
|
||||
assert pts[0]["values"] == [2.0] and pts[0]["n_samples"] == 2
|
||||
assert pts[1]["values"] == [54.5]
|
||||
assert [p["step"] for p in pts] == [0, 1]
|
||||
|
||||
|
||||
def test_jitter_within_the_settle_tolerance_stays_on_one_plateau():
|
||||
x = Axis("x", step=1.0)
|
||||
rec = TweakRecorder([x], Det("d", [1, 2, 3, 4]), settle_fraction=0.1)
|
||||
for v in (0.0, 0.03, -0.04, 0.02, 0.05):
|
||||
x.value = v
|
||||
rec.sample()
|
||||
assert len(rec.points) == 1 and rec.points[0]["n_samples"] == 4
|
||||
x.step = 0.1 # smaller step -> tighter tolerance: 0.05 is now a new spot
|
||||
x.value = 0.3
|
||||
rec.sample()
|
||||
rec.sample()
|
||||
assert len(rec.points) == 2
|
||||
|
||||
|
||||
def test_failed_and_array_detector_reads():
|
||||
class Flaky:
|
||||
name = "flaky"
|
||||
|
||||
def __init__(self):
|
||||
self.n = 0
|
||||
|
||||
def get_current_value(self):
|
||||
self.n += 1
|
||||
if self.n == 1:
|
||||
raise RuntimeError("timeout")
|
||||
return None if self.n == 2 else [1.0, 3.0]
|
||||
|
||||
x = Axis("x")
|
||||
rec = TweakRecorder([x], [Flaky()], settle_samples=1)
|
||||
for _ in range(3):
|
||||
rec.sample()
|
||||
p = rec.points[0]
|
||||
assert p["n_samples"] == 3 and p["values"] == [2.0] # array -> its mean
|
||||
|
||||
|
||||
def test_multi_axis_points_and_arrays():
|
||||
x, y = Axis("x"), Axis("y", 5.0)
|
||||
rec = TweakRecorder([x, y], [Det("a", range(100)), Det("b", range(100))], settle_samples=1)
|
||||
rec.sample()
|
||||
y.value = 6.0
|
||||
rec.sample()
|
||||
steps, pos, val = rec.arrays()
|
||||
assert list(steps) == [0, 1]
|
||||
assert pos.tolist() == [[0.0, 5.0], [0.0, 6.0]]
|
||||
assert val.shape == (2, 2)
|
||||
|
||||
|
||||
def test_dataframe():
|
||||
pytest.importorskip("pandas")
|
||||
x = Axis("x")
|
||||
rec = TweakRecorder([x], Det("d", [1.0, 2.0]), settle_samples=1)
|
||||
rec.sample()
|
||||
rec.sample()
|
||||
df = rec.to_dataframe()
|
||||
assert list(df.columns) == ["step", "time", "n_samples", "x", "d", "d_std"]
|
||||
assert df["d"].tolist() == [1.5]
|
||||
|
||||
|
||||
def test_recorder_thread_starts_and_stops():
|
||||
import time
|
||||
|
||||
x = Axis("x")
|
||||
rec = TweakRecorder([x], Det("d", [1.0] * 1000), interval=0.01).start()
|
||||
time.sleep(0.15)
|
||||
rec.stop()
|
||||
assert rec.points and rec.points[0]["n_samples"] > 3
|
||||
|
||||
|
||||
# --- plots ---
|
||||
|
||||
|
||||
def _figure():
|
||||
pytest.importorskip("matplotlib")
|
||||
from matplotlib.backends.backend_agg import FigureCanvasAgg
|
||||
from matplotlib.figure import Figure
|
||||
|
||||
fig = Figure()
|
||||
FigureCanvasAgg(fig)
|
||||
return fig
|
||||
|
||||
|
||||
def test_one_axis_plots_each_detector_against_the_position():
|
||||
fig = _figure()
|
||||
x = Axis("x")
|
||||
rec = TweakRecorder([x], [Det("a", range(9)), Det("b", range(9))], settle_samples=1)
|
||||
for v in (0.0, 1.0, 2.0):
|
||||
x.value = v
|
||||
rec.sample()
|
||||
draw_tweak_plot(fig, rec)
|
||||
axs = fig.axes
|
||||
assert plot_rows(rec) == 2 and len(axs) == 2
|
||||
assert [a.get_ylabel() for a in axs] == ["a", "b"]
|
||||
assert axs[-1].get_xlabel() == "x"
|
||||
assert list(axs[0].lines[0].get_xdata()) == [0.0, 1.0, 2.0]
|
||||
|
||||
|
||||
def test_several_axes_stack_detectors_on_top_over_the_step_number():
|
||||
fig = _figure()
|
||||
axes = [Axis("x"), Axis("y"), Axis("z")]
|
||||
rec = TweakRecorder(axes, [Det("a", range(9))], settle_samples=1)
|
||||
for v in (0.0, 1.0):
|
||||
axes[1].value = v
|
||||
rec.sample()
|
||||
draw_tweak_plot(fig, rec)
|
||||
axs = fig.axes
|
||||
assert [a.get_ylabel() for a in axs] == ["a", "x", "y", "z"]
|
||||
assert axs[-1].get_xlabel() == "step"
|
||||
assert list(axs[2].lines[0].get_ydata()) == [0.0, 1.0]
|
||||
|
||||
|
||||
def test_empty_recorder_draws_without_points():
|
||||
fig = _figure()
|
||||
draw_tweak_plot(fig, TweakRecorder([Axis("x")], Det("a", [])))
|
||||
assert len(fig.axes) == 1
|
||||
|
||||
|
||||
# --- panels ---
|
||||
|
||||
|
||||
def test_qt_panel_embeds_a_live_plot():
|
||||
pytest.importorskip("qtpy")
|
||||
pytest.importorskip("matplotlib")
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
from qtpy import QtWidgets
|
||||
|
||||
from eco.widgets.tweak_panel_qt import TweakPanelQt
|
||||
|
||||
app = QtWidgets.QApplication.instance() or QtWidgets.QApplication([])
|
||||
x = Axis("x")
|
||||
rec = TweakRecorder([x], Det("d", [float(i) for i in range(10000)]), interval=0.01)
|
||||
panel = TweakPanelQt(tp.TweakController([x]), recorder=rec)
|
||||
try:
|
||||
assert panel.plot is not None and rec._thread is not None
|
||||
import time
|
||||
|
||||
time.sleep(0.1)
|
||||
panel.plot.refresh()
|
||||
assert panel.plot._drawn_version == rec.version or rec.version > 0
|
||||
assert panel.plot.figure.axes[0].get_ylabel() == "d"
|
||||
finally:
|
||||
panel.close()
|
||||
app.processEvents()
|
||||
assert rec._stop_event.is_set()
|
||||
|
||||
|
||||
def test_ipy_panel_renders_the_plot_as_png():
|
||||
pytest.importorskip("ipywidgets")
|
||||
pytest.importorskip("matplotlib")
|
||||
from eco.widgets.tweak_panel_ipy import TweakPanelIpy
|
||||
|
||||
x = Axis("x")
|
||||
rec = TweakRecorder([x], Det("d", [1.0, 2.0, 3.0]), settle_samples=1)
|
||||
rec.sample()
|
||||
panel = TweakPanelIpy(tp.TweakController([x]), recorder=rec, poll_interval=10)
|
||||
try:
|
||||
import time
|
||||
|
||||
for _ in range(100): # rendered by the panel's poll thread
|
||||
if len(panel.plot.value):
|
||||
break
|
||||
time.sleep(0.02)
|
||||
assert bytes(panel.plot.value[:4]) == b"\x89PNG"
|
||||
finally:
|
||||
panel.close()
|
||||
Reference in new issue
Block a user