From f537827bfe3c80935eaa4f4e6d7cece5afeb143e Mon Sep 17 00:00:00 2001 From: Henrik Lemke Date: Sun, 9 Nov 2025 11:27:13 +0100 Subject: [PATCH 1/4] widgets and group dymanics start --- eco/acquisition/daq_client.py | 33 +- .../textual-status-editor/LICENSE | 9 + .../textual-status-editor/README.md | 64 +++ .../textual-status-editor/pyproject.toml | 23 + .../textual-status-editor/requirements.txt | 5 + .../src/textual_status_editor/__init__.py | 1 + .../adapters/assembly_adapter.py | 61 +++ .../src/textual_status_editor/app.py | 45 ++ .../src/textual_status_editor/config.py | 10 + .../src/textual_status_editor/main.py | 52 +++ .../models/status_item.py | 60 +++ .../textual_status_editor/widgets/__init__.py | 1 + .../widgets/status_table.py | 50 ++ .../tests/test_assembly_adapter.py | 56 +++ .../tests/test_status_table.py | 36 ++ eco/utilities/elog_scilog.py | 3 +- eco/widgets/display_widget.py | 324 +++++++++++++ eco/widgets/namespace_selector.py | 275 +++++++++++ eco/widgets/scan_widget.py | 428 ++++++++++++++++++ 19 files changed, 1524 insertions(+), 12 deletions(-) create mode 100644 eco/elements/textual test/textual-status-editor/LICENSE create mode 100644 eco/elements/textual test/textual-status-editor/README.md create mode 100644 eco/elements/textual test/textual-status-editor/pyproject.toml create mode 100644 eco/elements/textual test/textual-status-editor/requirements.txt create mode 100644 eco/elements/textual test/textual-status-editor/src/textual_status_editor/__init__.py create mode 100644 eco/elements/textual test/textual-status-editor/src/textual_status_editor/adapters/assembly_adapter.py create mode 100644 eco/elements/textual test/textual-status-editor/src/textual_status_editor/app.py create mode 100644 eco/elements/textual test/textual-status-editor/src/textual_status_editor/config.py create mode 100644 eco/elements/textual test/textual-status-editor/src/textual_status_editor/main.py create mode 100644 eco/elements/textual test/textual-status-editor/src/textual_status_editor/models/status_item.py create mode 100644 eco/elements/textual test/textual-status-editor/src/textual_status_editor/widgets/__init__.py create mode 100644 eco/elements/textual test/textual-status-editor/src/textual_status_editor/widgets/status_table.py create mode 100644 eco/elements/textual test/textual-status-editor/tests/test_assembly_adapter.py create mode 100644 eco/elements/textual test/textual-status-editor/tests/test_status_table.py create mode 100644 eco/widgets/display_widget.py create mode 100644 eco/widgets/namespace_selector.py create mode 100644 eco/widgets/scan_widget.py diff --git a/eco/acquisition/daq_client.py b/eco/acquisition/daq_client.py index cc21608..84273f8 100644 --- a/eco/acquisition/daq_client.py +++ b/eco/acquisition/daq_client.py @@ -519,11 +519,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 +620,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 +640,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 +677,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 +693,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 +758,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 +889,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 +903,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) @@ -910,7 +921,7 @@ class Daq(Assembly): f"Copying monitor file to run {runno} to the raw directory of {self.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']}" diff --git a/eco/elements/textual test/textual-status-editor/LICENSE b/eco/elements/textual test/textual-status-editor/LICENSE new file mode 100644 index 0000000..60c1a0a --- /dev/null +++ b/eco/elements/textual test/textual-status-editor/LICENSE @@ -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. \ No newline at end of file diff --git a/eco/elements/textual test/textual-status-editor/README.md b/eco/elements/textual test/textual-status-editor/README.md new file mode 100644 index 0000000..c4b006f --- /dev/null +++ b/eco/elements/textual test/textual-status-editor/README.md @@ -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. \ No newline at end of file diff --git a/eco/elements/textual test/textual-status-editor/pyproject.toml b/eco/elements/textual test/textual-status-editor/pyproject.toml new file mode 100644 index 0000000..0e4682e --- /dev/null +++ b/eco/elements/textual test/textual-status-editor/pyproject.toml @@ -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 "] +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" \ No newline at end of file diff --git a/eco/elements/textual test/textual-status-editor/requirements.txt b/eco/elements/textual test/textual-status-editor/requirements.txt new file mode 100644 index 0000000..afee95f --- /dev/null +++ b/eco/elements/textual test/textual-status-editor/requirements.txt @@ -0,0 +1,5 @@ +textual +rich +pytest +pytest-asyncio +textual-widget \ No newline at end of file diff --git a/eco/elements/textual test/textual-status-editor/src/textual_status_editor/__init__.py b/eco/elements/textual test/textual-status-editor/src/textual_status_editor/__init__.py new file mode 100644 index 0000000..82789f2 --- /dev/null +++ b/eco/elements/textual test/textual-status-editor/src/textual_status_editor/__init__.py @@ -0,0 +1 @@ +# This file is intentionally left blank. \ No newline at end of file diff --git a/eco/elements/textual test/textual-status-editor/src/textual_status_editor/adapters/assembly_adapter.py b/eco/elements/textual test/textual-status-editor/src/textual_status_editor/adapters/assembly_adapter.py new file mode 100644 index 0000000..3660e1c --- /dev/null +++ b/eco/elements/textual test/textual-status-editor/src/textual_status_editor/adapters/assembly_adapter.py @@ -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() \ No newline at end of file diff --git a/eco/elements/textual test/textual-status-editor/src/textual_status_editor/app.py b/eco/elements/textual test/textual-status-editor/src/textual_status_editor/app.py new file mode 100644 index 0000000..23364d3 --- /dev/null +++ b/eco/elements/textual test/textual-status-editor/src/textual_status_editor/app.py @@ -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() \ No newline at end of file diff --git a/eco/elements/textual test/textual-status-editor/src/textual_status_editor/config.py b/eco/elements/textual test/textual-status-editor/src/textual_status_editor/config.py new file mode 100644 index 0000000..7c51199 --- /dev/null +++ b/eco/elements/textual test/textual-status-editor/src/textual_status_editor/config.py @@ -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" \ No newline at end of file diff --git a/eco/elements/textual test/textual-status-editor/src/textual_status_editor/main.py b/eco/elements/textual test/textual-status-editor/src/textual_status_editor/main.py new file mode 100644 index 0000000..e99059e --- /dev/null +++ b/eco/elements/textual test/textual-status-editor/src/textual_status_editor/main.py @@ -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() \ No newline at end of file diff --git a/eco/elements/textual test/textual-status-editor/src/textual_status_editor/models/status_item.py b/eco/elements/textual test/textual-status-editor/src/textual_status_editor/models/status_item.py new file mode 100644 index 0000000..4a29195 --- /dev/null +++ b/eco/elements/textual test/textual-status-editor/src/textual_status_editor/models/status_item.py @@ -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() \ No newline at end of file diff --git a/eco/elements/textual test/textual-status-editor/src/textual_status_editor/widgets/__init__.py b/eco/elements/textual test/textual-status-editor/src/textual_status_editor/widgets/__init__.py new file mode 100644 index 0000000..82789f2 --- /dev/null +++ b/eco/elements/textual test/textual-status-editor/src/textual_status_editor/widgets/__init__.py @@ -0,0 +1 @@ +# This file is intentionally left blank. \ No newline at end of file diff --git a/eco/elements/textual test/textual-status-editor/src/textual_status_editor/widgets/status_table.py b/eco/elements/textual test/textual-status-editor/src/textual_status_editor/widgets/status_table.py new file mode 100644 index 0000000..b5f314f --- /dev/null +++ b/eco/elements/textual test/textual-status-editor/src/textual_status_editor/widgets/status_table.py @@ -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() \ No newline at end of file diff --git a/eco/elements/textual test/textual-status-editor/tests/test_assembly_adapter.py b/eco/elements/textual test/textual-status-editor/tests/test_assembly_adapter.py new file mode 100644 index 0000000..6289a6a --- /dev/null +++ b/eco/elements/textual test/textual-status-editor/tests/test_assembly_adapter.py @@ -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 \ No newline at end of file diff --git a/eco/elements/textual test/textual-status-editor/tests/test_status_table.py b/eco/elements/textual test/textual-status-editor/tests/test_status_table.py new file mode 100644 index 0000000..3109567 --- /dev/null +++ b/eco/elements/textual test/textual-status-editor/tests/test_status_table.py @@ -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 \ No newline at end of file diff --git a/eco/utilities/elog_scilog.py b/eco/utilities/elog_scilog.py index 42549cf..43064f3 100755 --- a/eco/utilities/elog_scilog.py +++ b/eco/utilities/elog_scilog.py @@ -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, diff --git a/eco/widgets/display_widget.py b/eco/widgets/display_widget.py new file mode 100644 index 0000000..327d792 --- /dev/null +++ b/eco/widgets/display_widget.py @@ -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="name", layout=widgets.Layout(width="30%")), + widgets.HTML(value="current", layout=widgets.Layout(width="40%")), + widgets.HTML(value="control", 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 = "" + + 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... diff --git a/eco/widgets/namespace_selector.py b/eco/widgets/namespace_selector.py new file mode 100644 index 0000000..541633e --- /dev/null +++ b/eco/widgets/namespace_selector.py @@ -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="Required items"), self.current_rb, + widgets.HTML(value="Mark component to select in 12 hours"), 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="Required items"), self.current_rb, + widgets.HTML(value="Mark component to select in 12 hours"), 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... \ No newline at end of file diff --git a/eco/widgets/scan_widget.py b/eco/widgets/scan_widget.py new file mode 100644 index 0000000..280414d --- /dev/null +++ b/eco/widgets/scan_widget.py @@ -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("Parameters"), 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("Positional / varargs")] + pos_rows) + if pos_rows + else widgets.HTML("") + ) + kw_section = ( + widgets.VBox([widgets.HTML("Keyword args")] + 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) From f8fdc20d3f4558e5b89f1b23a216a59abc3e5568 Mon Sep 17 00:00:00 2001 From: Henrik Lemke Date: Sun, 9 Nov 2025 21:34:17 +0100 Subject: [PATCH 2/4] added tk widget --- eco/widgets/display_tk.py | 496 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 496 insertions(+) create mode 100644 eco/widgets/display_tk.py diff --git a/eco/widgets/display_tk.py b/eco/widgets/display_tk.py new file mode 100644 index 0000000..aa670f4 --- /dev/null +++ b/eco/widgets/display_tk.py @@ -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 = "" + + # 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 From eabfd0dbf1ba465a351f9536140abe846827a63b Mon Sep 17 00:00:00 2001 From: Mathias Sander Date: Fri, 14 Nov 2025 15:30:05 +0100 Subject: [PATCH 3/4] tmp adding --- eco/acquisition/daq_client.py | 34 +++- eco/acquisition/scan.py | 119 +++++++++++- eco/elements/assembly.py | 9 +- eco/utilities/PeakAnalysis.py | 170 ++++++++++++++++ eco/widgets/assembly_browser.py | 331 ++++++++++++++++++++++++++++++++ 5 files changed, 651 insertions(+), 12 deletions(-) create mode 100644 eco/utilities/PeakAnalysis.py create mode 100644 eco/widgets/assembly_browser.py diff --git a/eco/acquisition/daq_client.py b/eco/acquisition/daq_client.py index 84273f8..caa9bb8 100644 --- a/eco/acquisition/daq_client.py +++ b/eco/acquisition/daq_client.py @@ -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() @@ -917,9 +939,7 @@ 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=pgroup, run_number=runno ) diff --git a/eco/acquisition/scan.py b/eco/acquisition/scan.py index 1f21599..c1b9c56 100755 --- a/eco/acquisition/scan.py +++ b/eco/acquisition/scan.py @@ -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"): diff --git a/eco/elements/assembly.py b/eco/elements/assembly.py index a87dd8f..6c8a54f 100644 --- a/eco/elements/assembly.py +++ b/eco/elements/assembly.py @@ -147,8 +147,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( @@ -506,6 +506,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 diff --git a/eco/utilities/PeakAnalysis.py b/eco/utilities/PeakAnalysis.py new file mode 100644 index 0000000..88b0a93 --- /dev/null +++ b/eco/utilities/PeakAnalysis.py @@ -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 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() From f0fbcabf55ef0d8448f903b38eddb6590c285c86 Mon Sep 17 00:00:00 2001 From: Mathias Sander Date: Mon, 17 Nov 2025 19:41:59 +0100 Subject: [PATCH 4/4] fixing once more the recursion issues --- eco/elements/assembly.py | 49 ++++++++++++++++++++++++++++++---------- 1 file changed, 37 insertions(+), 12 deletions(-) diff --git a/eco/elements/assembly.py b/eco/elements/assembly.py index 38bb58a..5aaf5d3 100644 --- a/eco/elements/assembly.py +++ b/eco/elements/assembly.py @@ -43,41 +43,60 @@ 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): @@ -96,7 +115,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): @@ -159,11 +178,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__: @@ -192,8 +219,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( @@ -205,8 +230,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( @@ -331,7 +354,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,