This commit is contained in:
2025-11-17 20:13:19 +01:00
24 changed files with 2708 additions and 36 deletions
+49 -18
View File
@@ -136,7 +136,18 @@ class Daq(Assembly):
return self._pgroup.set_target_value().wait()
self._pgroup = value
def acquire(self, scan=None, run_number=None, Npulses=100, acq_pars={}, **kwargs):
def acquire(
self,
scan=None,
run_number=None,
Npulses=100,
acq_pars={},
pgroup=None,
**kwargs,
):
if pgroup is None:
pgroup = self.pgroup
acq_pars = {}
if scan:
acq_pars = {
@@ -171,6 +182,7 @@ class Daq(Assembly):
channels_BS=self.channels["channels_BS"].get_current_value(),
channels_BSCAM=self.channels["channels_BSCAM"].get_current_value(),
channels_CA=self.channels["channels_CA"].get_current_value(),
pgroup=pgroup,
**acq_pars,
)
acquisition.acquisition_kwargs.update({"file_names": response["files"]})
@@ -186,10 +198,15 @@ class Daq(Assembly):
return acquisition
def acquire_pulses(self, Npulses, label=None, wait=True, **kwargs):
def acquire_pulses(self, Npulses, label=None, wait=True, pgroup=None, **kwargs):
if pgroup is None:
pgroup = self.pgroup
ix = self.start(label=label, **kwargs)
return self.stop(
stop_id=self.running[ix]["start_id"] + Npulses - 1, acq_ix=ix, wait=wait
stop_id=self.running[ix]["start_id"] + Npulses - 1,
acq_ix=ix,
wait=wait,
pgroup=pgroup,
)
def start(self, label=None, scan=None, **kwargs):
@@ -246,7 +263,10 @@ class Daq(Assembly):
wait=True,
wait_cycle_sleep=0.01,
scan=None,
pgroup=None,
):
if pgroup is None:
pgroup = self.pgroup
if not stop_id:
stop_id = int(self.pulse_id.get_current_value())
@@ -257,6 +277,7 @@ class Daq(Assembly):
acq_pars = self.running.pop(acq_ix)
acq_pars["stop_id"] = stop_id
label = acq_pars.pop("label")
# if scan:
@@ -267,6 +288,7 @@ class Daq(Assembly):
while int(self.pulse_id.get_current_value()) < stop_id:
sleep(wait_cycle_sleep)
acq_pars["pgroup"] = pgroup
response = self.retrieve(**acq_pars)
# print(response)
@@ -278,7 +300,7 @@ class Daq(Assembly):
# correct file names to relative paths
if scan:
run_directory = list(
Path(f"/sf/bernina/data/{self.pgroup}/raw").glob(
Path(f"/sf/bernina/data/{pgroup}/raw").glob(
f"run{scan.daq_run_number:04d}*"
)
)[0].as_posix()
@@ -519,11 +541,13 @@ class Daq(Assembly):
if o == "c":
raise Exception("User-requested cancelling!")
def count_run_number_up_and_attach_to_scan(self, scan, **kwargs):
def count_run_number_up_and_attach_to_scan(self, scan, pgroup=None, **kwargs):
"""
Increments the run number by one.
"""
runno = self.get_next_run_number(self.pgroup)
if pgroup is None:
pgroup = self.pgroup
runno = self.get_next_run_number(pgroup)
print(f"Run number incremented to {runno}")
scan.daq_run_number = runno
@@ -618,9 +642,12 @@ class Daq(Assembly):
print("WARNING: issue adding data to run table")
print(f"Runtable appending took: {time.time()-t_start_rt:.3f} s")
def copy_scan_info_to_raw(self, scan, **kwargs):
def copy_scan_info_to_raw(self, scan, pgroup=None, **kwargs):
t_start = time.time()
if pgroup is None:
pgroup = self.pgroup
if hasattr(scan, "daq_run_number"):
runno = scan.daq_run_number
else:
@@ -635,7 +662,6 @@ class Daq(Assembly):
si = scan.scan_info
# save temprary file and send then to raw
pgroup = self.pgroup
tmpdir = Path(f"/sf/bernina/data/{pgroup}/res/run_data/daq/run{runno:04d}/aux")
tmpdir.mkdir(exist_ok=True, parents=True)
try:
@@ -673,7 +699,9 @@ class Daq(Assembly):
# f"--> creating and copying file took{time.time()-t_start} s, presently adding to deadtime."
# )
def append_status_to_scan_and_store(self, scan, append_status_info=True, **kwargs):
def append_status_to_scan_and_store(
self, scan, pgroup=None, append_status_info=True, **kwargs
):
if not append_status_info:
return
@@ -687,7 +715,8 @@ class Daq(Assembly):
else:
runno = self.get_last_run_number()
pgroup = self.pgroup
if pgroup is None:
pgroup = self.pgroup
tmpdir = Path(f"/sf/bernina/data/{pgroup}/res/run_data/daq/run{runno:04d}/aux")
tmpdir.mkdir(exist_ok=True, parents=True)
try:
@@ -751,14 +780,15 @@ class Daq(Assembly):
if not self.checker.stop_and_analyze():
scan._current_step_ok = False
def copy_aliases_to_scan(self, scan, send_aliases_now=False, **kwargs):
def copy_aliases_to_scan(self, scan, send_aliases_now=False, pgroup=None, **kwargs):
if send_aliases_now or (len(scan.values_done()) == 1):
namespace_aliases = self.namespace.alias.get_all()
if hasattr(scan, "daq_run_number"):
runno = scan.daq_run_number
else:
runno = self.daq.get_last_run_number()
pgroup = self.pgroup
if pgroup is None:
pgroup = self.pgroup
tmpdir = Path(
f"/sf/bernina/data/{pgroup}/res/run_data/daq/run{runno:04d}/aux"
)
@@ -881,7 +911,7 @@ class Daq(Assembly):
print(f"Could not add daq.pulse_id monitor")
traceback.print_exc()
def end_scan_monitors(self, scan, **kwargs):
def end_scan_monitors(self, scan, pgroup=None, **kwargs):
for tmon in scan.daq_monitors:
scan.daq_monitors[tmon].stop_callback()
@@ -895,7 +925,10 @@ class Daq(Assembly):
else:
runno = self.get_last_run_number()
tmpdir = Path(f"/sf/bernina/data/{self.pgroup}/res/run_data/daq/run{runno}/aux")
if pgroup is None:
pgroup = self.pgroup
tmpdir = Path(f"/sf/bernina/data/{pgroup}/res/run_data/daq/run{runno}/aux")
tmpdir.mkdir(exist_ok=True, parents=True)
try:
tmpdir.chmod(0o775)
@@ -906,11 +939,9 @@ class Daq(Assembly):
with open(scanmonitorfile, "wb") as f:
pickle.dump(monitor_result, f)
print(
f"Copying monitor file to run {runno} to the raw directory of {self.pgroup}."
)
print(f"Copying monitor file to run {runno} to the raw directory of {pgroup}.")
response = self.append_aux(
scanmonitorfile.as_posix(), pgroup=self.pgroup, run_number=runno
scanmonitorfile.as_posix(), pgroup=pgroup, run_number=runno
)
print(
f"Status: {response.json()['status']} Message: {response.json()['message']}"
+116 -3
View File
@@ -279,7 +279,7 @@ class StepScan(Assembly):
acs = []
for ctr in self.counters:
acq = ctr.acquire(
scan=self, Npulses=self.pulses_per_step[0]
scan=self, Npulses=self.pulses_per_step[0], **self.callbacks_kwargs
) # TODO make sure step-individual aquisition argument is possible.
acs.append(acq)
try:
@@ -295,7 +295,7 @@ class StepScan(Assembly):
else:
acs = []
for ctr in self.counters:
ctr.start(scan=self)
ctr.start(scan=self, **self.callbacks_kwargs)
try:
if hasattr(ctr, "name"):
statstr += f"{ctr.name}, "
@@ -305,7 +305,7 @@ class StepScan(Assembly):
filenames = []
for ctr in self.counters:
resp = ctr.stop(scan=self)
resp = ctr.stop(scan=self, **self.callbacks_kwargs)
filenames.extend(resp["files"])
statstr = statstr[:-2] + " done."
print(statstr, end="\n")
@@ -1022,6 +1022,119 @@ class Scans(Assembly):
return s
def scan(
self,
*adj_specs,
scanning_order="last_fastest",
N_pulses=None,
description="",
counters=[],
start_immediately=True,
return_at_end="timeout",
settling_time=0,
step_info=None,
**kwargs_callbacks,
):
"""
Most general scan, i.e. a scan of multiple adjustable in multiple dimensions, where the last adjustable is moved first.
The scanning order can be changed by setting the `scanning_order` parameter.
"""
adjustables = []
positions = []
for adj_spec in adj_specs:
# simultaneous scan
if all([isinstance(ts[0], Adjustable) for ts in adj_spec]):
s_adjustables = [ts[0] for ts in adj_spec]
s_positions = [interpret_step_specification(ts[1:]) for ts in adj_spec]
if not len(set(map(len, s_positions))) == 1:
raise Exception(
"Simultaneous scan adjustables must have the same number of step positions!"
)
adjustables.append(s_adjustables)
positions.append(np.asarray(s_positions).T)
# mesh scan
else:
adj = adj_spec[0]
spec = adj_spec[1:]
if isinstance(adj, Adjustable):
adjustables.append(adj)
positions.append(interpret_step_specification(spec))
shape = [len(tp) for tp in positions]
if scanning_order == "last_fastest":
index_plan = list(product(*[range(n) for n in shape]))
elif scanning_order == "fist_fastst":
index_plan = [tc[::-1] for tc in product(*[range(n) for n in shape][::-1])]
values = []
for ixs in index_plan:
for ti, tp in zip(ixs, positions):
tpos = tp[ti]
if np.iterable(tpos) and len(tpos) > 1:
for ttpos in tpos:
values.append(ttpos)
else:
values.append(tpos)
adjustables_names = []
for ta in adjustables:
if isinstance(ta, list):
tas = []
for tta in ta:
tas.append(
tta.alias.get_full_name() if hasattr(tta, "alias") else tta.name
)
adjustables_names.append(tas)
else:
adjustables_names.append(
ta.alias.get_full_name() if hasattr(tta, "alias") else tta.name
)
gridspecs = {
"shape": shape,
"positions": positions,
"index_plan": index_plan,
"adjustables": adjustables_names,
}
adjustables_flat = []
for ta in adjustables:
if isinstance(ta, list):
adjustables_flat.extend(ta)
else:
adjustables_flat.append(ta)
if not counters:
counters = self._default_counters
s = StepScan(
adjustables_flat,
values,
counters=counters,
Npulses=N_pulses,
description=description,
return_at_end=return_at_end,
settling_time=settling_time,
callbacks_start_scan=self.callbacks_start_scan,
callbacks_start_step=self.callbacks_start_step,
callbacks_end_step=self.callbacks_end_step,
callbacks_end_scan=self.callbacks_end_scan,
# elog=self._elog,
gridspecs=gridspecs,
name="acquiring_scan",
**kwargs_callbacks,
)
self._append(s, name="acquiring_scan", overwrite=True, delete_old=True)
if start_immediately:
s.scan_all(step_info=step_info)
return s
class RunFilenameGenerator:
def __init__(self, path, prefix="run", Ndigits=4, separator="_", suffix="json"):
+44 -14
View File
@@ -43,42 +43,61 @@ class StatusCollection:
self._list = []
def get_list(self, selection=None, **kwargs):
ls = kwargs.get("ls", [])
rec_list_items = kwargs.get("rec_list_items", [])
ls = []
for witem in self._list:
item = witem()
if item is None:
continue
if item is self.parent:
continue
if item in ls:
continue
if selection is not None:
if selection not in self.selections.keys():
continue
item_name = item.alias.get_full_name(base=self.parent())
if item_name not in self.selections[selection].keys():
continue
recurse = self.selections[selection][item_name]["recurse"]
else:
recurse = True
ls.append(
item
) # important to get field in case no recursion is defined.
ls.append(item)
# important to get field in case no recursion is defined.
if item is self.parent():
recurse = False
if hasattr(item, f"{self.name}") and isinstance(
item.__dict__[self.name], self.__class__
):
if recurse:
# if hasattr(item, "recursing") and item.recursing:
# print(
# f"recursing detected loop at {item.alias.get_full_name()}"
# )
# item.recursing = True
for titem in item.__dict__[self.name].get_list(
selection=selection, ls=ls
selection=selection, ls=[]
):
if titem not in ls:
ls.append(titem)
else:
if item not in ls:
ls.append(item)
else:
if item not in ls:
ls.append(item)
return ls
def get_names(self, selection=None):
@@ -97,7 +116,7 @@ class StatusCollection:
self.selections[selection] = {}
obj_name = obj.alias.get_full_name(base=self.parent())
self.selections[selection][obj_name] = {"recurse": recursive}
if obj not in self._list:
if obj not in [tl() for tl in self._list]:
self._list.append(weakref.ref(obj))
def remove(self, obj, selection=None):
@@ -148,8 +167,8 @@ class Assembly:
self.memory = memory.Memory(self)
if elog:
self.__elog = elog
else:
self.__class__.__elog = property(lambda dum: ELOG)
# else:
# self.__class__.__elog = property(lambda dum: ELOG)
# TODO: Lazy an threaded append! (for PVs, should be quite a speedup).
def _append(
@@ -160,11 +179,19 @@ class Assembly:
is_setting=False,
is_display=True,
is_status=True,
recursive=None,
# recursive=None,
call_obj=True,
overwrite=False,
**kwargs,
):
"""This hidden method appends an object to the assembly. It can take either an object instance, or a class (in which case it will be called with the provided args and kwargs).
Parameters
----------
foo_obj_init : Adjustable, Detector, Assembly, class, callable
The object to append, or a class/callable to instantiate.
name : str, optional
The name of the object within the assembly. If None, the name attribute of the object will be used.
is_setting : bool or str "recursive", optional"""
if overwrite:
if name in self.__dict__:
@@ -193,8 +220,6 @@ class Assembly:
if is_setting:
if isinstance(is_setting, str):
recursive = is_setting.lower() == "recursive"
elif not (recursive is None):
recursive = recursive
else:
recursive = True
self.status_collection.append(
@@ -206,8 +231,6 @@ class Assembly:
if is_display:
if isinstance(is_display, str):
recursive = is_display.lower() == "recursive"
elif not (recursive is None):
recursive = recursive
else:
recursive = False
self.status_collection.append(
@@ -334,7 +357,9 @@ class Assembly:
sel_dict = {}
for selection_name in selections:
sel = self.status_collection.get_names(selection=selection_name)
sel_dict[selection_name] = {tname: status[tname] for tname in sel}
sel_dict[selection_name] = {
tname: status[tname] for tname in sel if tname in status.keys()
}
return {
# "settings": settings,
@@ -521,6 +546,11 @@ class Assembly:
else:
return None
def widget(self):
from eco.widgets.display_widget import make_assembly_widget
return make_assembly_widget(self)
import epics.pv
import time
@@ -0,0 +1,9 @@
MIT License
Copyright (c) 2023 [Your Name]
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
1. The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
2. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@@ -0,0 +1,64 @@
# textual-status-editor
This project implements a textual user interface for displaying and managing the status of various items in an assembly. It provides a tabular view of current values and allows users to set target values for these items.
## Features
- Display current values of status items in a tabular format.
- Entry fields for setting target values of status items.
- Interactive user interface built with the Textual library.
## Project Structure
```
textual-status-editor
├── src
│ └── textual_status_editor
│ ├── __init__.py
│ ├── app.py
│ ├── main.py
│ ├── config.py
│ ├── adapters
│ │ └── assembly_adapter.py
│ ├── models
│ │ └── status_item.py
│ └── widgets
│ ├── __init__.py
│ └── status_table.py
├── tests
│ ├── test_status_table.py
│ └── test_assembly_adapter.py
├── pyproject.toml
├── requirements.txt
├── README.md
├── .gitignore
└── LICENSE
```
## Installation
1. Clone the repository:
```
git clone https://github.com/yourusername/textual-status-editor.git
cd textual-status-editor
```
2. Install the required dependencies:
```
pip install -r requirements.txt
```
## Usage
To run the application, execute the following command:
```
python -m textual_status_editor.main
```
## Contributing
Contributions are welcome! Please feel free to submit a pull request or open an issue for any suggestions or improvements.
## License
This project is licensed under the MIT License. See the LICENSE file for more details.
@@ -0,0 +1,23 @@
[tool.poetry]
name = "textual-status-editor"
version = "0.1.0"
description = "A textual tabular widget to display and edit status values."
authors = ["Your Name <youremail@example.com>"]
license = "MIT"
readme = "README.md"
homepage = "https://github.com/yourusername/textual-status-editor"
repository = "https://github.com/yourusername/textual-status-editor"
keywords = ["textual", "status", "editor", "widget"]
[tool.poetry.dependencies]
python = "^3.8"
textual = "^0.1.0" # Replace with the actual version you want to use
rich = "^10.0.0" # For rich text formatting in the terminal
[tool.poetry.dev-dependencies]
pytest = "^6.0"
pytest-cov = "^2.10"
[build-system]
requires = ["poetry-core>=1.0.0"]
build-backend = "poetry.core.masonry.api"
@@ -0,0 +1,5 @@
textual
rich
pytest
pytest-asyncio
textual-widget
@@ -0,0 +1 @@
# This file is intentionally left blank.
@@ -0,0 +1,61 @@
from eco.elements.protocols import Assembly
from textual.app import App
from textual.widgets import Table, Input
from textual.reactive import Reactive
from textual.containers import Container
class StatusItem:
def __init__(self, name, current_value):
self.name = name
self.current_value = current_value
def set_target_value(self, value):
# Placeholder for setting the target value
self.current_value = value
class AssemblyAdapter:
def __init__(self, assembly: Assembly):
self.assembly = assembly
def get_status_items(self):
status_items = []
for item in self.assembly.status_collection.get_list():
current_value = item.get_current_value() if hasattr(item, 'get_current_value') else None
status_items.append(StatusItem(item.alias.get_full_name(), current_value))
return status_items
class StatusTable(App):
def __init__(self, assembly_adapter: AssemblyAdapter):
super().__init__()
self.assembly_adapter = assembly_adapter
self.status_items = self.assembly_adapter.get_status_items()
async def on_mount(self):
self.table = Table()
self.table.add_column("Name")
self.table.add_column("Current Value")
self.table.add_column("Set Target Value")
for item in self.status_items:
input_field = Input(placeholder="Enter value")
input_field.on_submit(lambda value, item=item: self.set_target_value(item, value))
self.table.add_row(item.name, str(item.current_value), input_field)
await self.view.dock(self.table)
def set_target_value(self, item: StatusItem, value: str):
item.set_target_value(value)
self.refresh_table()
def refresh_table(self):
self.table.clear()
for item in self.status_items:
input_field = Input(placeholder="Enter value")
input_field.on_submit(lambda value, item=item: self.set_target_value(item, value))
self.table.add_row(item.name, str(item.current_value), input_field)
if __name__ == "__main__":
assembly = Assembly() # Replace with actual assembly initialization
adapter = AssemblyAdapter(assembly)
app = StatusTable(adapter)
app.run()
@@ -0,0 +1,45 @@
from textual.app import App
from textual.widgets import Static, Input, Table
from textual.reactive import Reactive
from textual import events
from .adapters.assembly_adapter import AssemblyAdapter
class StatusItem:
def __init__(self, name, current_value):
self.name = name
self.current_value = current_value
def set_target_value(self, value):
# Logic to set the target value
pass
class StatusTable(Table):
def __init__(self, items):
super().__init__()
self.items = items
self.add_column("Name", min_width=20)
self.add_column("Current Value", min_width=20)
self.add_column("Set Target Value", min_width=20)
for item in self.items:
self.add_row(item.name, str(item.current_value), Input(placeholder="Set value"))
async def on_input_changed(self, event: events.InputChanged):
# Logic to handle input changes
row_index = self.get_row_index(event.sender)
if row_index is not None:
item = self.items[row_index]
item.set_target_value(event.sender.value)
class StatusEditorApp(App):
def __init__(self):
super().__init__()
self.adapter = AssemblyAdapter()
self.status_items = self.adapter.get_status_items()
async def on_mount(self):
self.table = StatusTable(self.status_items)
await self.view.dock(self.table)
if __name__ == "__main__":
StatusEditorApp.run()
@@ -0,0 +1,10 @@
# Configuration settings for the textual status editor application
# Layout parameters
TABLE_WIDTH = 80
TABLE_HEIGHT = 20
ENTRY_WIDTH = 10
# Constants
APP_TITLE = "Textual Status Editor"
STATUS_COLLECTION_NAME = "status_collection"
@@ -0,0 +1,52 @@
from textual.app import App
from textual.widgets import Static, Input, Table
from textual.reactive import Reactive
from textual import events
from .adapters.assembly_adapter import AssemblyAdapter
class StatusItem:
def __init__(self, name, current_value):
self.name = name
self.current_value = current_value
def set_target_value(self, value):
# Logic to set the target value
self.current_value = value
class StatusTable(Table):
def __init__(self, items):
super().__init__()
self.items = items
self.add_column("Name", min_width=20)
self.add_column("Current Value", min_width=20)
self.add_column("Set Target Value", min_width=20)
for item in self.items:
self.add_row(item.name, str(item.current_value), "")
async def on_input_changed(self, event: events.InputChanged):
row_index = event.row_index
column_index = event.column_index
if column_index == 2: # Assuming the third column is for setting target values
target_value = event.value
self.items[row_index].set_target_value(target_value)
self.update_row(row_index)
def update_row(self, row_index):
item = self.items[row_index]
self.update_row(row_index, item.name, str(item.current_value), "")
class StatusEditorApp(App):
def __init__(self):
super().__init__()
self.adapter = AssemblyAdapter()
self.status_items = self.adapter.get_status_items()
self.table = StatusTable(self.status_items)
async def on_mount(self):
await self.view.dock(self.table)
if __name__ == "__main__":
app = StatusEditorApp()
app.run()
@@ -0,0 +1,60 @@
from textual import events
from textual.widget import Widget
from textual.reactive import Reactive
from textual.containers import Container
from textual.widgets import Input, Table
class StatusItem:
def __init__(self, name, current_value):
self.name = name
self.current_value = current_value
def set_target_value(self, value):
# Logic to set the target value
self.current_value = value
class StatusItemWidget(Widget):
name: str
current_value: Reactive[str] = Reactive("")
def __init__(self, status_item: StatusItem):
super().__init__()
self.status_item = status_item
self.name = status_item.name
self.current_value = str(status_item.current_value)
def render(self):
return f"{self.name}: {self.current_value}"
async def on_input_changed(self, event: events.InputChanged):
if event.input.value:
self.status_item.set_target_value(event.input.value)
self.current_value = event.input.value
await self.refresh()
class StatusItemTable(Widget):
def __init__(self, status_items):
super().__init__()
self.status_items = status_items
self.table = Table()
def render(self):
self.table.clear()
self.table.add_column("Item Name")
self.table.add_column("Current Value")
self.table.add_column("Set Value")
for item in self.status_items:
row = [item.name, str(item.current_value), Input(placeholder="Set value")]
self.table.add_row(*row)
return Container(self.table)
async def on_input_changed(self, event: events.InputChanged):
for item in self.status_items:
if event.input.value:
item.set_target_value(event.input.value)
await self.refresh()
@@ -0,0 +1 @@
# This file is intentionally left blank.
@@ -0,0 +1,50 @@
from textual.app import App
from textual.widgets import Table, Input
from textual.reactive import Reactive
from textual.containers import Container
from eco.elements.protocols import Detector # Assuming Detector is imported from the correct module
from .assembly_adapter import AssemblyAdapter # Import the AssemblyAdapter
class StatusItem:
def __init__(self, name, current_value):
self.name = name
self.current_value = current_value
def set_target_value(self, value):
# Logic to set the target value
pass
class StatusTable(App):
status_items: Reactive[list[StatusItem]] = Reactive([])
def __init__(self, assembly_adapter: AssemblyAdapter):
super().__init__()
self.assembly_adapter = assembly_adapter
async def on_mount(self):
self.status_items = await self.assembly_adapter.get_status_items()
self.render_table()
def render_table(self):
table = Table(title="Status Table")
table.add_column("Name", justify="left")
table.add_column("Current Value", justify="right")
table.add_column("Set Target Value", justify="right")
for item in self.status_items:
input_field = Input(placeholder="Enter value", on_submit=self.set_value(item))
table.add_row(item.name, str(item.current_value), input_field)
self.set_widget(table)
async def set_value(self, item: StatusItem, value: str):
item.set_target_value(value)
await self.assembly_adapter.update_status_item(item)
def set_widget(self, widget):
container = Container(widget)
self.set_root(container)
if __name__ == "__main__":
assembly_adapter = AssemblyAdapter() # Initialize your adapter here
StatusTable(assembly_adapter).run()
@@ -0,0 +1,56 @@
import pytest
from textual_status_editor.adapters.assembly_adapter import AssemblyAdapter
from textual_status_editor.models.status_item import StatusItem
@pytest.fixture
def assembly_adapter():
return AssemblyAdapter()
def test_get_current_values(assembly_adapter):
# Mock the status collection to return predefined values
assembly_adapter.status_collection = [
StatusItem(name="Item1", current_value=10),
StatusItem(name="Item2", current_value=20),
]
current_values = assembly_adapter.get_current_values()
assert current_values == {
"Item1": 10,
"Item2": 20,
}
def test_set_target_value(assembly_adapter):
# Mock the status item
item = StatusItem(name="Item1", current_value=10)
assembly_adapter.status_collection = [item]
assembly_adapter.set_target_value("Item1", 15)
assert item.target_value == 15
def test_set_target_value_nonexistent_item(assembly_adapter):
# Mock the status collection
assembly_adapter.status_collection = [
StatusItem(name="Item1", current_value=10),
]
result = assembly_adapter.set_target_value("NonexistentItem", 15)
assert result is False # Expecting failure when item does not exist
def test_update_status_item(assembly_adapter):
item = StatusItem(name="Item1", current_value=10)
assembly_adapter.status_collection = [item]
assembly_adapter.update_status_item("Item1", 20)
assert item.current_value == 20
def test_update_status_item_nonexistent(assembly_adapter):
item = StatusItem(name="Item1", current_value=10)
assembly_adapter.status_collection = [item]
result = assembly_adapter.update_status_item("NonexistentItem", 20)
assert result is False # Expecting failure when item does not exist
@@ -0,0 +1,36 @@
import pytest
from textual_status_editor.widgets.status_table import StatusTable
from textual_status_editor.models.status_item import StatusItem
@pytest.fixture
def status_items():
return [
StatusItem(name="Item 1", current_value=10),
StatusItem(name="Item 2", current_value=20),
StatusItem(name="Item 3", current_value=30),
]
def test_status_table_display(status_items):
table = StatusTable(status_items)
rendered = table.render()
assert "Item 1" in rendered
assert "10" in rendered
assert "Item 2" in rendered
assert "20" in rendered
assert "Item 3" in rendered
assert "30" in rendered
def test_status_table_set_target_value(status_items):
table = StatusTable(status_items)
table.set_target_value("Item 1", 15)
assert status_items[0].current_value == 15
def test_status_table_invalid_target_value(status_items):
table = StatusTable(status_items)
table.set_target_value("Item 4", 25) # Non-existent item
assert status_items[0].current_value == 10 # Should remain unchanged
assert status_items[1].current_value == 20
assert status_items[2].current_value == 30
+170
View File
@@ -0,0 +1,170 @@
#!/usr/bin/env python
# -*- coding: latin-1 -*-
import scipy
from scipy.stats import skew
from scipy.interpolate import interp1d
# from numpy import *
from pylab import *
import config
def PeakAnalysis(x,y,nb=3,plotpoints=False):
""" nb = number of point (on each side) to use as background"""
## get background
xb = hstack((x[0:nb],x[-(nb):]))
yb = hstack((y[0:nb],y[-(nb):]))
a = polyfit(xb,yb,1)
b = polyval(a,x)
yf = y-b
yd = diff(yf)
## determine whether peak or step
ispeak = abs(skew(yf))>abs(skew(yd))
if ispeak:
yw = yf
xw = x
else:
yw = yd
xw = (x[1:]+x[0:-1])/2
## get background
xwb = hstack((xw[0:nb],xw[-(nb):]))
ywb = hstack((yw[0:nb],yw[-(nb):]))
aw = polyfit(xwb,ywb,1)
bw = polyval(aw,xw)
yw = yw-bw
Iw = (xw[1:]-xw[0:-1])*(yw[1:]+yw[0:-1])/2
if sum(Iw)<0:
yw = -yw
## get parameters
mm = yw.argmax(0)
PEAK = xw[mm]
ywmax = yw[mm]
gg = (yw[:mm][::-1]<(ywmax/2)).argmax()
ip = interp1d(yw.take([mm-gg-1,mm-gg]),xw.take([mm-gg-1,mm-gg]),kind='linear')
xhm1 = ip(ywmax/2)
gg = (yw[mm:]<(ywmax/2)).argmax()
ip = interp1d(yw.take([mm+gg,mm+gg-1]),xw.take([mm+gg,mm+gg-1]),kind='linear')
xhm2 = ip(ywmax/2)
FWHM = abs(xhm2-xhm1)
CEN = (xhm2+xhm1)/2
if plotpoints and ispeak is True:
# plot the found points for center and FWHM edges
ion()
hold(True)
plot(x,b,'g--')
plot(x,b+ywmax,'g--')
plot([xhm1,xhm1],polyval(a,xhm1)+[0,ywmax],'g--')
plot([xhm2,xhm2],polyval(a,xhm2)+[0,ywmax],'g--')
plot([CEN,CEN],polyval(a,CEN)+[0,ywmax],'g--')
plot([xhm1,xhm2],[polyval(a,xhm1),polyval(a,xhm2)]+ywmax/2,'gx')
draw()
if (config.DEBUG):
print "is peak ? %d" % ispeak
if not ispeak:
# findings start of step coming from left.
std0 = scipy.std(y[0:nb])
nt = nb
while (scipy.std(y[0:nt])<(2*std0)) and (nt<len(y)):
nt = nt+1
lev0 = scipy.mean(y[0:nt])
# findings start of step coming from right.
std0 = scipy.std(y[-nb:])
nt = nb
while (scipy.std(y[-nt:])<(2*std0)) and (nt<len(y)):
nt = nt+1
lev1 = scipy.mean(y[-nt:])
gg = abs(y-((lev0+lev1)/2)).argmin()
ftx = y[gg-2:gg+2]
fty = x[gg-2:gg+2]
if ftx[-1]<ftx[0]:
ftx = ftx[::-1]
fty = fty[::-1]
ip = interp1d(ftx,fty,kind='linear')
CEN = ip((lev0+lev1)/2)
gg = abs(y-(lev1+(lev0-lev1)*0.1195)).argmin()
ftx = y[gg-2:gg+2]
fty = x[gg-2:gg+2]
if ftx[-1]<ftx[0]:
ftx = ftx[::-1]
fty = fty[::-1]
#print " %f %f %f %f %f" % (ftx[0],ftx[1],fty[0],fty[1],lev1+(lev0-lev1)*0.1195)
ip = interp1d(ftx,fty,kind='linear')
H1 = ip((lev1+(lev0-lev1)*0.1195))
#print "H1=%f" % H1
gg = abs(y-(lev0+(lev1-lev0)*0.1195)).argmin()
ftx = y[gg-2:gg+2]
fty = x[gg-2:gg+2]
if ftx[-1]<ftx[0]:
ftx = ftx[::-1]
fty = fty[::-1]
# print " %f %f %f %f %f" % (ftx[0],ftx[1],fty[0],fty[1],lev0+(lev1-lev0)*0.1195)
ip = interp1d(ftx,fty,kind='linear')
H2 = ip((lev0+(lev1-lev0)*0.1195))
#print "H2=%f" % abs(H2-H1)
FWHM = abs(H2-H1)
if plotpoints is True:
# plot the found points for center and FWHM edges
ion()
hold(True)
plot([x.min(),x.max()],[lev0,lev0],'g--')
plot([x.min(),x.max()],[lev1,lev1],'g--')
plot([H2,H2],[lev0,lev1],'g--')
plot([H1,H1],[lev0,lev1],'g--')
plot([CEN,CEN],[lev0,lev1],'g--')
plot([H2,CEN,H1],[lev0+(lev1-lev0)*0.1195,(lev1+lev0)/2,lev1+(lev0-lev1)*0.1195],'gx')
draw()
return (CEN,FWHM,PEAK)
if (__name__ == "__main__"):
x = arange(-10,10,1)
sig = 2
y = .00*x+exp(-x**2/2/sig**2)+multiply(0.15,(rand(x.shape[0])-0.5))
x = x+22.473
nb = 3
(CEN,FWHM,PEAK) = PeakAnalysis(x,y,nb)
## plotting results
figh = figure(10)
plot(x,y,'k.-')
tit = 'CEN=%f, FWHM=%f, PEAK=%f' %(CEN,FWHM,PEAK)
title(tit)
figh.canvas.set_window_title('Scan analysis')
show()
## make step function
yy = ones(x.shape[0]-1)
for ii in (range(x.shape[0])[1:]):
yy[ii-1] = sum(y[:ii])
xx=x[1:];
x=xx
y=yy
(CEN,FWHM,PEAK) = PeakAnalysis(x,y,nb)
## plotting results
fig = figure(11)
plot(x,y,'k.-')
tit = 'CEN=%f, FWHM=%f, PEAK=%f' %(CEN,FWHM,PEAK)
title(tit)
fig.canvas.set_window_title('Scan analysis')
show()
+2 -1
View File
@@ -37,7 +37,7 @@ def getDefaultElogInstance(
print(f"Found more than one elog for user group {pgroup}")
for lb in lbs:
creater = lb.createdBy
if creater == 'scilog-admin@psi.ch':
if creater == "scilog-admin@psi.ch":
log.select_logbook(lb)
print(f"Choosing default logbook created by 'scilog-admin@psi.ch'")
else:
@@ -78,6 +78,7 @@ class Elog(Assembly):
self,
*args,
tags=[],
pgroups=None,
text_encoding="markdown",
markdown_extensions=["fenced_code"],
**kwargs,
+331
View File
@@ -0,0 +1,331 @@
import threading
import time
import inspect
from typing import Any, Set
import ipywidgets as widgets
from IPython.display import display
# try to import eco types used for identification
try:
import eco
from eco import Adjustable, Detector
from eco.elements.assembly import Assembly
except Exception:
# Fallback names if eco is not importable in test environment
Adjustable = None
Detector = None
Assembly = None
def _safe_get_value(obj: Any):
"""Try a few common getters used in eco for adjustables/detectors."""
for fn in ("get_current_value", "get", "__call__", "value", "get_value"):
try:
attr = getattr(obj, fn)
if callable(attr):
return attr()
else:
return attr
except Exception:
pass
# try attribute 'current_value' or 'current' etc.
for name in ("current_value", "current", "_value", "val"):
if hasattr(obj, name):
try:
return getattr(obj, name)
except Exception:
pass
return None
def _safe_set_value(obj: Any, val):
"""Try a few common setters used in eco for adjustables."""
# try common setter names
for fn in ("set_target_value", "mv", "mvr", "set", "set_value", "put", "write"):
try:
fnobj = getattr(obj, fn)
if callable(fnobj):
return fnobj(val)
except Exception:
pass
# try attribute assignment if has property 'value'
if hasattr(obj, "value"):
try:
setattr(obj, "value", val)
return True
except Exception:
pass
raise RuntimeError("No known setter found on object")
class _ItemWidget:
"""Internal container for one monitored item (adjustable/detector/assembly leaf)."""
def __init__(self, name: str, obj: Any, base):
self.name = name
self.obj = obj
self.base = base
self.type_label = widgets.Label(
value=type(obj).__name__, layout=widgets.Layout(width="160px")
)
self.name_label = widgets.Label(
value=name, layout=widgets.Layout(width="260px")
)
self.value_label = widgets.Label(
value="", layout=widgets.Layout(flex="1 1 auto")
)
self.refresh_btn = widgets.Button(
description="Refresh", layout=widgets.Layout(width="70px")
)
self.refresh_btn.on_click(lambda _: self.refresh())
# tweak controls for adjustables
self.tweak_text = widgets.Text(
placeholder="new value", layout=widgets.Layout(width="180px")
)
self.set_btn = widgets.Button(
description="Set", button_style="info", layout=widgets.Layout(width="60px")
)
self.set_btn.on_click(lambda _: self._on_set())
# assemble
if _is_adjustable(obj):
controls = [
self.name_label,
self.type_label,
self.value_label,
self.tweak_text,
self.set_btn,
]
elif _is_detector(obj):
controls = [
self.name_label,
self.type_label,
self.value_label,
self.refresh_btn,
]
else:
controls = [
self.name_label,
self.type_label,
self.value_label,
self.refresh_btn,
]
self.widget = widgets.HBox(
controls, layout=widgets.Layout(align_items="center", width="100%")
)
self.refresh()
def _on_set(self):
txt = self.tweak_text.value
if txt == "":
return
cur = _safe_get_value(self.obj)
# try to coerce type based on current value
try:
if isinstance(cur, bool):
v = txt.lower() in ("1", "true", "yes", "on")
elif isinstance(cur, int):
v = int(txt)
elif isinstance(cur, float):
v = float(txt)
else:
# fallback: try json-like parsing, else string
import json
try:
v = json.loads(txt)
except Exception:
v = txt
except Exception:
v = txt
try:
_safe_set_value(self.obj, v)
# auto refresh after set
time.sleep(0.01)
self.refresh()
except Exception as e:
self.value_label.value = f"Set failed: {e}"
def refresh(self):
try:
val = _safe_get_value(self.obj)
self.value_label.value = repr(val)
except Exception as e:
self.value_label.value = f"Err: {e}"
def _is_adjustable(obj):
if Adjustable is not None and isinstance(obj, Adjustable):
return True
# heuristics
return any(hasattr(obj, n) for n in ("mv", "set_target_value", "mvr", "set"))
def _is_detector(obj):
if Detector is not None and isinstance(obj, Detector):
return True
return any(hasattr(obj, n) for n in ("get_current_value", "get"))
class AssemblyBrowser:
def __init__(self, assembly, refresh_interval: float = 1.0, expand_level: int = 10):
"""
assembly: an Assembly instance or namespace-like object that has .status_collection
refresh_interval: seconds between auto-refresh cycles when enabled
expand_level: max recursion depth
"""
self.root = assembly
self.refresh_interval = refresh_interval
self.expand_level = expand_level
self._widgets = [] # list of _ItemWidget
self._visited = set()
self._timer = None
self._running = False
# controls
self.type_filter = widgets.Dropdown(
options=["All", "Adjustable", "Detector", "Assembly"],
value="All",
description="Type:",
)
self.refresh_toggle = widgets.ToggleButton(
value=False, description="Live", tooltip="Toggle live refresh"
)
self.refresh_btn = widgets.Button(description="Refresh now")
self.refresh_btn.on_click(lambda _: self.refresh_all())
self.refresh_toggle.observe(self._on_toggle, "value")
self.header = widgets.HBox(
[self.type_filter, self.refresh_toggle, self.refresh_btn]
)
# container
self.content = widgets.VBox()
self.top = widgets.VBox([self.header, self.content])
# build UI
self._build_tree()
def _on_toggle(self, change):
if change["new"]:
self.start()
else:
self.stop()
def start(self):
if not self._running:
self._running = True
self._schedule()
def stop(self):
self._running = False
if self._timer:
self._timer.cancel()
self._timer = None
def _schedule(self):
if not self._running:
return
self.refresh_all()
self._timer = threading.Timer(self.refresh_interval, self._schedule)
self._timer.daemon = True
self._timer.start()
def _build_tree(self):
self._visited = set()
nodes = self._build_children(self.root, depth=0, base=self.root)
self.content.children = nodes
def _build_children(self, obj, depth: int, base) -> list:
"""Return list of widgets for items in obj.status_collection (non-blocking)."""
if depth > self.expand_level:
return [widgets.Label(value="... max depth reached ...")]
out_widgets = []
# try to access status_collection.get_list()
try:
sc = getattr(obj, "status_collection", None)
if sc is None:
return [widgets.Label(value=f"No status_collection on {obj}")]
members = sc.get_list()
except Exception:
# fallback: try dir(obj) members
members = []
for n, v in inspect.getmembers(obj):
if n.startswith("_"):
continue
members.append(v)
# convert weakref refs etc to concrete objects and names
for member in members:
try:
# member might be weakref, or actual object
if hasattr(member, "__call__") and not isinstance(member, type):
pass
# get alias name if available
name = getattr(member, "alias", None)
if name is not None and hasattr(name, "get_full_name"):
item_name = name.get_full_name(base=base)
else:
# fallback use object's __name__ or repr
item_name = (
getattr(member, "name", None)
or getattr(member, "__name__", None)
or repr(member)
)
except Exception:
item_name = repr(member)
# detect assembly-like objects (heuristic by attribute)
is_assembly = hasattr(member, "status_collection") and hasattr(
member, "alias"
)
if is_assembly:
# optionally show as Accordion with recursive children
if self.type_filter.value in ("All", "Assembly"):
acc = widgets.Accordion(
children=[
widgets.VBox(
self._build_children(member, depth + 1, base=member)
)
]
)
acc.set_title(0, item_name)
out_widgets.append(acc)
else:
# leaf item
add = False
if self.type_filter.value == "All":
add = True
elif self.type_filter.value == "Adjustable" and _is_adjustable(member):
add = True
elif self.type_filter.value == "Detector" and _is_detector(member):
add = True
if add:
itw = _ItemWidget(item_name, member, base)
self._widgets.append(itw)
out_widgets.append(itw.widget)
if not out_widgets:
return [widgets.Label(value="(no items)")]
return out_widgets
def refresh_all(self):
for it in list(self._widgets):
try:
it.refresh()
except Exception:
pass
def widget(self):
return self.top
def show_assembly_browser(
assembly, refresh_interval: float = 1.0, expand_level: int = 10
):
"""Convenience function to create, start and return the browser widget."""
br = AssemblyBrowser(
assembly, refresh_interval=refresh_interval, expand_level=expand_level
)
return br.widget()
+496
View File
@@ -0,0 +1,496 @@
"""
PySimpleGUI/Tkinter based GUI for Assembly display items.
Usage (blocking):
from eco.widgets.display_tk import DisplayTk
gui = DisplayTk(my_assembly, poll_interval=1.0)
gui.run() # runs the GUI event loop (blocking)
Usage (non-blocking from notebook/script):
gui = DisplayTk(my_assembly, poll_interval=1.0)
gui.start() # spawns GUI loop in a background thread
...
gui.stop() # stops polling and closes window
Behavior:
- Shows name / current value for each display_collection item.
- If item is Detector and not Adjustable -> read-only label.
- If item is Adjustable:
- numeric -> step field + up/down + value entry + Set button
- enum (AdjustableEnum) -> Combo with options
- non-numeric -> text entry + Set button
- If item supports MonitorableValueUpdate, attempts to register a callback
via common setter names so value label is updated by callback instead of polling.
"""
import threading
import time
import traceback
from typing import Any, Dict, List, Optional
try:
import PySimpleGUI as sg
except Exception as e:
raise RuntimeError("PySimpleGUI is required for this module") from e
# Try to import eco types for isinstance checks
try:
from eco import Adjustable, Detector, AdjustableEnum, MonitorableValueUpdate
except Exception: # fall back to object so isinstance checks are safe
Adjustable = object
Detector = object
AdjustableEnum = object
MonitorableValueUpdate = object
def _label_of(item: Any, assembly=None) -> str:
try:
if hasattr(item, "alias") and hasattr(item.alias, "get_full_name"):
return (
item.alias.get_full_name(base=assembly)
if assembly is not None
else item.alias.get_full_name()
)
except Exception:
pass
try:
if hasattr(item, "name"):
return str(item.name)
except Exception:
pass
return str(item)
def _get_enum_options(item) -> Optional[List]:
for attr in ("choices", "options", "allowed_values", "values", "enum_values"):
opts = getattr(item, attr, None)
if opts:
try:
return list(opts)
except Exception:
return opts
for meth in ("get_choices", "get_options", "allowed_values"):
fn = getattr(item, meth, None)
if callable(fn):
try:
return list(fn())
except Exception:
try:
return fn()
except Exception:
pass
return None
class DisplayTk:
def __init__(self, assembly, poll_interval: float = 1.0, auto_start: bool = False):
"""
assembly: assembly instance with a display collection (assembly.display_collection() or selection "display")
poll_interval: seconds between polls for non-monitorable items
auto_start: if True start the GUI loop in a background thread on init
"""
self.assembly = assembly
self.poll_interval = poll_interval
self.window = None
self._stop_event = threading.Event()
self._poll_thread = None
self._gui_thread = None
self._monitorables = set()
self._items = [] # list of item descriptors
self._build_layout()
if auto_start:
self.start()
def _get_display_items(self):
try:
return list(self.assembly.display_collection())
except Exception:
try:
return list(
self.assembly.status_collection.get_list(selection="display")
)
except Exception:
return []
def _build_layout(self):
# headers
header = [
sg.Text("name", size=(40, 1)),
sg.Text("current", size=(30, 1)),
sg.Text("control", size=(40, 1)),
]
rows = [header, [sg.HorizontalSeparator()]]
items = self._get_display_items()
for item in items:
name = _label_of(item, assembly=self.assembly)
key_val = f"VAL::{name}"
key_input = f"IN::{name}"
key_step = f"STEP::{name}"
key_up = f"UP::{name}"
key_down = f"DOWN::{name}"
key_set = f"SET::{name}"
key_combo = f"COMBO::{name}"
# initial current value (best-effort)
try:
cur = item.get_current_value()
except Exception:
cur = "<error>"
# build control depending on type
control_elems = []
# Detector (non-Adjustable): read-only
if isinstance(item, Detector) and not isinstance(item, Adjustable):
control_elems = [sg.Text("read-only (Detector)")]
# Adjustable
elif isinstance(item, Adjustable):
# enum
if isinstance(item, AdjustableEnum):
opts = _get_enum_options(item) or []
# coerce to strings for display but keep values in values list
combo = sg.Combo(
values=[str(o) for o in opts],
default_value=(
str(cur)
if cur is not None
else (str(opts[0]) if opts else "")
),
key=key_combo,
size=(20, 1),
)
control_elems = [combo, sg.Button("Set", key=key_set)]
else:
# numeric?
if isinstance(cur, (int, float)) and not isinstance(cur, bool):
step_input = sg.Input(
default_text=str(1 if isinstance(cur, int) else 0.1),
size=(8, 1),
key=key_step,
)
up = sg.Button("", key=key_up)
down = sg.Button("", key=key_down)
val_in = sg.Input(
default_text=str(cur), size=(12, 1), key=key_input
)
set_btn = sg.Button("Set", key=key_set)
control_elems = [step_input, up, down, val_in, set_btn]
else:
# non-numeric adjustable: text input + set
val_in = sg.Input(
default_text=str(cur), size=(20, 1), key=key_input
)
set_btn = sg.Button("Set", key=key_set)
control_elems = [val_in, set_btn]
# fallback: if has set_target_value, allow text entry
elif hasattr(item, "set_target_value") and callable(
getattr(item, "set_target_value")
):
val_in = sg.Input(default_text=str(cur), size=(20, 1), key=key_input)
set_btn = sg.Button("Set", key=key_set)
control_elems = [val_in, set_btn]
else:
control_elems = [sg.Text("")]
# row: name label, current value label, control elements
row = [
sg.Text(name, size=(40, 1)),
sg.Text(str(cur), size=(30, 1), key=key_val),
sg.Column([control_elems], pad=(0, 0)),
]
rows.append(row)
# store descriptor
self._items.append(
{
"item": item,
"name": name,
"key_val": key_val,
"key_input": key_input,
"key_step": key_step,
"key_up": key_up,
"key_down": key_down,
"key_set": key_set,
"key_combo": key_combo,
}
)
# register monitorable callbacks if provided
if isinstance(item, MonitorableValueUpdate):
self._monitorables.add(item)
cb_setter = None
for setter_name in (
"set_current_value_update",
"set_value_update_callback",
"on_value_update",
):
if hasattr(item, setter_name) and callable(
getattr(item, setter_name)
):
cb_setter = getattr(item, setter_name)
break
if cb_setter:
def make_cb(k):
def _cb(val):
try:
# push to GUI thread safely
if self.window is not None:
self.window.write_event_value(("MB_UPDATE", k), val)
except Exception:
pass
return _cb
try:
cb_setter(make_cb(key_val))
except Exception:
# ignore registration failures
pass
# Add a Close button row
rows.append([sg.HorizontalSeparator()])
rows.append([sg.Button("Close"), sg.Button("Refresh values")])
self.layout = rows
# create window
self.window = sg.Window(
f"Assembly Display - {getattr(self.assembly, 'name', '')}",
self.layout,
finalize=True,
)
def _poll_loop(self):
# poll non-monitorable items and push updates into GUI via write_event_value
while not self._stop_event.wait(self.poll_interval):
for desc in self._items:
it = desc["item"]
if it in self._monitorables:
continue
try:
val = it.get_current_value()
self.window.write_event_value(("MB_UPDATE", desc["key_val"]), val)
except Exception:
# ignore single failures
pass
def _handle_gui_event(self, event, values):
try:
if event == sg.WIN_CLOSED or event == "Close":
self.stop()
return False
if event == "Refresh values":
# force immediate refresh of all non-monitorables
for desc in self._items:
it = desc["item"]
try:
val = it.get_current_value()
self.window.Element(desc["key_val"]).Update(str(val))
except Exception:
pass
return True
# monitorable update events pushed by write_event_value
if isinstance(event, tuple) and event[0] == "MB_UPDATE":
key = event[1]
val = values.get(event) if event in values else values.get(event)
# PySimpleGUI puts the pushed value in the "values" dict under the event tuple
pushed = values.get(event, None)
# for our usage, pushed contains the value; sometimes event tuple is used as key, sometimes not
new_val = pushed if pushed is not None else val
# find element and update
try:
# event was MB_UPDATE, key is the element key we want to update
self.window.Element(key).Update(str(new_val))
except Exception:
pass
return True
# control button handling: look for up/down/set/combo events
# event keys are of form "UP::name", "DOWN::name", "SET::name", "COMBO::name"
if isinstance(event, str):
if (
event.startswith("UP::")
or event.startswith("DOWN::")
or event.startswith("SET::")
or event.startswith("COMBO::")
):
# find descriptor
name = event.split("::", 1)[1]
desc = next((d for d in self._items if d["name"] == name), None)
if desc is None:
return True
it = desc["item"]
key_val = desc["key_val"]
# handle combo set
if event.startswith("COMBO::"):
# a combo value changed; values[event] contains the selected string
sel = values.get(event)
# try map back to actual option values if AdjustableEnum provides raw options list
try:
opts = _get_enum_options(it) or []
# if option strings match raw, pick raw matching index
raw = None
for o in opts:
if str(o) == str(sel):
raw = o
break
if raw is None:
raw = sel
r = it.set_target_value(raw)
if hasattr(r, "wait"):
r.wait(timeout=5)
# update displayed value
try:
newv = it.get_current_value()
self.window.Element(key_val).Update(str(newv))
except Exception:
pass
except Exception:
pass
return True
# handle SET
if event.startswith("SET::"):
# prefer numeric input key, else combo
input_key = desc.get("key_input")
combo_key = desc.get("key_combo")
sel_val = None
if combo_key and values.get(combo_key) is not None:
sel_val = values.get(combo_key)
elif input_key and values.get(input_key) is not None:
sel_val = values.get(input_key)
# coerce simple numeric if looks like number
try:
if isinstance(sel_val, str):
s = sel_val.strip()
if s.lower() in ("true", "false"):
val = s.lower() == "true"
else:
try:
val = int(s)
except Exception:
try:
val = float(s)
except Exception:
val = sel_val
else:
val = sel_val
except Exception:
val = sel_val
try:
r = it.set_target_value(val)
if hasattr(r, "wait"):
r.wait(timeout=5)
# update display
try:
newv = it.get_current_value()
self.window.Element(key_val).Update(str(newv))
except Exception:
pass
except Exception:
pass
return True
# handle UP / DOWN for numeric adjustables
if event.startswith("UP::") or event.startswith("DOWN::"):
step_key = desc.get("key_step")
input_key = desc.get("key_input")
try:
step_raw = values.get(step_key)
step = (
float(step_raw) if step_raw not in (None, "") else 1.0
)
except Exception:
step = 1.0
# get base value from input if present else from current
try:
base_raw = values.get(input_key)
if base_raw is None or base_raw == "":
base = it.get_current_value()
else:
# coerce
try:
base = int(base_raw)
except Exception:
try:
base = float(base_raw)
except Exception:
base = base_raw
except Exception:
base = 0
if event.startswith("UP::"):
try:
newv = base + step
except Exception:
newv = base
else:
try:
newv = base - step
except Exception:
newv = base
# set it
try:
r = it.set_target_value(newv)
if hasattr(r, "wait"):
r.wait(timeout=5)
# update input and display
try:
if input_key:
self.window.Element(input_key).Update(str(newv))
self.window.Element(key_val).Update(
str(it.get_current_value())
)
except Exception:
pass
except Exception:
pass
return True
return True
except Exception:
# swallow GUI handler exceptions to keep UI alive
traceback.print_exc()
return True
def run(self):
"""Blocking run of the GUI event loop."""
if self.window is None:
self._build_layout()
# start poll thread
self._stop_event.clear()
self._poll_thread = threading.Thread(target=self._poll_loop, daemon=True)
self._poll_thread.start()
try:
while True:
event, values = self.window.read(timeout=100)
cont = self._handle_gui_event(event, values)
if cont is False:
break
finally:
self.stop()
def _gui_thread_target(self):
# wrapper for background thread run
self.run()
def start(self):
"""Start GUI in background thread (non-blocking)."""
if self._gui_thread and self._gui_thread.is_alive():
return
self._stop_event.clear()
self._gui_thread = threading.Thread(target=self._gui_thread_target, daemon=True)
self._gui_thread.start()
def stop(self):
"""Stop polling and close window."""
self._stop_event.set()
try:
if self.window is not None:
self.window.close()
self.window = None
except Exception:
pass
+324
View File
@@ -0,0 +1,324 @@
# ...existing code...
"""
Jupyter widget to view display items and (where supported) set targets.
Usage:
from eco.widgets.display_widget import make_assembly_widget
w = make_assembly_widget(my_assembly, poll_interval=1.0)
display(w)
Returned widget has methods:
w.start() # start background polling (already started by default)
w.stop() # stop background polling
"""
import threading
import time
from typing import Any, List
import ipywidgets as widgets
from IPython.display import display
# Try to import types for isinstance checks if available.
try:
from eco import Adjustable, Detector
except Exception:
Adjustable = object
Detector = object
def _make_input_widget_for_value(value: Any):
"""Return a suitable ipywidget for editing a value, plus a function to read it."""
if isinstance(value, bool):
w = widgets.Checkbox(value=value)
reader = lambda: w.value
elif isinstance(value, (int,)) and not isinstance(value, bool):
w = widgets.IntText(value=value)
reader = lambda: int(w.value)
elif isinstance(value, (float,)):
w = widgets.FloatText(value=value)
reader = lambda: float(w.value)
else:
# fallback to text field (strings, enums represented as strings)
w = widgets.Text(value=str(value) if value is not None else "")
reader = lambda: w.value
return w, reader
def _make_step_widget_for_value(value: Any):
"""Create step-size input suitable for numeric types."""
if isinstance(value, int) and not isinstance(value, bool):
step_w = widgets.IntText(value=1, layout=widgets.Layout(width="80px"))
reader = lambda: int(step_w.value)
else:
step_w = widgets.FloatText(
value=0.1 if isinstance(value, float) else 1.0,
layout=widgets.Layout(width="80px"),
)
reader = lambda: float(step_w.value)
return step_w, reader
def make_assembly_widget(assembly, poll_interval: float = 1.0, auto_start: bool = True):
"""
Build an ipywidgets VBox showing items in assembly.display_collection.
For items that are eco.Adjustable a tweak control with up/down buttons and step size is shown.
For items that are eco.Detector (and not Adjustable) no control is added.
For other items, a readonly display is shown.
Returns a VBox widget; the returned widget has .start() and .stop() methods
to control the background polling thread.
"""
rows: List[widgets.HBox] = []
item_entries = [] # list of dicts with item -> widgets and reader
# obtain list of display items (support either call or attribute)
try:
display_items = assembly.display_collection()
except Exception:
try:
display_items = assembly.status_collection.get_list(selection="display")
except Exception:
display_items = []
header = widgets.HBox(
[
widgets.HTML(value="<b>name</b>", layout=widgets.Layout(width="30%")),
widgets.HTML(value="<b>current</b>", layout=widgets.Layout(width="40%")),
widgets.HTML(value="<b>control</b>", layout=widgets.Layout(width="30%")),
]
)
for item in display_items:
name = (
item.alias.get_full_name(base=assembly)
if hasattr(item, "alias")
else getattr(item, "name", str(item))
)
try:
cur = item.get_current_value()
except Exception:
cur = "<error>"
name_w = widgets.Label(str(name), layout=widgets.Layout(width="30%"))
value_w = widgets.Label(str(cur), layout=widgets.Layout(width="40%"))
# control area
control_box = widgets.HBox(layout=widgets.Layout(width="30%"))
input_widget = None
reader = None
# If it's a Detector and NOT Adjustable -> no control widget (readonly)
if isinstance(item, Detector) and not isinstance(item, Adjustable):
control_box.children = (widgets.Label("read-only (Detector)"),)
# If it's Adjustable -> show tweak widget (step, up, down)
elif isinstance(item, Adjustable):
# create step widget based on current value
step_w, step_reader = _make_step_widget_for_value(cur)
up_btn = widgets.Button(
description="", layout=widgets.Layout(width="40px")
)
down_btn = widgets.Button(
description="", layout=widgets.Layout(width="40px")
)
# optional direct input to set an absolute value
if not isinstance(cur, (list, dict)) and not isinstance(
cur, (bytes, bytearray)
):
input_widget, reader = _make_input_widget_for_value(cur)
input_widget.layout.margin = "0 6px 0 0"
else:
input_widget = widgets.Label("n/a", layout=widgets.Layout(width="80px"))
def make_tweak_handlers(
it, val_widget, inp_reader, step_reader, up_b, down_b
):
def _do_set(newval, btn=None):
try:
r = it.set_target_value(newval)
try:
if hasattr(r, "wait"):
r.wait(timeout=5)
except Exception:
pass
try:
val_widget.value = str(it.get_current_value())
except Exception:
pass
if btn:
btn.description = (
btn.description
) # no-op to keep UI consistent
except Exception:
if btn:
old = btn.description
btn.description = "Err"
def _reset(b=btn, o=old):
time.sleep(1.2)
b.description = o
threading.Thread(target=_reset, daemon=True).start()
def _on_up(b=None):
try:
step = step_reader()
if inp_reader:
base = inp_reader()
else:
base = it.get_current_value()
newv = base + step
_do_set(newv, up_b)
# sync input widget if present
if inp_reader:
try:
input_widget.value = str(newv)
except Exception:
pass
except Exception:
_do_set(None, up_b) # triggers error visual
def _on_down(b=None):
try:
step = step_reader()
if inp_reader:
base = inp_reader()
else:
base = it.get_current_value()
newv = base - step
_do_set(newv, down_b)
if inp_reader:
try:
input_widget.value = str(newv)
except Exception:
pass
except Exception:
_do_set(None, down_b)
def _on_set_direct(b=None):
if not inp_reader:
return
try:
val = inp_reader()
_do_set(val, None)
# update shown value
try:
val_widget.value = str(it.get_current_value())
except Exception:
pass
except Exception:
pass
return _on_up, _on_down, _on_set_direct
on_up, on_down, on_set_direct = make_tweak_handlers(
item, value_w, reader, step_reader, up_btn, down_btn
)
up_btn.on_click(on_up)
down_btn.on_click(on_down)
set_btn = widgets.Button(
description="Set",
button_style="primary",
layout=widgets.Layout(width="60px"),
)
set_btn.on_click(on_set_direct)
control_box.children = (step_w, up_btn, down_btn, input_widget, set_btn)
# Fallback: if item has set_target_value (callable) but wasn't captured above, allow simple set
elif hasattr(item, "set_target_value") and callable(
getattr(item, "set_target_value")
):
# create input widget based on current value
input_widget, reader = _make_input_widget_for_value(cur)
input_widget.layout.margin = "0 6px 0 0"
set_button = widgets.Button(
description="Set",
button_style="primary",
layout=widgets.Layout(width="60px"),
)
def make_on_set(it, rw, vw, btn):
def _on_set(b):
try:
val = rw()
r = it.set_target_value(val)
try:
if hasattr(r, "wait"):
r.wait(timeout=5)
except Exception:
pass
try:
vw.value = str(it.get_current_value())
except Exception:
pass
btn.description = "Set"
except Exception:
btn.description = "Err"
def _reset():
time.sleep(1.2)
btn.description = "Set"
threading.Thread(target=_reset, daemon=True).start()
return _on_set
set_button.on_click(make_on_set(item, reader, value_w, set_button))
control_box.children = (input_widget, set_button)
else:
control_box.children = (
widgets.Label("", layout=widgets.Layout(margin="0 0 0 6px")),
)
row = widgets.HBox([name_w, value_w, control_box])
rows.append(row)
item_entries.append(
{
"item": item,
"value_widget": value_w,
"input_widget": input_widget,
"reader": reader,
}
)
vbox = widgets.VBox([header] + rows)
# background updater
stop_event = threading.Event()
updater_thread = None
def _update_loop():
while not stop_event.wait(poll_interval):
for ent in item_entries:
it = ent["item"]
vw = ent["value_widget"]
try:
val = it.get_current_value()
vw.value = str(val)
except Exception:
pass
def start():
nonlocal updater_thread
if updater_thread and updater_thread.is_alive():
return
stop_event.clear()
updater_thread = threading.Thread(target=_update_loop, daemon=True)
updater_thread.start()
def stop():
stop_event.set()
vbox.start = start
vbox.stop = stop
vbox._stop_event = stop_event
if auto_start:
start()
return vbox
# ...existing code...
+275
View File
@@ -0,0 +1,275 @@
"""
IPython widget to select one item from a Namespace.required_names and
to choose one item to be selected in 12 hours (separate radio group).
Usage:
from eco.widgets.namespace_selector import make_namespace_selector
w = make_namespace_selector(ns)
display(w) # w is a widgets.VBox subclass
cur = w.get_current()
sched = w.get_scheduled_12h()
w.on_change(lambda cur, sched: print("changed", cur, sched))
"""
# ...existing code...
from typing import Any, Callable, Iterable, List, Optional, Tuple
import ipywidgets as widgets
def _label_of(item: Any) -> str:
# prefer alias/full name, then name, then str()
try:
if hasattr(item, "alias") and hasattr(item.alias, "get_full_name"):
return item.alias.get_full_name()
except Exception:
pass
try:
if hasattr(item, "name"):
return str(item.name)
except Exception:
pass
return str(item)
class NamespaceSelector(widgets.VBox):
def __init__(
self,
namespace: Any,
required_names_attr: str = "required_names",
initial: Optional[Any] = None,
scheduled_initial: Optional[Any] = None,
none_label: str = "None",
):
"""
namespace: object containing an iterable attribute required_names_attr
(list of items or names). Each entry can be any object.
initial: optional item from the required_names to pre-select (value compared by identity).
scheduled_initial: optional item to pre-select for 12-hour selection (or None).
"""
# collect items
items = getattr(namespace, required_names_attr, None)
if items is None:
items = []
# normalize to list
items = list(items)
# build options as (label, value) pairs
self._options: List[Tuple[str, Any]] = [( _label_of(it), it ) for it in items]
# radio for "current selection"
rb_options = [(lbl, val) for lbl, val in self._options]
self.current_rb = widgets.RadioButtons(
options=rb_options,
value=(initial if initial is not None else (rb_options[0][1] if rb_options else None)),
description="Select",
layout=widgets.Layout(width="100%"),
)
# radio for "selected in 12 hours" — include explicit None option
rb12_options = [(none_label, None)] + [(lbl, val) for lbl, val in self._options]
self.scheduled_rb = widgets.RadioButtons(
options=rb12_options,
value=(scheduled_initial if scheduled_initial is not None else None),
description="In 12h",
layout=widgets.Layout(width="100%"),
)
super().__init__([widgets.HTML(value="<b>Required items</b>"), self.current_rb,
widgets.HTML(value="<b>Mark component to select in 12 hours</b>"), self.scheduled_rb])
# callbacks list called with (current_value, scheduled_value)
self._cbs: List[Callable[[Any, Any], None]] = []
# attach observers
self.current_rb.observe(self._on_change, names="value")
self.scheduled_rb.observe(self._on_change, names="value")
def _on_change(self, change):
cur = self.get_current()
sched = self.get_scheduled_12h()
for cb in list(self._cbs):
try:
cb(cur, sched)
except Exception:
# swallow callback errors to keep widget responsive
pass
def get_current(self) -> Optional[Any]:
"""Return the currently selected item (or None)."""
return self.current_rb.value
def get_scheduled_12h(self) -> Optional[Any]:
"""Return the item selected for 12 hours from now (or None)."""
return self.scheduled_rb.value
def set_current(self, item: Any) -> None:
"""Programmatically set current selection (item must be one of options or None)."""
# allow None if present in options (rare); otherwise ignore
values = [v for (_, v) in self.current_rb.options]
if item in values:
self.current_rb.value = item
def set_scheduled_12h(self, item: Optional[Any]) -> None:
"""Programmatically set 12h selection (item must be one of options or None)."""
values = [v for (_, v) in self.scheduled_rb.options]
if item in values:
self.scheduled_rb.value = item
def on_change(self, cb: Callable[[Any, Any], None]) -> None:
"""Register a callback called as cb(current, scheduled) on changes."""
if callable(cb):
self._cbs.append(cb)
def make_namespace_selector(
namespace: Any,
required_names_attr: str = "required_names",
initial: Optional[Any] = None,
scheduled_initial: Optional[Any] = None,
) -> NamespaceSelector:
"""Convenience factory."""
return NamespaceSelector(
namespace,
required_names_attr=required_names_attr,
initial=initial,
scheduled_initial=scheduled_initial,
)
# ...existing code...
```# filepath: /home/lemke_h/mypy/eco/eco/widgets/namespace_selector.py
"""
IPython widget to select one item from a Namespace.required_names and
to choose one item to be selected in 12 hours (separate radio group).
Usage:
from eco.widgets.namespace_selector import make_namespace_selector
w = make_namespace_selector(ns)
display(w) # w is a widgets.VBox subclass
cur = w.get_current()
sched = w.get_scheduled_12h()
w.on_change(lambda cur, sched: print("changed", cur, sched))
"""
# ...existing code...
from typing import Any, Callable, Iterable, List, Optional, Tuple
import ipywidgets as widgets
def _label_of(item: Any) -> str:
# prefer alias/full name, then name, then str()
try:
if hasattr(item, "alias") and hasattr(item.alias, "get_full_name"):
return item.alias.get_full_name()
except Exception:
pass
try:
if hasattr(item, "name"):
return str(item.name)
except Exception:
pass
return str(item)
class NamespaceSelector(widgets.VBox):
def __init__(
self,
namespace: Any,
required_names_attr: str = "required_names",
initial: Optional[Any] = None,
scheduled_initial: Optional[Any] = None,
none_label: str = "None",
):
"""
namespace: object containing an iterable attribute required_names_attr
(list of items or names). Each entry can be any object.
initial: optional item from the required_names to pre-select (value compared by identity).
scheduled_initial: optional item to pre-select for 12-hour selection (or None).
"""
# collect items
items = getattr(namespace, required_names_attr, None)
if items is None:
items = []
# normalize to list
items = list(items)
# build options as (label, value) pairs
self._options: List[Tuple[str, Any]] = [( _label_of(it), it ) for it in items]
# radio for "current selection"
rb_options = [(lbl, val) for lbl, val in self._options]
self.current_rb = widgets.RadioButtons(
options=rb_options,
value=(initial if initial is not None else (rb_options[0][1] if rb_options else None)),
description="Select",
layout=widgets.Layout(width="100%"),
)
# radio for "selected in 12 hours" — include explicit None option
rb12_options = [(none_label, None)] + [(lbl, val) for lbl, val in self._options]
self.scheduled_rb = widgets.RadioButtons(
options=rb12_options,
value=(scheduled_initial if scheduled_initial is not None else None),
description="In 12h",
layout=widgets.Layout(width="100%"),
)
super().__init__([widgets.HTML(value="<b>Required items</b>"), self.current_rb,
widgets.HTML(value="<b>Mark component to select in 12 hours</b>"), self.scheduled_rb])
# callbacks list called with (current_value, scheduled_value)
self._cbs: List[Callable[[Any, Any], None]] = []
# attach observers
self.current_rb.observe(self._on_change, names="value")
self.scheduled_rb.observe(self._on_change, names="value")
def _on_change(self, change):
cur = self.get_current()
sched = self.get_scheduled_12h()
for cb in list(self._cbs):
try:
cb(cur, sched)
except Exception:
# swallow callback errors to keep widget responsive
pass
def get_current(self) -> Optional[Any]:
"""Return the currently selected item (or None)."""
return self.current_rb.value
def get_scheduled_12h(self) -> Optional[Any]:
"""Return the item selected for 12 hours from now (or None)."""
return self.scheduled_rb.value
def set_current(self, item: Any) -> None:
"""Programmatically set current selection (item must be one of options or None)."""
# allow None if present in options (rare); otherwise ignore
values = [v for (_, v) in self.current_rb.options]
if item in values:
self.current_rb.value = item
def set_scheduled_12h(self, item: Optional[Any]) -> None:
"""Programmatically set 12h selection (item must be one of options or None)."""
values = [v for (_, v) in self.scheduled_rb.options]
if item in values:
self.scheduled_rb.value = item
def on_change(self, cb: Callable[[Any, Any], None]) -> None:
"""Register a callback called as cb(current, scheduled) on changes."""
if callable(cb):
self._cbs.append(cb)
def make_namespace_selector(
namespace: Any,
required_names_attr: str = "required_names",
initial: Optional[Any] = None,
scheduled_initial: Optional[Any] = None,
) -> NamespaceSelector:
"""Convenience factory."""
return NamespaceSelector(
namespace,
required_names_attr=required_names_attr,
initial=initial,
scheduled_initial=scheduled_initial,
)
# ...existing code...
+428
View File
@@ -0,0 +1,428 @@
"""
IPython widget for Scans instances.
- Choose scan method from a dropdown (ascan, dscan, meshscan, acquire, etc).
- Selecting a method builds a parameter form for positional and keyword args
(inspecting the method signature) and includes dynamic callback keywords
returned by scans.get_callback_keywords(method_name) when available.
- Second tab contains a matplotlib Figure with an empty axis. The widget exposes
`.fig` and `.ax` for plotting.
- Pressing "Run" will call a user-provided run_callback(scan_obj, method_name, args, kwargs)
if given, otherwise it will attempt to call the scan method directly.
Usage:
from eco.widgets.scan_widget import ScanWidget, make_scan_widget
w = make_scan_widget(scans_instance)
display(w)
# Access figure: w.fig, w.ax
# Register custom runner:
w.run_callback = lambda scans, m, a, k: print("would run", m, a, k)
"""
from typing import Any, Callable, Dict, List, Optional, Tuple
import inspect
import threading
import json
import ipywidgets as widgets
from IPython.display import display, clear_output
import matplotlib.pyplot as plt
# helper to coerce simple string to numeric/bool if possible
def _coerce_value(s: str) -> Any:
if s is None:
return None
s = s.strip()
if s == "":
return ""
# try bool
if s.lower() in ("true", "false"):
return s.lower() == "true"
# try int
try:
iv = int(s)
return iv
except Exception:
pass
# try float
try:
fv = float(s)
return fv
except Exception:
pass
# try json (list/dict)
try:
j = json.loads(s)
return j
except Exception:
pass
return s
def _make_widget_for_default(value: Any):
"""Return (widget, reader) for a default value."""
# None -> text input (empty)
if isinstance(value, bool):
w = widgets.Checkbox(value=value)
return w, lambda: w.value
if isinstance(value, int) and not isinstance(value, bool):
w = widgets.IntText(value=value)
return w, lambda: int(w.value)
if isinstance(value, float):
w = widgets.FloatText(value=value)
return w, lambda: float(w.value)
# list/tuple -> Text (JSON) so user can enter JSON-like
if isinstance(value, (list, dict, tuple)):
w = widgets.Text(value=json.dumps(value), layout=widgets.Layout(width="100%"))
return w, lambda: _coerce_value(w.value)
# fallback string
w = widgets.Text(
value=str(value) if value is not None else "",
layout=widgets.Layout(width="100%"),
)
return w, lambda: _coerce_value(w.value)
def _make_free_arg_widget(placeholder: str = ""):
w = widgets.Text(
value="", placeholder=placeholder, layout=widgets.Layout(width="100%")
)
return w, lambda: _coerce_value(w.value)
class ScanWidget(widgets.Tab):
def __init__(
self,
scans_obj: Any,
methods: Optional[List[str]] = None,
auto_build: bool = True,
):
"""
scans_obj: instance providing scan methods and optionally get_callback_keywords(method_name).
methods: optional list of method names to offer; if None common names will be searched on scans_obj.
"""
self.scans = scans_obj
# find available methods if not provided
if methods is None:
cand = ["ascan", "dscan", "meshscan", "acquire"]
methods = [m for m in cand if hasattr(scans_obj, m)]
# also include any callable attributes that look like scans
for name in dir(scans_obj):
if (
name not in methods
and callable(getattr(scans_obj, name))
and not name.startswith("_")
):
methods.append(name)
self.methods = methods
# top controls: dropdown, run button, get params button
self.method_dd = widgets.Dropdown(
options=self.methods,
description="Method:",
layout=widgets.Layout(width="50%"),
)
self.run_button = widgets.Button(description="Run", button_style="primary")
self.get_params_button = widgets.Button(description="Get params")
self.status_label = widgets.Label("")
top_box = widgets.HBox(
[self.method_dd, self.run_button, self.get_params_button, self.status_label]
)
# parameter area will be rebuilt per method
self.params_box = widgets.VBox([])
# expose run callback override
# signature: run_callback(scans_obj, method_name, args_list, kwargs_dict)
self.run_callback: Optional[
Callable[[Any, str, List[Any], Dict[str, Any]], Any]
] = None
# figure tab: create empty figure and axis, display into an Output widget
self.fig = plt.Figure(figsize=(6, 4))
self.ax = self.fig.add_subplot(111)
self.plot_out = widgets.Output(layout=widgets.Layout(border="1px solid #ddd"))
with self.plot_out:
display(self.fig)
# assemble two tab children: form and plot output
self.form_vbox = widgets.VBox(
[top_box, widgets.HTML("<b>Parameters</b>"), self.params_box]
)
children = [self.form_vbox, self.plot_out]
super().__init__(children)
self.set_title(0, "Form")
self.set_title(1, "Plot")
# wire events
self.method_dd.observe(self._on_method_change, names="value")
self.run_button.on_click(self._on_run)
self.get_params_button.on_click(self._on_get_params)
# storage for widgets mapping
self._pos_widgets: List[Tuple[str, widgets.Widget, Callable[[], Any]]] = []
self._kw_widgets: List[Tuple[str, widgets.Widget, Callable[[], Any]]] = []
if auto_build:
self.build_for_method(self.method_dd.value)
def _on_method_change(self, change):
if change.get("name") == "value":
self.build_for_method(change["new"])
def _get_dynamic_callback_keywords(self, method_name: str) -> Dict[str, Any]:
"""Call scans.get_callback_keywords(method_name) if available, return dict of kw->default/metadata."""
fn = getattr(self.scans, "get_callback_keywords", None)
if callable(fn):
try:
kws = fn(method_name)
if isinstance(kws, dict):
return kws
# try list of names -> treat as None defaults
if isinstance(kws, (list, tuple)):
return {k: None for k in kws}
except Exception:
pass
return {}
def build_for_method(self, method_name: str):
"""(Re)build parameter widgets for selected method."""
self._pos_widgets = []
self._kw_widgets = []
self.status_label.value = ""
self.params_box.children = [
widgets.Label(f"Building parameter form for {method_name}...")
]
method = getattr(self.scans, method_name, None)
if method is None or not callable(method):
self.params_box.children = [widgets.Label("Selected method not available")]
return
sig = None
try:
sig = inspect.signature(method)
except Exception:
sig = None
# dynamic callback keywords
dyn_kws = self._get_dynamic_callback_keywords(method_name)
pos_rows = []
kw_rows = []
if sig is not None:
for pname, param in sig.parameters.items():
if pname == "self":
continue
kind = param.kind
default = param.default if param.default is not inspect._empty else None
if kind in (
inspect.Parameter.POSITIONAL_ONLY,
inspect.Parameter.POSITIONAL_OR_KEYWORD,
):
# positional parameter: create widget; if default is None treat as required
if default is None:
w, reader = _make_free_arg_widget(
placeholder=f"{pname} (required)"
)
else:
w, reader = _make_widget_for_default(default)
lbl = widgets.Label(pname, layout=widgets.Layout(width="25%"))
row = widgets.HBox([lbl, w])
pos_rows.append(row)
self._pos_widgets.append((pname, w, reader))
elif kind == inspect.Parameter.VAR_POSITIONAL:
# allow multiple positional args as newline-separated or JSON list
w = widgets.Textarea(
placeholder="comma separated or JSON list",
layout=widgets.Layout(width="100%"),
)
reader = lambda w=w: _coerce_value(w.value)
lbl = widgets.Label("*" + pname, layout=widgets.Layout(width="25%"))
row = widgets.HBox([lbl, w])
pos_rows.append(row)
self._pos_widgets.append((pname, w, reader))
elif kind == inspect.Parameter.KEYWORD_ONLY:
# keyword-only parameter
if default is None:
w, reader = _make_free_arg_widget(
placeholder=f"{pname} (required)"
)
else:
w, reader = _make_widget_for_default(default)
lbl = widgets.Label(pname, layout=widgets.Layout(width="25%"))
row = widgets.HBox([lbl, w])
kw_rows.append(row)
self._kw_widgets.append((pname, w, reader))
elif kind == inspect.Parameter.VAR_KEYWORD:
# provide a Textarea for free-form kwargs (JSON or key=val lines)
w = widgets.Textarea(
placeholder='JSON object or "k=v" lines',
layout=widgets.Layout(width="100%"),
)
reader = lambda w=w: _coerce_value(w.value)
lbl = widgets.Label(
"**" + pname, layout=widgets.Layout(width="25%")
)
row = widgets.HBox([lbl, w])
kw_rows.append(row)
self._kw_widgets.append((pname, w, reader))
else:
# unknown signature: provide free args and kwargs boxes
wpos = widgets.Textarea(
placeholder="JSON list of positional args",
layout=widgets.Layout(width="100%"),
)
rr_pos = lambda w=wpos: _coerce_value(w.value)
self._pos_widgets.append(("args", wpos, rr_pos))
wkw = widgets.Textarea(
placeholder="JSON kwargs dict", layout=widgets.Layout(width="100%")
)
rr_kw = lambda w=wkw: _coerce_value(w.value)
self._kw_widgets.append(("kwargs", wkw, rr_kw))
pos_rows.append(wpos)
kw_rows.append(wkw)
# include dynamic callback keywords (if any) as additional kwargs (do not overwrite existing)
for k, v in dyn_kws.items():
if k in [name for name, _, _ in self._kw_widgets]:
continue
# create widget depending on provided default
if isinstance(v, (list, tuple)):
# treat as choices -> Dropdown
options = [(str(opt), opt) for opt in v]
dd = widgets.Dropdown(
options=options,
value=(v[0] if len(v) else None),
layout=widgets.Layout(width="60%"),
)
reader = lambda dd=dd: dd.value
lbl = widgets.Label(k, layout=widgets.Layout(width="25%"))
row = widgets.HBox([lbl, dd])
kw_rows.append(row)
self._kw_widgets.append((k, dd, reader))
else:
if v is None:
w, reader = _make_free_arg_widget(placeholder=f"{k} (optional)")
else:
w, reader = _make_widget_for_default(v)
lbl = widgets.Label(k, layout=widgets.Layout(width="25%"))
row = widgets.HBox([lbl, w])
kw_rows.append(row)
self._kw_widgets.append((k, w, reader))
pos_section = (
widgets.VBox([widgets.HTML("<b>Positional / varargs</b>")] + pos_rows)
if pos_rows
else widgets.HTML("")
)
kw_section = (
widgets.VBox([widgets.HTML("<b>Keyword args</b>")] + kw_rows)
if kw_rows
else widgets.HTML("")
)
self.params_box.children = [pos_section, kw_section]
def _collect_params(self) -> Tuple[List[Any], Dict[str, Any]]:
"""Read widgets and return (args_list, kwargs_dict)."""
args: List[Any] = []
kwargs: Dict[str, Any] = {}
# positional widgets
for name, w, reader in self._pos_widgets:
val = None
try:
val = reader()
except Exception:
val = None
if name.startswith("*"):
# not used here, but include raw
args.append(val)
elif name == "args":
if isinstance(val, list):
args.extend(val)
elif isinstance(val, (str,)):
# try parse comma separated
if val.strip().startswith("[") or val.strip().startswith("{"):
try:
parsed = _coerce_value(val)
if isinstance(parsed, list):
args.extend(parsed)
else:
args.append(parsed)
except Exception:
args.append(val)
else:
parts = [p.strip() for p in val.split(",") if p.strip()]
for p in parts:
args.append(_coerce_value(p))
else:
args.append(val)
else:
# normal positional param: include value (even if None) but caller may require
args.append(val)
# keyword widgets
for name, w, reader in self._kw_widgets:
try:
val = reader()
except Exception:
val = None
if name == "kwargs" or name.startswith("**"):
# parse as dict if possible
if isinstance(val, dict):
kwargs.update(val)
elif isinstance(val, str):
# attempt parse JSON
parsed = _coerce_value(val)
if isinstance(parsed, dict):
kwargs.update(parsed)
else:
# try parse "k=v" lines
for line in val.splitlines():
if "=" in line:
k, v = line.split("=", 1)
kwargs[k.strip()] = _coerce_value(v)
elif isinstance(val, dict):
kwargs.update(val)
else:
# skip unknown
pass
else:
kwargs[name] = val
return args, kwargs
def _on_get_params(self, _=None):
a, k = self._collect_params()
self.status_label.value = f"Args: {a} Kw: {k}"
def _on_run(self, _=None):
method = self.method_dd.value
args, kwargs = self._collect_params()
self.status_label.value = "Running..."
# allow custom callback
def _do_call():
try:
if callable(self.run_callback):
res = self.run_callback(self.scans, method, args, kwargs)
else:
fn = getattr(self.scans, method, None)
if not callable(fn):
raise RuntimeError("method not callable")
res = fn(*args, **kwargs)
self.status_label.value = "Done"
except Exception as exc:
self.status_label.value = f"Error: {exc}"
# run in background thread to avoid blocking UI
t = threading.Thread(target=_do_call, daemon=True)
t.start()
def make_scan_widget(scans_obj: Any, methods: Optional[List[str]] = None) -> ScanWidget:
return ScanWidget(scans_obj, methods=methods)