From 783a509dd2d08d6627ef6e3f48508a0d96df8983 Mon Sep 17 00:00:00 2001 From: CSSFrancis Date: Wed, 28 Jan 2026 14:48:04 -0600 Subject: [PATCH 01/20] New Feature: Add in multiple scans. --- deapi/client.py | 77 +++++++++++++++---- deapi/tests/test_scanning/__init__.py | 0 .../test_scanning/test_continual_scanning.py | 64 +++++++++++++++ pyproject.toml | 1 - 4 files changed, 125 insertions(+), 17 deletions(-) create mode 100644 deapi/tests/test_scanning/__init__.py create mode 100644 deapi/tests/test_scanning/test_continual_scanning.py diff --git a/deapi/client.py b/deapi/client.py index fcd9ba35..d228cf00 100644 --- a/deapi/client.py +++ b/deapi/client.py @@ -583,6 +583,11 @@ def set_property(self, name: str, value): value : any The value to set the property to """ + if isinstance(value, bool): + if value: + value = "On" + else: + value = "Off" t0 = self.GetTime() ret = False @@ -1397,19 +1402,55 @@ def set_xy_array(self, positions, width=None, height=None): The height of the scan array, by default None. If None, the max of the y positions will be used and the scan will cover the full height of the image. """ - - if positions.dtype != np.int32: - log.error("Positions must be integers... Casting to int") - positions = positions.astype(np.int32) - if width is None: - width = np.max(positions[:, 0]) + 1 - if height is None: - height = np.max(positions[:, 1]) + 1 - - num_positions = len(positions) - + # first check to see if multiple arrays were passed in: + new_width = 0 + new_height = 0 + if isinstance(positions, list): + for i in range(len(positions)): + pos = positions[i] + if not isinstance(pos, np.ndarray): + log.error("Positions must be a numpy array or list of numpy arrays") + return False + else: + if not pos.dtype == np.int32: + log.error("Positions must be integers... Casting to int") + positions[i] = pos.astype(np.int32) + elif pos.ndim != 2 or pos.shape[1] != 2: + log.error("Positions must be of shape (N, 2)") + return False + if width is None: + new_width = max(new_width, np.max(pos[:, 0]) + 1) + if height is None: + new_height = max(new_height, np.max(pos[:, 1]) + 1) + # For an array handle both 2 and 3d cases... + elif isinstance(positions, np.ndarray): + if positions.ndim > 3: + log.error("Positions must be a 2D array of shape (N, 2) or 3D array of shape (M, N, 2)") + return False + elif positions.ndim == 2: + positions = positions[np.newaxis, :, :] + if positions.dtype != np.int32: + log.error("Positions must be integers... Casting to int") + positions = positions.astype(np.int32) + if width is None: + new_width = np.max(positions[:,:, 0]) + 1 + if height is None: + new_height = np.max(positions[:,:, 1]) + 1 + + if width is not None: + new_width = width + if height is not None: + new_height = height + + num_positions = [] + + for pos in positions: + num_positions.append(len(pos)) + + vals_to_send = [int(new_width), int(new_height)] + num_positions + print("Vals to send:", vals_to_send) command = self._addSingleCommand( - self.SET_SCAN_XY_ARRAY, None, [width, height, num_positions] + self.SET_SCAN_XY_ARRAY, None, vals_to_send ) try: packet = struct.pack("I", command.ByteSize()) + command.SerializeToString() @@ -1420,12 +1461,16 @@ def set_xy_array(self, positions, width=None, height=None): raise socket.error( "Error sending x-y scan positions to socket. Is the server running?" ) + if ret: try: - x = positions[:, 0].tobytes() - self.__sendToSocket(self.socket, x, len(x)) - y = positions[:, 1].tobytes() - self.__sendToSocket(self.socket, y, len(y)) + # convert to bytes and send + + for pos in positions: + x = pos[:, 0].tobytes() + y = pos[:, 1].tobytes() + self.__sendToSocket(self.socket, x, len(x)) + self.__sendToSocket(self.socket, y, len(y)) except socket.error as e: log.log(logging.ERROR, "Error sending data to socket: %s", e) return False diff --git a/deapi/tests/test_scanning/__init__.py b/deapi/tests/test_scanning/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/deapi/tests/test_scanning/test_continual_scanning.py b/deapi/tests/test_scanning/test_continual_scanning.py new file mode 100644 index 00000000..cad00976 --- /dev/null +++ b/deapi/tests/test_scanning/test_continual_scanning.py @@ -0,0 +1,64 @@ +""" +This class is used for testing repeated scans. The main purpose is to ensure that +the scanning logic will properly cycle from the end of one scan to the beginning of the next +without errors. + +Expected behavior: +""" +import numpy as np +import pytest +from time import sleep + + +class TestContinualScanning: + """Test class for continual scanning functionality.""" + @pytest.fixture(autouse=True) + def clean_state(self, client): + # First set the hardware ROI to a known state + client["Hardware ROI Offset X"] = 0 + client["Hardware ROI Offset Y"] = 0 + client["Hardware Binning X"] = 1 + client["Hardware Binning Y"] = 1 + client["Hardware ROI Size X"] = 1024 + client["Hardware ROI Size Y"] = 1024 + client["Scan - Type"] = "Raster" + # Set the software Binning to 1 + client["Binning X"] = 1 + client["Binning Y"] = 1 + + @pytest.mark.server + def test_continual_scanning(self, client): + """Test continual scanning logic.""" + # Set up scan parameters + client["Scan - Size X"] = 8 + client["Scan - Size Y"] = 8 + client["Scan - Repeats"] = 3 + client["Scan - Enable"] = True + + assert client["Scan - Size X"] == 8 + assert client["Scan - Size Y"] == 8 + assert client["Scan - Repeats"] == 3 + assert client["Scan - Enable"] == "On" + + # Start the scan + client.start_acquisition() + while client.acquiring: + sleep(0.1) + # After scan completion, verify the scan parameters + + assert client["Frame Count"] == 8*8*3 + + @pytest.mark.server + def test_sending_multiple_scan_patterns(self, client): + + scan1 = np.array([[0,0],[1,0],[1,1],[0,1]]) + scans = [] + for i in range(10): + scans.append(scan1 + 2*i) + client.set_xy_array(scans, height=40, width=40) + + #client["Scan - Repeats"] = 100 + #client["Scan - Enable"] = True + #client.start_acquisition() + + #client.set_adaptive_roi(128,128) \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 857f621c..a2912792 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -52,7 +52,6 @@ tests = [ "setuptools_scm", "pytest-cov", "pytest-xprocess", - "libertem", "hyperspy", ] service = [ From 34c79a3ed4f181b85880a57628d8808ed367410c Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Tue, 3 Feb 2026 07:01:38 -0500 Subject: [PATCH 02/20] Add tests for multiple scan patterns in continual scanning --- .../test_scanning/test_continual_scanning.py | 62 ++++++++++++++++--- 1 file changed, 55 insertions(+), 7 deletions(-) diff --git a/deapi/tests/test_scanning/test_continual_scanning.py b/deapi/tests/test_scanning/test_continual_scanning.py index cad00976..684597be 100644 --- a/deapi/tests/test_scanning/test_continual_scanning.py +++ b/deapi/tests/test_scanning/test_continual_scanning.py @@ -5,6 +5,7 @@ Expected behavior: """ + import numpy as np import pytest from time import sleep @@ -12,6 +13,7 @@ class TestContinualScanning: """Test class for continual scanning functionality.""" + @pytest.fixture(autouse=True) def clean_state(self, client): # First set the hardware ROI to a known state @@ -46,19 +48,65 @@ def test_continual_scanning(self, client): sleep(0.1) # After scan completion, verify the scan parameters - assert client["Frame Count"] == 8*8*3 + assert client["Frame Count"] == 8 * 8 * 3 @pytest.mark.server def test_sending_multiple_scan_patterns(self, client): + """Test sending multiple scan patterns for continual scanning. It should + properly cycle through the patterns for the specified number of repeats. + """ - scan1 = np.array([[0,0],[1,0],[1,1],[0,1]]) + scan1 = np.array([[0, 0], [1, 0], [1, 1], [0, 1]]) scans = [] for i in range(10): - scans.append(scan1 + 2*i) + scans.append(scan1 + 2 * i) client.set_xy_array(scans, height=40, width=40) - #client["Scan - Repeats"] = 100 - #client["Scan - Enable"] = True - #client.start_acquisition() + client["Scan - Repeats"] = 100 + client["Scan - Enable"] = True + client.start_acquisition() + while client.acquiring: + sleep(0.1) + assert client["Frame Count"] == 4 * 100 + + @pytest.mark.server + def test_multiple_scan_patterns_different_lengths(self, client): + """Test sending multiple scan patterns of different lengths for continual scanning. + + Different patterns will be run depending on the index set by: `Scan - XY File Pattern ID` + """ + scan1 = np.array([[0, 0], [1, 0], [1, 1], [0, 1]]) # 4 points + scan2 = np.array([[0, 0], [2, 0], [2, 2], [0, 2], [1, 1]]) # 5 points + scan3 = np.array([[0, 0], [3, 0], [3, 3], [0, 3], [1, 1], [2, 2]]) # 6 points + scans = [scan1, scan2, scan3] + client.set_xy_array(scans, height=10, width=10) + + for i, num_points in enumerate([2, 3, 4]): + client["Scan - Repeats"] = 1 + client["Scan - XY File Pattern ID"] = i + client["Scan - Enable"] = True + client.start_acquisition() + while client.acquiring: + sleep(0.1) + assert client["Frame Count"] == num_points + + @pytest.mark.server + def test_repeat_scanning_no_DE_camera(self, client): + """Test continual scanning without using the DE camera. + + This is to test only using an EXT detector and not the DE camera. + """ + + client["Scan - Size X"] = 5 + client["Scan - Size Y"] = 5 + client["Scan - Repeats"] = 10 + client["Scan - Enable"] = True + client["Scan - Use DE Camera"] = "Off" + + # Start the scan + client.start_acquisition() + while client.acquiring: + sleep(0.1) + # After scan completion, verify the scan parameters - #client.set_adaptive_roi(128,128) \ No newline at end of file + result = client.get_result("external_image1") From 4530158ccbb5f809cb632f92df788868e42890ea Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Tue, 3 Feb 2026 07:22:31 -0500 Subject: [PATCH 03/20] Enhancement: Add tests for saving virtual images during continual scanning --- deapi/tests/conftest.py | 2 + .../test_scanning/test_continual_scanning.py | 56 +++++++++++++++++-- 2 files changed, 53 insertions(+), 5 deletions(-) diff --git a/deapi/tests/conftest.py b/deapi/tests/conftest.py index 660a533d..b41ee744 100644 --- a/deapi/tests/conftest.py +++ b/deapi/tests/conftest.py @@ -196,3 +196,5 @@ class Starter(ProcessStarter): c.connect(port=port) yield c xprocess.getinfo("server-%s" % port).terminate() + + diff --git a/deapi/tests/test_scanning/test_continual_scanning.py b/deapi/tests/test_scanning/test_continual_scanning.py index 684597be..4cc06733 100644 --- a/deapi/tests/test_scanning/test_continual_scanning.py +++ b/deapi/tests/test_scanning/test_continual_scanning.py @@ -5,28 +5,39 @@ Expected behavior: """ +import os import numpy as np import pytest from time import sleep - +import glob class TestContinualScanning: """Test class for continual scanning functionality.""" + @pytest.fixture + def tmp_path(self): + import tempfile + from pathlib import Path + temp_dir = Path("D:/temp") / f"test_{id(self)}" + temp_dir.mkdir(parents=True, exist_ok=True) + yield temp_dir + # Optional: cleanup + import shutil + shutil.rmtree(temp_dir, ignore_errors=True) + @pytest.fixture(autouse=True) def clean_state(self, client): # First set the hardware ROI to a known state - client["Hardware ROI Offset X"] = 0 - client["Hardware ROI Offset Y"] = 0 client["Hardware Binning X"] = 1 client["Hardware Binning Y"] = 1 - client["Hardware ROI Size X"] = 1024 - client["Hardware ROI Size Y"] = 1024 + client.set_adaptive_roi(size=(256, 256)) # set to a reduced size for faster testing + client["Frames Per Second"] = 100000 # set to max (will be capped by sever) client["Scan - Type"] = "Raster" # Set the software Binning to 1 client["Binning X"] = 1 client["Binning Y"] = 1 + client["Scan - Use DE Camera"] = "On" @pytest.mark.server def test_continual_scanning(self, client): @@ -110,3 +121,38 @@ def test_repeat_scanning_no_DE_camera(self, client): # After scan completion, verify the scan parameters result = client.get_result("external_image1") + + assert result.attributes.acq_index == 49 # 5x5x10 - 1 = 49 + + @pytest.mark.server + def test_saving_virtual_images(self, client, tmp_path): + """Test that virtual images are saved correctly during continual scanning. + + This should be a 3D image with dimensions (Repeats, Size Y, Size X). + """ + + client["Scan - Size X"] = 4 + client["Scan - Size Y"] = 4 + client["Scan - Repeats"] = 2 + client["Scan - Enable"] = True + client["Virtual Image 0 - Save To File"] = "On" + + client["Autosave Directory"] = str(tmp_path) + client["Autosave Virtual Image 0"] = "On" + + # Start the scan + client.start_acquisition() + while client.acquiring: + sleep(0.1) + + path = client["Autosave Virtual Image 0 File Path"] + + assert path.startswith(str(tmp_path)) + + # get the file size + osize = os.path.getsize(path) + + HEADER_SIZE = 1024 # Header size for a MRC file + expected_size = HEADER_SIZE + 2 * 4 * 4 # 2 repeats, 4x4 image, 4 bytes per pixel + + assert osize == expected_size From d7f214f76e924f744c7a42df440f0aed917c584d Mon Sep 17 00:00:00 2001 From: CSSFrancis Date: Tue, 3 Feb 2026 07:19:17 -0600 Subject: [PATCH 04/20] Enhancement: Update tests for continual scanning and adjust scan parameters --- .../test_scanning/test_continual_scanning.py | 31 ++++++++++++------- 1 file changed, 20 insertions(+), 11 deletions(-) diff --git a/deapi/tests/test_scanning/test_continual_scanning.py b/deapi/tests/test_scanning/test_continual_scanning.py index 4cc06733..76144246 100644 --- a/deapi/tests/test_scanning/test_continual_scanning.py +++ b/deapi/tests/test_scanning/test_continual_scanning.py @@ -31,17 +31,21 @@ def clean_state(self, client): # First set the hardware ROI to a known state client["Hardware Binning X"] = 1 client["Hardware Binning Y"] = 1 - client.set_adaptive_roi(size=(256, 256)) # set to a reduced size for faster testing + client.set_adaptive_roi(256, 256) # set to a reduced size for faster testing client["Frames Per Second"] = 100000 # set to max (will be capped by sever) client["Scan - Type"] = "Raster" # Set the software Binning to 1 client["Binning X"] = 1 client["Binning Y"] = 1 - client["Scan - Use DE Camera"] = "On" + client["Scan - Use DE Camera"] = "Use Frame Time" @pytest.mark.server def test_continual_scanning(self, client): - """Test continual scanning logic.""" + """Test continual scanning logic. + + This test fails when DE-Server hasn't switched to the 4D STEM tab??? Some other + variable needs to be set. + """ # Set up scan parameters client["Scan - Size X"] = 8 client["Scan - Size Y"] = 8 @@ -59,7 +63,13 @@ def test_continual_scanning(self, client): sleep(0.1) # After scan completion, verify the scan parameters - assert client["Frame Count"] == 8 * 8 * 3 + sleep(3) # wait for any finalization + + #assert client["Frame Count"] == 8 * 8 * 3 + print("Frame Count:",client["Frame Count"]) + result = client.get_result() + print("Aq:",result.attributes.acqIndex) + @pytest.mark.server def test_sending_multiple_scan_patterns(self, client): @@ -78,6 +88,8 @@ def test_sending_multiple_scan_patterns(self, client): client.start_acquisition() while client.acquiring: sleep(0.1) + + sleep(5) assert client["Frame Count"] == 4 * 100 @pytest.mark.server @@ -92,7 +104,7 @@ def test_multiple_scan_patterns_different_lengths(self, client): scans = [scan1, scan2, scan3] client.set_xy_array(scans, height=10, width=10) - for i, num_points in enumerate([2, 3, 4]): + for i, num_points in enumerate([4, 5, 6]): client["Scan - Repeats"] = 1 client["Scan - XY File Pattern ID"] = i client["Scan - Enable"] = True @@ -122,7 +134,6 @@ def test_repeat_scanning_no_DE_camera(self, client): result = client.get_result("external_image1") - assert result.attributes.acq_index == 49 # 5x5x10 - 1 = 49 @pytest.mark.server def test_saving_virtual_images(self, client, tmp_path): @@ -131,8 +142,8 @@ def test_saving_virtual_images(self, client, tmp_path): This should be a 3D image with dimensions (Repeats, Size Y, Size X). """ - client["Scan - Size X"] = 4 - client["Scan - Size Y"] = 4 + client["Scan - Size X"] = 32 + client["Scan - Size Y"] = 32 client["Scan - Repeats"] = 2 client["Scan - Enable"] = True client["Virtual Image 0 - Save To File"] = "On" @@ -147,12 +158,10 @@ def test_saving_virtual_images(self, client, tmp_path): path = client["Autosave Virtual Image 0 File Path"] - assert path.startswith(str(tmp_path)) - # get the file size osize = os.path.getsize(path) HEADER_SIZE = 1024 # Header size for a MRC file - expected_size = HEADER_SIZE + 2 * 4 * 4 # 2 repeats, 4x4 image, 4 bytes per pixel + expected_size = HEADER_SIZE + 2 * 32 * 32 * 4 # 2 repeats, 8x8 image, 4 bytes per pixel assert osize == expected_size From ca044d90d36c80e1fc94ea85be2e7d7731dca2e2 Mon Sep 17 00:00:00 2001 From: CSSFrancis Date: Tue, 24 Feb 2026 15:41:08 -0600 Subject: [PATCH 05/20] Enhancement: Improve scanning performance and add tests for multiple scan patterns --- deapi/client.py | 8 +- .../test_scanning/test_continual_scanning.py | 157 +++++++++++++++++- 2 files changed, 155 insertions(+), 10 deletions(-) diff --git a/deapi/client.py b/deapi/client.py index d228cf00..176b1361 100644 --- a/deapi/client.py +++ b/deapi/client.py @@ -1465,12 +1465,14 @@ def set_xy_array(self, positions, width=None, height=None): if ret: try: # convert to bytes and send - + tic =time.time() for pos in positions: x = pos[:, 0].tobytes() y = pos[:, 1].tobytes() self.__sendToSocket(self.socket, x, len(x)) self.__sendToSocket(self.socket, y, len(y)) + toc = time.time() + log.info(f"Time to send {len(positions)} Scan Patterns: {toc-tic} s") except socket.error as e: log.log(logging.ERROR, "Error sending data to socket: %s", e) return False @@ -1489,11 +1491,11 @@ def set_xy_array(self, positions, width=None, height=None): def get_result( self, frame_type: Union[FrameType, str] = "singleframe_integrated", - pixel_format: Union[PixelFormat, str] = "UINT16", + pixel_format: Union[PixelFormat, str] = "AUTO", attributes="auto", histogram=None, **kwargs, - ): + ) -> Result: """ Get the specified type of frames in the desired pixel format and associated information. diff --git a/deapi/tests/test_scanning/test_continual_scanning.py b/deapi/tests/test_scanning/test_continual_scanning.py index 76144246..b47e8856 100644 --- a/deapi/tests/test_scanning/test_continual_scanning.py +++ b/deapi/tests/test_scanning/test_continual_scanning.py @@ -77,20 +77,22 @@ def test_sending_multiple_scan_patterns(self, client): properly cycle through the patterns for the specified number of repeats. """ - scan1 = np.array([[0, 0], [1, 0], [1, 1], [0, 1]]) + scan1 = np.array([[0, 0], [1, 0], [1, 1], [0, 1],[2, 1], [2, 0], [0,2], [1,2]] ) # 8 points) scans = [] - for i in range(10): + for i in range(5): scans.append(scan1 + 2 * i) - client.set_xy_array(scans, height=40, width=40) + client.set_xy_array(scans, height=10, width=10) client["Scan - Repeats"] = 100 client["Scan - Enable"] = True + client["Scan - Camera Frames Per Point"] = 1 + client["Scan - Repeat Delay"] client.start_acquisition() while client.acquiring: sleep(0.1) sleep(5) - assert client["Frame Count"] == 4 * 100 + assert client["Frame Count"]- client["Actual Frames to Ignore"] *10 ==8 * 100 @pytest.mark.server def test_multiple_scan_patterns_different_lengths(self, client): @@ -101,7 +103,7 @@ def test_multiple_scan_patterns_different_lengths(self, client): scan1 = np.array([[0, 0], [1, 0], [1, 1], [0, 1]]) # 4 points scan2 = np.array([[0, 0], [2, 0], [2, 2], [0, 2], [1, 1]]) # 5 points scan3 = np.array([[0, 0], [3, 0], [3, 3], [0, 3], [1, 1], [2, 2]]) # 6 points - scans = [scan1, scan2, scan3] + scans = [scan3, scan2, scan1] client.set_xy_array(scans, height=10, width=10) for i, num_points in enumerate([4, 5, 6]): @@ -111,7 +113,32 @@ def test_multiple_scan_patterns_different_lengths(self, client): client.start_acquisition() while client.acquiring: sleep(0.1) - assert client["Frame Count"] == num_points + print(client["Frame Count"]) + # assert client["Frame Count"] == num_points + + @pytest.mark.server + def test_send_100_patterns(self, client): + """Test sending 100 scan patterns. + + This is to test the stability of the system when handling a large number of patterns. + """ + # create 1000 patterns that are 1k x 1k in size with only 10% of the points filled in. + state = np.random.RandomState(0) + coords = [] + + for i in range(100): + mask = np.ones((128, 128), dtype=bool) + # randomly remove 95% of the points + mask[state.random(mask.shape) < 0.95] = False + # turn the mask into a list of xy coordinates + coords.append(np.argwhere(mask)) + + client.set_xy_array(coords, height=128, width=128) + client["Scan - Repeats"] = 100 + client["Scan - Enable"] = True + #client.start_acquisition() + #while client.acquiring: + # sleep(0.1) @pytest.mark.server def test_repeat_scanning_no_DE_camera(self, client): @@ -146,7 +173,7 @@ def test_saving_virtual_images(self, client, tmp_path): client["Scan - Size Y"] = 32 client["Scan - Repeats"] = 2 client["Scan - Enable"] = True - client["Virtual Image 0 - Save To File"] = "On" + client["Use DE Camera"] = "Use Frame Time" client["Autosave Directory"] = str(tmp_path) client["Autosave Virtual Image 0"] = "On" @@ -156,6 +183,7 @@ def test_saving_virtual_images(self, client, tmp_path): while client.acquiring: sleep(0.1) + sleep(3) # wait for any finalization path = client["Autosave Virtual Image 0 File Path"] # get the file size @@ -165,3 +193,118 @@ def test_saving_virtual_images(self, client, tmp_path): expected_size = HEADER_SIZE + 2 * 32 * 32 * 4 # 2 repeats, 8x8 image, 4 bytes per pixel assert osize == expected_size + + @pytest.mark.server + def test_frame_repeats(self,client): + """Test that frame repeats work correctly during continual scanning. + + This should repeat each frame the specified number of times before moving to the next position. + """ + client["Scan - Size X"] = 4 + client["Scan - Size Y"] = 4 + client["Scan - Repeats"] = 2 + client["Scan - Camera Frames Per Point"] = 8 + client["Scan - Enable"] = True + client["Test Pattern"] = "SW Frame Number" + client["Use DE Camera"] = "Use Frame Time" + + # Start the scan + client.start_acquisition() + while client.acquiring: + sleep(0.1) + + res_1=client.get_result("virtual_image0", pixel_format="AUTO" ) + + # num pixels/frame + n_pix = client["Image Size X (pixels)"] * client["Image Size Y (pixels)"] + print(n_pix) + frame_number = (res_1.image / n_pix) / client["Scan - Camera Frames Per Point"] + print(frame_number) + + + @pytest.mark.server + def test_frame_repeats_auto_save(self,client): + """Test that frame repeats work correctly during continual scanning. + + This should repeat each frame the specified number of times before moving to the next position. + """ + client["Scan - Size X"] = 16 + client["Scan - Size Y"] = 16 + client["Scan - Repeats"] = 1 + client["Scan - Camera Frames Per Point"] = 8 + client["Scan - Enable"] = True + client["Test Pattern"] = "SW Frame Number" + client["Autosave Movie"] = "On" + client["Autosave Virtual Image 0"] = "On" + client["Autosave 4D File Format"] = "MRC" + client["Use DE Camera"] = "Use Frame Time" + + # Start the scan + client.start_acquisition() + while client.acquiring: + + sleep(0.1) + print(client["Acquisition Status"]) + sleep(3) + res_1=client.get_result("virtual_image0", pixel_format="AUTO" ) + + # num pixels/frame + n_pix = client["Image Size X (pixels)"] * client["Image Size Y (pixels)"] + print(n_pix) + frame_number = (res_1.image / n_pix) / client["Scan - Camera Frames Per Point"] + print("frame1", frame_number) + + client["Scan - Camera Frames Per Point"] = 4 + client["Scan - Repeats"] = 3 + + client.start_acquisition() + while client.acquiring: + sleep(0.1) + sleep(3) + print(client["Acquisition Status"]) + res_2=client.get_result("virtual_image0", pixel_format="AUTO" ) + frame_number_2 = (res_2.image / n_pix) / client["Scan - Camera Frames Per Point"] + print("frame2", frame_number_2) + + # test the size of the result... + + path = client["Autosave Movie Frames File Path"] + + # get the file size + osize = os.path.getsize(path) + + HEADER_SIZE = 1024 # Header size for a MRC file + image_size = client["Image Size X (pixels)"] * client["Image Size Y (pixels)"] * 2 # 4 bytes per pixel + expected_size = HEADER_SIZE + (image_size* + client["Scan - Size X"] * + client["Scan - Size Y"] * + client["Scan - Repeats"]) + + assert osize == expected_size + + client["Scan - Camera Frames Per Point"] = 8 + client["Scan - Repeats"] = 2 + + client.start_acquisition() + while client.acquiring: + sleep(0.1) + sleep(1) + res_2 = client.get_result("virtual_image0", pixel_format="AUTO") + frame_number_2 = (res_2.image / n_pix) / client["Scan - Camera Frames Per Point"] + print("frame3",frame_number_2) + # test the size of the result... + + path = client["Autosave Movie Frames File Path"] + + # get the file size + osize = os.path.getsize(path) + + HEADER_SIZE = 1024 # Header size for a MRC file + image_size = client["Image Size X (pixels)"] * client["Image Size Y (pixels)"] * 2 # 2 bytes per pixel + expected_size = HEADER_SIZE + (image_size * + client["Scan - Size X"] * + client["Scan - Size Y"] * + client["Scan - Repeats"]) # 2 bytes per pixel + + assert osize == expected_size + From 44744f48978f7c1ca7a7c0f79099bca212eeede4 Mon Sep 17 00:00:00 2001 From: CSSFrancis Date: Tue, 10 Mar 2026 09:01:52 -0500 Subject: [PATCH 06/20] Enhancement: Add tests for initial delay and flyback time in continual scanning --- .../test_scanning/test_continual_scanning.py | 119 ++++++++++++++++++ 1 file changed, 119 insertions(+) diff --git a/deapi/tests/test_scanning/test_continual_scanning.py b/deapi/tests/test_scanning/test_continual_scanning.py index b47e8856..38d97a37 100644 --- a/deapi/tests/test_scanning/test_continual_scanning.py +++ b/deapi/tests/test_scanning/test_continual_scanning.py @@ -38,6 +38,13 @@ def clean_state(self, client): client["Binning X"] = 1 client["Binning Y"] = 1 client["Scan - Use DE Camera"] = "Use Frame Time" + client["Scan - Enable"] = True + client["Scan - Initial Delay (microseconds)"] = 0 + client["Scan - Flyback Time Going Positive (microseconds)"] = 0 + client["Scan - Flyback Time Going Negative (microseconds)"] = 0 + client["Scan - Repeats"] = 1 + client["Scan - Repeat Delay (seconds)"] = 0 + @pytest.mark.server def test_continual_scanning(self, client): @@ -308,3 +315,115 @@ def test_frame_repeats_auto_save(self,client): assert osize == expected_size + + @pytest.mark.parametrize("fly_back_time", [0, 1000, 5000]) + @pytest.mark.server + def test_hidden_scan_points_single_scan(self, client,fly_back_time): + """Test that hidden scan points are properly ignored during continual scanning. + + This should ensure that points marked as hidden are not included in the scan pattern. + """ + size_x = 16 + size_y = 16 + client["Scan - Use DE Camera"] = "Off" + client["Scan - Size X"] = size_x + client["Scan - Size Y"] = size_y + client["Scan - Dwell Time (microseconds)"] = 1000 + + # Recorded Scan points = 16 x 16 = 256 + client["Scan - Flyback Time Going Positive (microseconds)"] = fly_back_time + client["Scan - Flyback Time Going Negative (microseconds)"] = 0 + client["Scan - Repeat Delay (seconds)"] = 0 + client["Scan - Initial Delay (microseconds)"] = 0 + fly_back_time = client["Scan - Flyback Time Going Positive (microseconds)"] + points_per_row = np.ceil(fly_back_time/client["Scan - Dwell Time (microseconds)"]) + + assert client["Scan - Flyback Time Going Positive (count)"] == points_per_row + total_points = size_x * size_y + size_y * points_per_row + print("Total points:", total_points) + assert client["Scan - Points (Total)"] == total_points + assert client["Scan - Points (Hidden)"] == size_y * points_per_row + assert client["Scan - Points (Visible)"] == size_x * size_y + + @pytest.mark.parametrize("initial_delay", [0, 1000, 5000]) + @pytest.mark.server + def test_initial_delay(self, client, initial_delay): + """Test that initial delay is properly applied during continual scanning. + + This should ensure that the specified initial delay is observed before the scan starts. + """ + size_x = 16 + size_y = 16 + client["Scan - Use DE Camera"] = "Off" + client["Scan - Size X"] = size_x + client["Scan - Size Y"] = size_y + client["Scan - Dwell Time (microseconds)"] = 1000 + client["Scan - Initial Delay (microseconds)"] = initial_delay + + extra_points = np.ceil(initial_delay/client["Scan - Dwell Time (microseconds)"]) + total_points = size_x * size_y + extra_points + print("Total points:", total_points) + assert client["Scan - Points (Total)"] == total_points + assert client["Scan - Points (Hidden)"] == extra_points + assert client["Scan - Points (Visible)"] == size_x * size_y + + + @pytest.mark.parametrize("initial_delay", [0, 1000, 5000]) + @pytest.mark.server + def test_initial_delay_repeats(self, client, initial_delay): + """Test that initial delay is properly applied during continual scanning. + + This should ensure that the specified initial delay is observed before the scan starts. + """ + size_x = 16 + size_y = 16 + repeats = 5 + client["Scan - Use DE Camera"] = "Off" + client["Scan - Size X"] = size_x + client["Scan - Size Y"] = size_y + client["Scan - Repeats"] = repeats + client["Scan - Dwell Time (microseconds)"] = 1000 + client["Scan - Initial Delay (microseconds)"] = initial_delay + + extra_points = np.ceil(initial_delay/client["Scan - Dwell Time (microseconds)"]) + total_points = size_x * size_y * repeats + extra_points * repeats + print("Total points:", total_points) + print("Extra points:", client["Scan - Points (Total)"] - total_points) + + assert client["Scan - Initial Delay Count"] == extra_points # For 1 Scan + + assert client["Scan - Points (Total)"] == total_points # For all repeats + assert client["Scan - Points (Hidden)"] == extra_points * repeats # For all repeats + assert client["Scan - Points (Visible)"] == size_x * size_y * repeats # For all repeats + + assert client["Actual Frames to Ignore"] == extra_points * repeats + + @pytest.mark.parametrize("fly_back_time", [0, 1000, 5000]) + @pytest.mark.server + def test_flyback_time_repeats(self, client, fly_back_time): + """Test that flyback time is properly applied during continual scanning. + + This should ensure that the specified flyback time is observed between rows during the scan. + """ + size_x = 16 + size_y = 16 + repeats = 5 + client["Scan - Use DE Camera"] = "Off" + client["Scan - Size X"] = size_x + client["Scan - Size Y"] = size_y + client["Scan - Repeats"] = repeats + client["Scan - Dwell Time (microseconds)"] = 1000 + client["Scan - Flyback Time Going Positive (microseconds)"] = fly_back_time + + points_per_row = np.ceil(fly_back_time/client["Scan - Dwell Time (microseconds)"]) + client["Scan - Flyback Time Going Negative (count)"] = points_per_row + + total_points = (size_x * size_y + size_y * points_per_row) * repeats + print("Total points:", total_points) + assert client["Scan - Points (Total)"] == total_points + assert client["Scan - Points (Hidden)"] == size_y * points_per_row * repeats + assert client["Scan - Points (Visible)"] == size_x * size_y * repeats + + #assert client["Actual Frames to Ignore"] == size_y * points_per_row * repeats + + assert client["Number of Frames To Grab"] == total_points \ No newline at end of file From 7e1c87311ad3605a3ddff9dcc70d080b6a468ca7 Mon Sep 17 00:00:00 2001 From: CSSFrancis Date: Mon, 30 Mar 2026 11:07:05 -0500 Subject: [PATCH 07/20] Enhancement: Update tests for flyback time and scan points in continual scanning --- .../test_file_loading_libertem.py | 3 +- .../test_scanning/test_continual_scanning.py | 31 +++++++++---------- 2 files changed, 16 insertions(+), 18 deletions(-) diff --git a/deapi/tests/test_file_saving/test_file_loading_libertem.py b/deapi/tests/test_file_saving/test_file_loading_libertem.py index 8eb7dfaa..b1509976 100644 --- a/deapi/tests/test_file_saving/test_file_loading_libertem.py +++ b/deapi/tests/test_file_saving/test_file_loading_libertem.py @@ -5,7 +5,6 @@ work. """ -import libertem.api as lt import pytest import os import glob @@ -15,6 +14,7 @@ class TestLoadingLiberTEM: @pytest.fixture(autouse=True) def clean_state(self, client): + # First set the hardware ROI to a known state client["Hardware ROI Offset X"] = 0 client["Hardware ROI Offset Y"] = 0 @@ -38,6 +38,7 @@ def clean_state(self, client): ) # MRC file loading in LiberTEM is broken! @pytest.mark.server def test_save_4DSTEM(self, client, file_format): + import libertem.api as lt if not os.path.exists("D:\Temp"): os.mkdir("D:\Temp") temp_dir = "D:\Temp" diff --git a/deapi/tests/test_scanning/test_continual_scanning.py b/deapi/tests/test_scanning/test_continual_scanning.py index 38d97a37..321b982a 100644 --- a/deapi/tests/test_scanning/test_continual_scanning.py +++ b/deapi/tests/test_scanning/test_continual_scanning.py @@ -316,7 +316,7 @@ def test_frame_repeats_auto_save(self,client): assert osize == expected_size - @pytest.mark.parametrize("fly_back_time", [0, 1000, 5000]) + @pytest.mark.parametrize("fly_back_time", [0, 1000, 4000]) @pytest.mark.server def test_hidden_scan_points_single_scan(self, client,fly_back_time): """Test that hidden scan points are properly ignored during continual scanning. @@ -341,9 +341,9 @@ def test_hidden_scan_points_single_scan(self, client,fly_back_time): assert client["Scan - Flyback Time Going Positive (count)"] == points_per_row total_points = size_x * size_y + size_y * points_per_row print("Total points:", total_points) - assert client["Scan - Points (Total)"] == total_points - assert client["Scan - Points (Hidden)"] == size_y * points_per_row - assert client["Scan - Points (Visible)"] == size_x * size_y + assert client["Scan - Points"] == total_points + assert client["Scan - Points (Not Recorded)"] == size_y * points_per_row + assert client["Scan - Points (Recorded)"] == size_x * size_y @pytest.mark.parametrize("initial_delay", [0, 1000, 5000]) @pytest.mark.server @@ -363,9 +363,9 @@ def test_initial_delay(self, client, initial_delay): extra_points = np.ceil(initial_delay/client["Scan - Dwell Time (microseconds)"]) total_points = size_x * size_y + extra_points print("Total points:", total_points) - assert client["Scan - Points (Total)"] == total_points - assert client["Scan - Points (Hidden)"] == extra_points - assert client["Scan - Points (Visible)"] == size_x * size_y + assert client["Scan - Points"] == total_points + assert client["Scan - Points (Not Recorded)"] == extra_points + assert client["Scan - Points (Recorded)"] == size_x * size_y @pytest.mark.parametrize("initial_delay", [0, 1000, 5000]) @@ -392,9 +392,9 @@ def test_initial_delay_repeats(self, client, initial_delay): assert client["Scan - Initial Delay Count"] == extra_points # For 1 Scan - assert client["Scan - Points (Total)"] == total_points # For all repeats - assert client["Scan - Points (Hidden)"] == extra_points * repeats # For all repeats - assert client["Scan - Points (Visible)"] == size_x * size_y * repeats # For all repeats + assert client["Scan - Points"] == total_points # For all repeats + assert client["Scan - Points (Not Recorded)"] == extra_points * repeats # For all repeats + assert client["Scan - Points (Recorded)"] == size_x * size_y * repeats # For all repeats assert client["Actual Frames to Ignore"] == extra_points * repeats @@ -413,16 +413,13 @@ def test_flyback_time_repeats(self, client, fly_back_time): client["Scan - Size Y"] = size_y client["Scan - Repeats"] = repeats client["Scan - Dwell Time (microseconds)"] = 1000 - client["Scan - Flyback Time Going Positive (microseconds)"] = fly_back_time - + client["Scan - Flyback Time (microseconds)"] = fly_back_time points_per_row = np.ceil(fly_back_time/client["Scan - Dwell Time (microseconds)"]) - client["Scan - Flyback Time Going Negative (count)"] = points_per_row - total_points = (size_x * size_y + size_y * points_per_row) * repeats print("Total points:", total_points) - assert client["Scan - Points (Total)"] == total_points - assert client["Scan - Points (Hidden)"] == size_y * points_per_row * repeats - assert client["Scan - Points (Visible)"] == size_x * size_y * repeats + assert client["Scan - Points"] == total_points + assert client["Scan - Points (Not Recorded)"] == size_y * points_per_row * repeats + assert client["Scan - Points (Recorded)"] == size_x * size_y * repeats #assert client["Actual Frames to Ignore"] == size_y * points_per_row * repeats From 29664b1c59e7fcd5b023dbd17458bc1bd275be7e Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Mon, 4 May 2026 12:43:54 -0500 Subject: [PATCH 08/20] Enhancement: Refactor client tests for improved state management and performance --- deapi/client.py | 1 + deapi/tests/conftest.py | 3 +- deapi/tests/test_client.py | 58 ++++++------------ .../test_file_loading_rsciio.py | 2 +- .../test_scan_pattern_saving.py | 1 + .../test_scanning/test_continual_scanning.py | 57 ++++++++++-------- deapi/tests/test_utils/test_utils.py | 59 ++++++++----------- 7 files changed, 80 insertions(+), 101 deletions(-) diff --git a/deapi/client.py b/deapi/client.py index 176b1361..be2a17f0 100644 --- a/deapi/client.py +++ b/deapi/client.py @@ -1306,6 +1306,7 @@ def stop_acquisition(self): sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) # UDP sock.sendto(b"PyClientStopAcq", (self.host, self.port)) respond = sock.recv(32) + if logLevel == logging.INFO: log.info(f"{self.host} {self.port} {respond}") if logLevel <= logging.DEBUG: diff --git a/deapi/tests/conftest.py b/deapi/tests/conftest.py index b41ee744..f300f922 100644 --- a/deapi/tests/conftest.py +++ b/deapi/tests/conftest.py @@ -158,7 +158,7 @@ class Starter(ProcessStarter): return -@pytest.fixture(scope="module") +@pytest.fixture(scope="session") def client(xprocess, request): if request.config.getoption("--server"): c = Client() @@ -174,7 +174,6 @@ def client(xprocess, request): enable=True, password=request.config.getoption("--engineering") ) yield c - time.sleep(4) c.disconnect() return else: diff --git a/deapi/tests/test_client.py b/deapi/tests/test_client.py index 3de70cd4..d7c78ee7 100644 --- a/deapi/tests/test_client.py +++ b/deapi/tests/test_client.py @@ -1,5 +1,3 @@ -import time - import numpy as np from deapi import Client, Histogram @@ -18,6 +16,8 @@ class TestClient: @pytest.fixture(autouse=True) def clean_state(self, client): # First set the hardware ROI to a known state + client.stop_acquisition() + wait_for_idle(client, timeout=10) client["Hardware ROI Offset X"] = 0 client["Hardware ROI Offset Y"] = 0 client["Hardware Binning X"] = 1 @@ -25,12 +25,12 @@ def clean_state(self, client): client["Hardware ROI Size X"] = 1024 client["Hardware ROI Size Y"] = 1024 client["Scan - Type"] = "Raster" + client["Frames Per Second"] = 1000 # Set the software Binning to 1 client["Binning X"] = 1 client["Binning Y"] = 1 - - def teardown(self): - time.sleep(0.1) + client["Crop Offset X"] = 0 + client["Crop Offset Y"] = 0 def test_client_connection(self, client): assert client.connected @@ -54,7 +54,6 @@ def test_enable_scan(self, client): assert client["Scan - Enable"] == "On" def test_start_acquisition(self, client): - client["Frames Per Second"] = 1000 client.scan(size_x=10, size_y=10, enable="On") client.start_acquisition(1) assert client.acquiring @@ -62,22 +61,14 @@ def test_start_acquisition(self, client): assert not client.acquiring def test_start_acquisition_scan_disabled(self, client): - client["Frames Per Second"] = 1000 client.scan(enable="Off") - client.start_acquisition(10) + client.start_acquisition(1) assert client.acquiring wait_for_idle(client) assert not client.acquiring def test_get_result(self, client): - client["Frames Per Second"] = 1000 client.scan(size_x=3, size_y=3, enable="On") - assert client["Hardware ROI Size X"] == 1024 - assert client["Hardware ROI Size Y"] == 1024 - assert client["Hardware Binning X"] == 1 - assert client["Hardware Binning Y"] == 1 - assert client["Hardware ROI Offset X"] == 0 - assert client["Hardware ROI Offset Y"] == 0 client.start_acquisition(1) wait_for_idle(client) result = client.get_result() @@ -88,7 +79,6 @@ def test_get_result(self, client): @pytest.mark.server def test_get_histogram(self, client): - client["Frames Per Second"] = 1000 client.scan(size_x=10, size_y=10, enable="On") client.start_acquisition(1) wait_for_idle(client) @@ -97,7 +87,6 @@ def test_get_histogram(self, client): result[3].plot() def test_get_result_no_scan(self, client): - client["Frames Per Second"] = 1000 client.scan(enable="Off") client.start_acquisition(1) result = client.get_result("singleframe_integrated") @@ -125,6 +114,7 @@ def test_binning(self, client, binx): def test_get_virtual_mask(self, client): client.virtual_masks[1][:] = 1 + client["Scan - Virtual Detector 1 Shape"] = "Arbitrary" assert isinstance(client.virtual_masks[1], VirtualMask) assert isinstance(client.virtual_masks[1][:], np.ndarray) np.testing.assert_allclose(client.virtual_masks[1][:], 1) @@ -183,6 +173,7 @@ def test_bin_property_set(self, client): @pytest.mark.server @pytest.mark.parametrize("bin_sw", [1, 2, 4]) def test_property_spec_set(self, client, bin_sw): + client.set_property("Hardware Binning X", 1) client.set_property("Hardware Binning Y", 1) client.set_property("Binning Y", bin_sw) sp = client.get_property_spec("Binning Y") @@ -192,6 +183,7 @@ def test_property_spec_set(self, client, bin_sw): sp.options == "'1*', '2', '4', '8', '16', '32', '64', '128', '256', '512', '1024'" ) + client.set_property("Hardware Binning X", 2) client.set_property("Hardware Binning Y", 2) sp = client.get_property_spec("Binning Y") assert sp.currentValue == str(bin_sw) @@ -204,17 +196,6 @@ def test_property_spec_set(self, client, bin_sw): @pytest.mark.parametrize("size", [512, 256]) @pytest.mark.parametrize("bin_sw", [1, 2, 4]) def test_image_size(self, client, bin, offsetx, size, bin_sw): - client["Hardware ROI Offset X"] = 0 - client["Hardware ROI Offset Y"] = 0 - client["Hardware ROI Size X"] = 1024 - client["Hardware ROI Size Y"] = 1024 - client["Hardware Binning X"] = 1 - client["Hardware Binning Y"] = 1 - client["Binning X"] = 1 - client["Binning Y"] = 1 - client["Crop Offset X"] = 0 - client["Crop Offset Y"] = 0 - assert client["Image Size X (pixels)"] == 1024 assert client["Image Size Y (pixels)"] == 1024 @@ -239,7 +220,7 @@ def test_image_size(self, client, bin, offsetx, size, bin_sw): assert client["Image Size X (pixels)"] == size // bin_sw // bin def test_stream_data(self, client): - client["Frames Per Second"] = 5 + client["Frames Per Second"] = 100 client.scan(size_x=10, size_y=10, enable="On") client.start_acquisition(1, request_movie_buffer=True) numberFrames = 0 @@ -283,7 +264,7 @@ def test_stream_data(self, client): index += 1 if not success: break - time.sleep(4) + wait_for_idle(client) @pytest.mark.server def test_set_xy_array(self, client): @@ -311,28 +292,28 @@ def test_set_xy_array(self, client): @pytest.mark.server def test_gain_reference(self, client): client["Test Pattern"] = "SW Constant 400" - client.TakeDarkReference(100) # take a dark reference first with 400 ADU + client.TakeDarkReference(100, acquisitions =1) # take a dark reference first with 400 ADU client["Test Pattern"] = "SW Constant 1600" - client.take_gain_reference(100, target_electrons_per_pixel=1000, counting=False) + client.take_gain_reference(100, target_electrons_per_pixel=10, counting=False, num_acq=1) @pytest.mark.server def test_gain_reference_too_bright(self, client): client["Test Pattern"] = "SW Constant 1" - client.TakeDarkReference(100) # take a dark reference first with 400 ADU + client.TakeDarkReference(100, acquisitions=1) # take a dark reference first with 400 ADU client["Test Pattern"] = "SW Gaussian M1600 D200" with pytest.raises(ValueError): client.take_gain_reference( - 100, target_electrons_per_pixel=1000, counting=False + 100, target_electrons_per_pixel=10, counting=False ) @pytest.mark.server def test_get_trial_gain_reference(self, client): client["Scan - Enable"] = "Off" client["Test Pattern"] = "SW Constant 400" - client.take_dark_reference(10) # take a dark reference first with 400 ADU + client.take_dark_reference(100, acquisitions=1) # take a dark reference first with 400 ADU client["Test Pattern"] = "SW Constant 1600" # others don't work?? - exposure, num_acquire, el = client.take_trial_gain_reference(10) + exposure, num_acquire, el = client.take_trial_gain_reference(10, target_electrons_per_pixel=10) assert exposure == 1 assert el > 0 @@ -341,8 +322,8 @@ def test_flip_dark_reference(self, client): """Test to make sure that the dark reference is still correct after flipping.""" client["Scan - Enable"] = "Off" client["Test Pattern"] = "SW Gradient Diagonal" - client["Frames Per Second"] = 10 - client.take_dark_reference(frame_rate=10) + client["Frames Per Second"] = 100 + client.take_dark_reference(frame_rate=100, acquisitions=1) client["Image Processing - Flatfield Correction"] = "Dark" client.start_acquisition(1) wait_for_idle(client) @@ -350,7 +331,6 @@ def test_flip_dark_reference(self, client): np.testing.assert_array_equal(image, 0) client["Image Processing - Flip Horizontally"] = "On" - client["Exposure Time (seconds)"] = 1 client.start_acquisition(1) wait_for_idle(client) image = client.get_result()[0] diff --git a/deapi/tests/test_file_saving/test_file_loading_rsciio.py b/deapi/tests/test_file_saving/test_file_loading_rsciio.py index 22ceaaa6..aaece6da 100644 --- a/deapi/tests/test_file_saving/test_file_loading_rsciio.py +++ b/deapi/tests/test_file_saving/test_file_loading_rsciio.py @@ -29,6 +29,7 @@ def clean_state(self, client): @pytest.mark.parametrize("file_format", ["MRC", "DE5", "HSPY"]) @pytest.mark.server + @pytest.mark.skip(reason="Slow and broken") def test_save_4DSTEM(self, client, file_format): if not os.path.exists("D:\Temp"): os.mkdir("D:\Temp") @@ -45,7 +46,6 @@ def test_save_4DSTEM(self, client, file_format): client.start_acquisition(1) while client.acquiring: time.sleep(0.1) - time.sleep(1) assert file_format.lower() in client["Autosave Movie Frames File Path"] s = hs.load(client["Autosave Movie Frames File Path"]) if file_format == "MRC": diff --git a/deapi/tests/test_file_saving/test_scan_pattern_saving.py b/deapi/tests/test_file_saving/test_scan_pattern_saving.py index 5cfe8554..4935fe96 100644 --- a/deapi/tests/test_file_saving/test_scan_pattern_saving.py +++ b/deapi/tests/test_file_saving/test_scan_pattern_saving.py @@ -15,6 +15,7 @@ class TestSavingScans: @pytest.mark.parametrize("buffer", [2, 16]) @pytest.mark.parametrize("file_format", ["HSPY", "MRC"]) @pytest.mark.server + @pytest.mark.skip(reason="Slow and broken") def test_save_scans(self, client, scan_type, buffer, file_format): if client.acquiring: client.stop_acquisition() diff --git a/deapi/tests/test_scanning/test_continual_scanning.py b/deapi/tests/test_scanning/test_continual_scanning.py index 321b982a..57676d14 100644 --- a/deapi/tests/test_scanning/test_continual_scanning.py +++ b/deapi/tests/test_scanning/test_continual_scanning.py @@ -29,6 +29,7 @@ def tmp_path(self): @pytest.fixture(autouse=True) def clean_state(self, client): # First set the hardware ROI to a known state + client.stop_acquisition() client["Hardware Binning X"] = 1 client["Hardware Binning Y"] = 1 client.set_adaptive_roi(256, 256) # set to a reduced size for faster testing @@ -40,8 +41,7 @@ def clean_state(self, client): client["Scan - Use DE Camera"] = "Use Frame Time" client["Scan - Enable"] = True client["Scan - Initial Delay (microseconds)"] = 0 - client["Scan - Flyback Time Going Positive (microseconds)"] = 0 - client["Scan - Flyback Time Going Negative (microseconds)"] = 0 + client["Scan - Flyback Time (microseconds)"] = 0 client["Scan - Repeats"] = 1 client["Scan - Repeat Delay (seconds)"] = 0 @@ -84,22 +84,21 @@ def test_sending_multiple_scan_patterns(self, client): properly cycle through the patterns for the specified number of repeats. """ - scan1 = np.array([[0, 0], [1, 0], [1, 1], [0, 1],[2, 1], [2, 0], [0,2], [1,2]] ) # 8 points) + scan1 = np.array([[0, 0], [1, 0], [1, 1], [0, 1],[2, 1], [2, 0], [0,2], [1,2]] *10) # 80 points scans = [] for i in range(5): scans.append(scan1 + 2 * i) client.set_xy_array(scans, height=10, width=10) - - client["Scan - Repeats"] = 100 + reps = 6 + client["Scan - Repeats"] = 6 client["Scan - Enable"] = True client["Scan - Camera Frames Per Point"] = 1 - client["Scan - Repeat Delay"] + client["Frames Per Second"] = 100 client.start_acquisition() while client.acquiring: sleep(0.1) - - sleep(5) - assert client["Frame Count"]- client["Actual Frames to Ignore"] *10 ==8 * 100 + # should "Actual Frames to Ignore" be scaled by the number of reps. + assert client["Frame Count"] - (client["Actual Frames to Ignore"]) == 80 * reps @pytest.mark.server def test_multiple_scan_patterns_different_lengths(self, client): @@ -107,22 +106,25 @@ def test_multiple_scan_patterns_different_lengths(self, client): Different patterns will be run depending on the index set by: `Scan - XY File Pattern ID` """ - scan1 = np.array([[0, 0], [1, 0], [1, 1], [0, 1]]) # 4 points - scan2 = np.array([[0, 0], [2, 0], [2, 2], [0, 2], [1, 1]]) # 5 points - scan3 = np.array([[0, 0], [3, 0], [3, 3], [0, 3], [1, 1], [2, 2]]) # 6 points - scans = [scan3, scan2, scan1] + #min points is buffer size + frames_per_buffer = client["Grabbing - Frames Per Buffer"] + scan1 = np.array([[0, 0], [1, 0], [1, 1], [0, 1]]*10) # 40 points + scan2 = np.array([[0, 0], [2, 0], [2, 2], [0, 2], [1, 1]]*10) # 50 points + scan3 = np.array([[0, 0], [3, 0], [3, 3], [0, 3], [1, 1], [2, 2]]*10) # 60 points + scans = [scan1, scan2, scan3] client.set_xy_array(scans, height=10, width=10) - for i, num_points in enumerate([4, 5, 6]): + for i, num_points in enumerate([40, 50, 60]): client["Scan - Repeats"] = 1 client["Scan - XY File Pattern ID"] = i client["Scan - Enable"] = True client.start_acquisition() while client.acquiring: sleep(0.1) - print(client["Frame Count"]) - # assert client["Frame Count"] == num_points + print(f"frame Count: {client['Frame Count']}") + + assert client["Scan - Points (Recorded)"] == num_points @pytest.mark.server def test_send_100_patterns(self, client): """Test sending 100 scan patterns. @@ -335,13 +337,13 @@ def test_hidden_scan_points_single_scan(self, client,fly_back_time): client["Scan - Flyback Time Going Negative (microseconds)"] = 0 client["Scan - Repeat Delay (seconds)"] = 0 client["Scan - Initial Delay (microseconds)"] = 0 - fly_back_time = client["Scan - Flyback Time Going Positive (microseconds)"] + fly_back_time = client["Scan - Flyback Time (microseconds)"] points_per_row = np.ceil(fly_back_time/client["Scan - Dwell Time (microseconds)"]) - assert client["Scan - Flyback Time Going Positive (count)"] == points_per_row + assert client["Scan - Flyback Time Count"] == points_per_row total_points = size_x * size_y + size_y * points_per_row print("Total points:", total_points) - assert client["Scan - Points"] == total_points + assert client["Number of Frames Requested"] == total_points assert client["Scan - Points (Not Recorded)"] == size_y * points_per_row assert client["Scan - Points (Recorded)"] == size_x * size_y @@ -359,11 +361,16 @@ def test_initial_delay(self, client, initial_delay): client["Scan - Size Y"] = size_y client["Scan - Dwell Time (microseconds)"] = 1000 client["Scan - Initial Delay (microseconds)"] = initial_delay - + per_buf = client["Grabbing - Frames Per Buffer"] extra_points = np.ceil(initial_delay/client["Scan - Dwell Time (microseconds)"]) + + extra_points = np.ceil(extra_points/per_buf) * per_buf total_points = size_x * size_y + extra_points + print("Total points:", total_points) - assert client["Scan - Points"] == total_points + + + assert client["Number of Frames Requested"] == total_points assert client["Scan - Points (Not Recorded)"] == extra_points assert client["Scan - Points (Recorded)"] == size_x * size_y @@ -392,11 +399,13 @@ def test_initial_delay_repeats(self, client, initial_delay): assert client["Scan - Initial Delay Count"] == extra_points # For 1 Scan - assert client["Scan - Points"] == total_points # For all repeats + assert client["Number of Frames Requested"] == total_points # For all repeats assert client["Scan - Points (Not Recorded)"] == extra_points * repeats # For all repeats assert client["Scan - Points (Recorded)"] == size_x * size_y * repeats # For all repeats - assert client["Actual Frames to Ignore"] == extra_points * repeats + # Actual Frames to Ignore = max(base, initialDelayCount, flybackCountPositive) + flybackTotalCount + # With zero flyback this reduces to initialDelayCount — it is NOT scaled by repeats. + assert client["Actual Frames to Ignore"] == extra_points @pytest.mark.parametrize("fly_back_time", [0, 1000, 5000]) @pytest.mark.server @@ -417,7 +426,7 @@ def test_flyback_time_repeats(self, client, fly_back_time): points_per_row = np.ceil(fly_back_time/client["Scan - Dwell Time (microseconds)"]) total_points = (size_x * size_y + size_y * points_per_row) * repeats print("Total points:", total_points) - assert client["Scan - Points"] == total_points + assert client["Number of Frames Requested"] == total_points assert client["Scan - Points (Not Recorded)"] == size_y * points_per_row * repeats assert client["Scan - Points (Recorded)"] == size_x * size_y * repeats diff --git a/deapi/tests/test_utils/test_utils.py b/deapi/tests/test_utils/test_utils.py index 938e218b..46429fb5 100644 --- a/deapi/tests/test_utils/test_utils.py +++ b/deapi/tests/test_utils/test_utils.py @@ -1,12 +1,23 @@ -from hyperspy.drawing.utils import plot_images -from sympy.polys.groebnertools import cp_key - -from deapi.utils import image_adjust_gain import numpy as np import pytest import time import hyperspy.api as hs -import matplotlib.pyplot as plt + +from deapi.utils import image_adjust_gain + + +@pytest.fixture(scope="session") +def gain_references(client): + """Take dark and gain references once per session, reused across all parametrized tests.""" + client["Image Processing - Flatfield Correction"] = "Dark and Gain" + client["Scan - Enable"] = "Off" + client["Exposure Mode"] = "Normal" + client["Test Pattern"] = "SW Constant 1" + client["Frames Per Second"] = 100 + client.take_dark_reference(100) + client["Image Processing - Apply Gain on Movie"] = "Off" + client["Test Pattern"] = "SW Four Parts" + client.take_gain_reference(100, target_electrons_per_pixel=100) class TestUtils: @@ -18,24 +29,9 @@ class TestUtils: @pytest.mark.parametrize("flip_horizontal", [False, True]) @pytest.mark.parametrize("flip_vertical", [False, True]) def test_image_adjust_gain( - self, client, binx, offset_y, flip_horizontal, flip_vertical + self, client, gain_references, binx, offset_y, flip_horizontal, flip_vertical ): """Test the image adjustment function.""" - client["Image Processing - Flatfield Correction"] = "Dark and Gain" - - client["Scan - Enable"] = "Off" - client["Exposure Mode"] = "Normal" - client["Test Pattern"] = "SW Constant 1" - client.take_dark_reference(100) - time.sleep(1) - client["Image Processing - Apply Gain on Movie"] = "Off" - client["Frames Per Second"] = 100 - - client["Test Pattern"] = "SW Four Parts" - client.take_gain_reference( - 100, target_electrons_per_pixel=100 - ) # ignore warning for testing - time.sleep(1) client["Binning Y"] = 1 client["Binning X"] = binx client["Crop Offset Y"] = offset_y @@ -44,11 +40,7 @@ def test_image_adjust_gain( "On" if flip_horizontal else "Off" ) client["Image Processing - Flip Vertically"] = "On" if flip_vertical else "Off" - - client["Image Processing - Flatfield Correction"] = ( - "Dark and Gain" # engineering property... - ) - # apply the gain reference on the final but not on the movie + client["Image Processing - Flatfield Correction"] = "Dark and Gain" client["Image Processing - Apply Gain on Movie"] = "Off" client["Image Processing - Apply Gain on Final"] = "On" client["Autosave Final Image"] = "On" @@ -58,21 +50,17 @@ def test_image_adjust_gain( while client.acquiring: time.sleep(0.1) - time.sleep(5) # wait 5 sec for the acquisition to finish final_image_path = client["Autosave Final Image File Path"] movie_path = client["Autosave Movie Frames File Path"] - # just being lazy here and using hyperspy to load the images final_image = hs.load(final_image_path).data movie = hs.load(movie_path).data.astype(np.float32) - # The gain should make everything equal np.testing.assert_array_almost_equal(final_image[0, 0], final_image, decimal=1) assert np.not_equal( final_image, movie ).any(), "Movie should not be equal to final image after gain adjustment" info_file = final_image_path.replace("final.mrc", "info.txt") adjusted_gain = image_adjust_gain(info_file) - print("Adjusted Gain:", adjusted_gain) adjusted_movie_final = np.sum(movie, axis=0) * adjusted_gain np.testing.assert_array_almost_equal( final_image[0, 0], adjusted_movie_final, decimal=-1 @@ -82,21 +70,22 @@ def test_image_adjust_gain( def test_get_gain(self, client): client["Exposure Mode"] = "Normal" client["Test Pattern"] = "SW Constant 1" + client["Frames Per Second"] = 100 client.take_dark_reference(100) - time.sleep(1) client["Test Pattern"] = "SW Four Parts" client["Exposure Mode"] = "Gain" - client["Frames Per Second"] = 100 client["Exposure Time (seconds)"] = 1 prev_gain = client["Reference - Integrating Gain"] - client.start_acquisition(2) # if this is == 1 it fails currently + client.start_acquisition(2) while client.acquiring: time.sleep(0.1) - time.sleep(5) # wait 5 sec for the gain to be applied + # Poll until gain is applied instead of sleeping + deadline = time.time() + 10 + while client["Reference - Integrating Gain"] == prev_gain and time.time() < deadline: + time.sleep(0.2) assert ( client["Reference - Integrating Gain"] != prev_gain ), "Gain should have been applied" - print(prev_gain, client["Reference - Integrating Gain"]) client["Exposure Mode"] = "Normal" client["Autosave Final Image"] = "On" client["Image Processing - Apply Gain on Final"] = "On" From 5eac6343ddc578b3c5a19517bc1313f81d62c1f5 Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Wed, 6 May 2026 09:49:43 -0500 Subject: [PATCH 09/20] Enhancement: Update continual scanning tests for improved frame count validation and initial delay handling --- .../test_scanning/test_continual_scanning.py | 80 ++++++++++++++----- 1 file changed, 58 insertions(+), 22 deletions(-) diff --git a/deapi/tests/test_scanning/test_continual_scanning.py b/deapi/tests/test_scanning/test_continual_scanning.py index 57676d14..cd25f54a 100644 --- a/deapi/tests/test_scanning/test_continual_scanning.py +++ b/deapi/tests/test_scanning/test_continual_scanning.py @@ -88,20 +88,38 @@ def test_sending_multiple_scan_patterns(self, client): scans = [] for i in range(5): scans.append(scan1 + 2 * i) - client.set_xy_array(scans, height=10, width=10) - reps = 6 - client["Scan - Repeats"] = 6 + client.set_xy_array(scans, height=5, width=5) + reps = 7 + client["Scan - Repeats"] = reps client["Scan - Enable"] = True client["Scan - Camera Frames Per Point"] = 1 client["Frames Per Second"] = 100 + + + # Extra frames calculation + min_frame_per_buffer = client["Grabbing - Frames Per Buffer"] + # initial delay count needs to be a multiple of Frames per buffer with a minimum of 1 Buffer len. + client["Scan - Initial Delay (microseconds)"] = 0 # --> + #assert client["Scan - Initial Delay Count"] == min_frame_per_buffer + actual_len_of_scan = len(scan1) + min_frame_per_buffer + + frames_per_scan = np.ceil(actual_len_of_scan/min_frame_per_buffer) * min_frame_per_buffer + + assert client["Frame Count"] == frames_per_scan * reps client.start_acquisition() while client.acquiring: sleep(0.1) - # should "Actual Frames to Ignore" be scaled by the number of reps. - assert client["Frame Count"] - (client["Actual Frames to Ignore"]) == 80 * reps + total_points = 80 * reps + assert client["Scan - Points (Recorded)"] == total_points + # Number of total frames Processed (Frames through GPU) + assert client["Number of Frames Processed"] == client["Scan - Points (Recorded)"] + client["Scan - Points (Not Recorded)"] + assert client["Number of Frames Processed"] == frames_per_scan * reps + # number of frames (after acquisition) + + @pytest.mark.parametrize("index", (0,1,2)) @pytest.mark.server - def test_multiple_scan_patterns_different_lengths(self, client): + def test_multiple_scan_patterns_different_lengths(self, client, index): """Test sending multiple scan patterns of different lengths for continual scanning. Different patterns will be run depending on the index set by: `Scan - XY File Pattern ID` @@ -114,17 +132,20 @@ def test_multiple_scan_patterns_different_lengths(self, client): scans = [scan1, scan2, scan3] client.set_xy_array(scans, height=10, width=10) - for i, num_points in enumerate([40, 50, 60]): - client["Scan - Repeats"] = 1 - client["Scan - XY File Pattern ID"] = i - client["Scan - Enable"] = True - client.start_acquisition() - while client.acquiring: - sleep(0.1) - print(f"frame Count: {client['Frame Count']}") + num_points = [40,50,60] + num_p = num_points[index] + client["Scan - Repeats"] = 1 + client["Scan - XY File Pattern ID"] = index # 0, 1, 2 --> Set to Scan 1, Scan 2 and Scan 3 + client["Scan - Enable"] = True + client.start_acquisition() + while client.acquiring: + sleep(0.1) + print(f"frame Count: {client['Frame Count']}") + assert client["Scan - Points (Recorded)"] == num_p + + - assert client["Scan - Points (Recorded)"] == num_points @pytest.mark.server def test_send_100_patterns(self, client): """Test sending 100 scan patterns. @@ -347,7 +368,7 @@ def test_hidden_scan_points_single_scan(self, client,fly_back_time): assert client["Scan - Points (Not Recorded)"] == size_y * points_per_row assert client["Scan - Points (Recorded)"] == size_x * size_y - @pytest.mark.parametrize("initial_delay", [0, 1000, 5000]) + @pytest.mark.parametrize("initial_delay", [0, 1000, 5000, 10000]) @pytest.mark.server def test_initial_delay(self, client, initial_delay): """Test that initial delay is properly applied during continual scanning. @@ -375,7 +396,7 @@ def test_initial_delay(self, client, initial_delay): assert client["Scan - Points (Recorded)"] == size_x * size_y - @pytest.mark.parametrize("initial_delay", [0, 1000, 5000]) + @pytest.mark.parametrize("initial_delay", [0, 1000, 5000, 10000]) @pytest.mark.server def test_initial_delay_repeats(self, client, initial_delay): """Test that initial delay is properly applied during continual scanning. @@ -385,21 +406,36 @@ def test_initial_delay_repeats(self, client, initial_delay): size_x = 16 size_y = 16 repeats = 5 - client["Scan - Use DE Camera"] = "Off" + client["Scan - Use DE Camera"] = "On" # Not Related to number of frames per buffer if "Off" + client["Scan - Trigger Source"] = "DE-FreeScan" client["Scan - Size X"] = size_x client["Scan - Size Y"] = size_y client["Scan - Repeats"] = repeats + client["Scan - Repeat Delay (seconds)"] = 0 client["Scan - Dwell Time (microseconds)"] = 1000 client["Scan - Initial Delay (microseconds)"] = initial_delay - extra_points = np.ceil(initial_delay/client["Scan - Dwell Time (microseconds)"]) + # Initial Delay count is equal to (initial delay/dwell-time) if this is less than 1 buffer --> 1 buffer. + # If multiple frames are summed per point --> This must be a multiple of the number of frames-per-point. + per_buf = client["Grabbing - Frames Per Buffer"] + + cam_frames_per_point = client["Scan - Camera Frames Per Point"] + extra_points = np.ceil(initial_delay/client["Scan - Dwell Time (microseconds)"]/cam_frames_per_point) *cam_frames_per_point + extra_points_init = extra_points if extra_points > per_buf else per_buf + + extra_points = np.ceil(extra_points_init/per_buf) * per_buf + total_points = size_x * size_y * repeats + extra_points * repeats print("Total points:", total_points) - print("Extra points:", client["Scan - Points (Total)"] - total_points) + print("Extra points:", client["Scan - Points"] - total_points) + print(f"Terminal Count: {client['Scan - Terminal Count']}") + + # initial delay counts is minimum 1 buffer + - assert client["Scan - Initial Delay Count"] == extra_points # For 1 Scan + assert client["Scan - Initial Delay Count"] == extra_points_init # For 1 Scan - assert client["Number of Frames Requested"] == total_points # For all repeats + assert client["Scan - Points (Recorded)"] + client["Scan - Points (Not Recorded)"] == client["Scan - Points"] assert client["Scan - Points (Not Recorded)"] == extra_points * repeats # For all repeats assert client["Scan - Points (Recorded)"] == size_x * size_y * repeats # For all repeats From 4ca85f86f966f24ca395758ed5f967deb6cae59b Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Wed, 6 May 2026 09:52:24 -0500 Subject: [PATCH 10/20] Documentation: Add design documentation for DE-Freescan and integrate sphinx-tabs for improved layout --- doc/conf.py | 1 + doc/reference/index.rst | 8 ++ doc/reference/scan_design.rst | 178 ++++++++++++++++++++++++++++++++++ pyproject.toml | 2 +- 4 files changed, 188 insertions(+), 1 deletion(-) create mode 100644 doc/reference/scan_design.rst diff --git a/doc/conf.py b/doc/conf.py index 155422fe..df93cf77 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -20,6 +20,7 @@ "sphinx.ext.intersphinx", "sphinx.ext.napoleon", "sphinx_gallery.gen_gallery", + "sphinx_tabs.tabs", ] templates_path = ["_templates"] exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] diff --git a/doc/reference/index.rst b/doc/reference/index.rst index b9dd47a9..2d0e06a1 100644 --- a/doc/reference/index.rst +++ b/doc/reference/index.rst @@ -45,3 +45,11 @@ data and metadata! fake_data + +.. rubric:: Design Documents + +.. toctree:: + :maxdepth: 1 + + scan_design + diff --git a/doc/reference/scan_design.rst b/doc/reference/scan_design.rst new file mode 100644 index 00000000..2f0c5429 --- /dev/null +++ b/doc/reference/scan_design.rst @@ -0,0 +1,178 @@ +.. _scan-design: + +##################### +DE-Freescan Design +##################### + +This document describes the scanning associated with the DE-Freescan, explaining +its setup, operation, and limitations, along with code examples for properly +interfacing with the scan controller. + +The Freescan device drives coils within the microscope — primarily sending voltages +to scan X/Y coils. For each direction (e.g. X), there are four deflection coils +driven: a tilt and detilt coil above the sample, and a tilt and detilt coil below +the sample. These voltages are applied to cancel each other out. In practice, due +to relaxation times within the coils, scan artifacts (scan distortions) and descan +artifacts (probe wandering) can occur. + +.. contents:: Table of Contents + :local: + :depth: 2 + +-------------------------- +Internal Scan Design +-------------------------- + +Scan voltages (X/Y) are continuously sent from the DE-Computer to the scan +generator. The scan generator operates in a **FIFO** (First In, First Out) manner, +where scan points are sent to the microscope in the order they are received. +This enables indefinite streaming of scan positions to the microscope. + +While this model *could* support continual updates to scan positions, this is +currently not supported due to difficulties with latency, uploading scan patterns +during acquisition, and the dynamic definition of virtual images. As a compromise, +the properties ``Scan - Offset X (points)`` and ``Scan - Offset Y (points)`` can +shift the beam by some fraction of a scan point — analogous to using image shift +for drift correction in a TEM. This is a beta feature that requires additional +testing. + +Custom scan patterns may also be defined. In this case, the 3V ↔ −3V area is +subdivided into a grid and only specified positions are scanned. The returned +virtual image will contain the scanned values at those positions and zeros +elsewhere. + +-------------------------- +Defining Custom Scan Patterns +-------------------------- + +Lists of X/Y scan points define scan patterns. The total scan area (when no ROI +is active) is defined by the full voltage range of the microscope. Finer voltage +control is achieved by increasing ``Scan - Size X`` and ``Scan - Size Y``. + +In most cases, scan patterns are pre-defined (``Raster``, ``Serpentine``, +``Distributed``, etc.) and a single pattern is used. When ``Scan - Repeats`` > 1, +the single scan pattern is repeated continuously into the FIFO buffer. + +For custom X/Y scan patterns, it is possible to: + +* Send multiple scan patterns and run a selected one by index. +* Cycle through multiple scan patterns in sequence. + +.. note:: + The underlying ``height`` and ``width`` must be equal for all patterns in a set. + +Example: Sending Multiple Patterns (Scan Repeat == 1) +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +This example sends three scan patterns and runs each one individually. + +.. tabs:: + + .. tab:: Python + + .. code-block:: python + + import numpy as np + from time import sleep + + scan1 = np.array([[0, 0], [1, 0], [1, 1], [0, 1]]) # 4 points + scan2 = np.array([[0, 0], [2, 0], [2, 2], [0, 2], [1, 1]]) # 5 points + scan3 = np.array([[0, 0], [3, 0], [3, 3], [0, 3], [1, 1], [2, 2]]) # 6 points + scans = [scan3, scan2, scan1] + + client.set_xy_array(scans, height=10, width=10) # Send all 3 scans to DE-Server + + for i, num_points in enumerate([4, 5, 6]): + client["Scan - Repeats"] = 1 # Single scan, no repeats + client["Scan - XY File Pattern ID"] = i # Select pattern by index + client["Scan - Enable"] = True + client.start_acquisition() + while client.acquiring: + sleep(0.1) + assert client["Scan - Frames (Recorded)"] == num_points # 4, 5, or 6 + + .. tab:: C# + + .. code-block:: csharp + + // TODO: Add C# implementation + + .. tab:: C++ + + .. code-block:: cpp + + // TODO: Add C++ implementation + +Example: Cycling Through Multiple Patterns (Scan Repeat > 1) +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +This example creates five scan patterns derived from a base pattern and cycles +through them for 100 repeats. + +.. tabs:: + + .. tab:: Python + + .. code-block:: python + + import numpy as np + from time import sleep + + scan1 = np.array([ + [0, 0], [1, 0], [1, 1], [0, 1], + [2, 1], [2, 0], [0, 2], [1, 2] + ]) # 8 points + + scans = [scan1 + 2 * i for i in range(5)] + client.set_xy_array(scans, height=10, width=10) + + client["Scan - Repeats"] = 100 + client["Scan - Enable"] = True + client["Scan - Camera Frames Per Point"] = 1 + client["Scan - Repeat Delay"] = 0 # seconds + client.start_acquisition() + while client.acquiring: + sleep(0.1) + + sleep(5) + + .. tab:: C# + + .. code-block:: csharp + + // TODO: Add C# implementation + + .. tab:: C++ + + .. code-block:: cpp + + // TODO: Add C++ implementation + +Cycling through patterns is important for reducing latency with repeated scans, +particularly with regard to allocating memory for results. + +-------------------------- +Returning Virtual Images +-------------------------- + +When ``Scan - Repeats`` > 1 and ``Scan - Repeat Delay (seconds)`` > 5, the camera +will operate continuously: the shutter remains open and the probe moves to the park +position for the duration of the delay. The beam is **not** blanked during this time. + +Virtual images are continuously written to disk using a **Ping-Pong buffer** (A → B → A → B …) +that holds the image currently being acquired and the previously completed image. +There are three ways to access virtual images: + +1. **Return the "Stitched" Image** + Returns the current buffer stitched together with the previous buffer, based on + the current scan position. Primarily used for continuous display purposes. + +2. **Return the Last Full Scan** + Returns the most recently completed full scan image. When buffer A is being + written to, buffer B is returned, and vice versa. Suitable for fully asynchronous + workflows where dropping frames is acceptable (e.g. continuous drift correction). + +3. **Stream Virtual Images** + The client subscribes to a stream of images from the server, and every virtual + image is delivered. Use this when all frames must be captured. + diff --git a/pyproject.toml b/pyproject.toml index a2912792..3f99b7cd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -61,7 +61,7 @@ doc = [ "sphinx", "pydata_sphinx_theme", "sphinx-gallery", - + "sphinx-tabs", ] [project.urls] From 0acf2eac8bc50b7db0f884e53edf53a766eb012b Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Wed, 6 May 2026 10:46:38 -0500 Subject: [PATCH 11/20] Enhancement: Add Windows-specific dependency for pywin32 and comment out client.stop_acquisition in test setup --- deapi/tests/test_client.py | 2 +- pyproject.toml | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/deapi/tests/test_client.py b/deapi/tests/test_client.py index d7c78ee7..484e77a7 100644 --- a/deapi/tests/test_client.py +++ b/deapi/tests/test_client.py @@ -16,7 +16,7 @@ class TestClient: @pytest.fixture(autouse=True) def clean_state(self, client): # First set the hardware ROI to a known state - client.stop_acquisition() + #client.stop_acquisition() wait_for_idle(client, timeout=10) client["Hardware ROI Offset X"] = 0 client["Hardware ROI Offset Y"] = 0 diff --git a/pyproject.toml b/pyproject.toml index 3f99b7cd..6498aaf9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,6 +23,7 @@ dependencies = [ "scipy", "scikit-image", "sympy", + "pywin32; platform_system == 'Windows'", ] description = "API for DE Server" version = "5.3b5" From df4590558a6c2fb4188e124d3fe0c33519a1fcf5 Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Wed, 6 May 2026 13:44:34 -0500 Subject: [PATCH 12/20] Enhancement: Update continual scanning test to set test pattern and validate single frame result --- deapi/tests/test_scanning/test_continual_scanning.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/deapi/tests/test_scanning/test_continual_scanning.py b/deapi/tests/test_scanning/test_continual_scanning.py index cd25f54a..67879f3b 100644 --- a/deapi/tests/test_scanning/test_continual_scanning.py +++ b/deapi/tests/test_scanning/test_continual_scanning.py @@ -126,6 +126,7 @@ def test_multiple_scan_patterns_different_lengths(self, client, index): """ #min points is buffer size frames_per_buffer = client["Grabbing - Frames Per Buffer"] + client["Test Pattern"] = "SW Frame Number" scan1 = np.array([[0, 0], [1, 0], [1, 1], [0, 1]]*10) # 40 points scan2 = np.array([[0, 0], [2, 0], [2, 2], [0, 2], [1, 1]]*10) # 50 points scan3 = np.array([[0, 0], [3, 0], [3, 3], [0, 3], [1, 1], [2, 2]]*10) # 60 points @@ -137,9 +138,12 @@ def test_multiple_scan_patterns_different_lengths(self, client, index): client["Scan - Repeats"] = 1 client["Scan - XY File Pattern ID"] = index # 0, 1, 2 --> Set to Scan 1, Scan 2 and Scan 3 client["Scan - Enable"] = True + client["Reference - Dark"] = "None" client.start_acquisition() while client.acquiring: sleep(0.1) + res = client.get_result("SINGLEFRAME_INTEGRATED") + assert np.max(res.image) == num_p -1 print(f"frame Count: {client['Frame Count']}") assert client["Scan - Points (Recorded)"] == num_p From 4f92abb2a0c5adf87a0add265a3190dacf40714c Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 6 May 2026 18:45:39 +0000 Subject: [PATCH 13/20] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- deapi/client.py | 16 +- deapi/simulated_server/fake_server.py | 10 +- deapi/tests/conftest.py | 2 - deapi/tests/test_client.py | 22 ++- .../test_file_loading_libertem.py | 1 + .../test_scanning/test_continual_scanning.py | 184 +++++++++++------- deapi/tests/test_utils/test_utils.py | 5 +- 7 files changed, 150 insertions(+), 90 deletions(-) diff --git a/deapi/client.py b/deapi/client.py index be2a17f0..692f5f45 100644 --- a/deapi/client.py +++ b/deapi/client.py @@ -1024,7 +1024,7 @@ def set_binning(self, bin_x, bin_y, use_hw=True): prop_hw_bin_x = self.GetProperty("Hardware Binning X") prop_hw_bin_y = self.GetProperty("Hardware Binning Y") - + if prop_hw_bin_x is not False: hw_bin_x = int(prop_hw_bin_x) @@ -1426,7 +1426,9 @@ def set_xy_array(self, positions, width=None, height=None): # For an array handle both 2 and 3d cases... elif isinstance(positions, np.ndarray): if positions.ndim > 3: - log.error("Positions must be a 2D array of shape (N, 2) or 3D array of shape (M, N, 2)") + log.error( + "Positions must be a 2D array of shape (N, 2) or 3D array of shape (M, N, 2)" + ) return False elif positions.ndim == 2: positions = positions[np.newaxis, :, :] @@ -1434,9 +1436,9 @@ def set_xy_array(self, positions, width=None, height=None): log.error("Positions must be integers... Casting to int") positions = positions.astype(np.int32) if width is None: - new_width = np.max(positions[:,:, 0]) + 1 + new_width = np.max(positions[:, :, 0]) + 1 if height is None: - new_height = np.max(positions[:,:, 1]) + 1 + new_height = np.max(positions[:, :, 1]) + 1 if width is not None: new_width = width @@ -1450,9 +1452,7 @@ def set_xy_array(self, positions, width=None, height=None): vals_to_send = [int(new_width), int(new_height)] + num_positions print("Vals to send:", vals_to_send) - command = self._addSingleCommand( - self.SET_SCAN_XY_ARRAY, None, vals_to_send - ) + command = self._addSingleCommand(self.SET_SCAN_XY_ARRAY, None, vals_to_send) try: packet = struct.pack("I", command.ByteSize()) + command.SerializeToString() self.socket.send(packet) @@ -1466,7 +1466,7 @@ def set_xy_array(self, positions, width=None, height=None): if ret: try: # convert to bytes and send - tic =time.time() + tic = time.time() for pos in positions: x = pos[:, 0].tobytes() y = pos[:, 1].tobytes() diff --git a/deapi/simulated_server/fake_server.py b/deapi/simulated_server/fake_server.py index 46ce02d2..579465ef 100644 --- a/deapi/simulated_server/fake_server.py +++ b/deapi/simulated_server/fake_server.py @@ -481,9 +481,12 @@ def _fake_set_property(self, command): val = command.command[0].parameter[1].p_float else: # type == pb.AnyParameter.P_STRING: val = command.command[0].parameter[1].p_string - normalized_name = name.replace(" ", "_").lower().replace("(", "").replace(")", "") + normalized_name = ( + name.replace(" ", "_").lower().replace("(", "").replace(")", "") + ) if normalized_name not in self._values: import sys + print( f"FakeServer WARNING: SetProperty '{name}' not found in prop_dump.json" f" — property not in FakeServer", @@ -503,9 +506,12 @@ def _fake_get_property(self, command): ack1 = acknowledge_return.acknowledge.add() # add the first acknowledge ack1.command_id = command.command[0].command_id name = command.command[0].parameter[0].p_string - normalized_name = name.replace(" ", "_").lower().replace("(", "").replace(")", "") + normalized_name = ( + name.replace(" ", "_").lower().replace("(", "").replace(")", "") + ) if normalized_name not in self._values: import sys + print( f"FakeServer WARNING: GetProperty '{name}' not found in prop_dump.json" f" — property not in FakeServer", diff --git a/deapi/tests/conftest.py b/deapi/tests/conftest.py index f300f922..141f6755 100644 --- a/deapi/tests/conftest.py +++ b/deapi/tests/conftest.py @@ -195,5 +195,3 @@ class Starter(ProcessStarter): c.connect(port=port) yield c xprocess.getinfo("server-%s" % port).terminate() - - diff --git a/deapi/tests/test_client.py b/deapi/tests/test_client.py index 484e77a7..7c5a4081 100644 --- a/deapi/tests/test_client.py +++ b/deapi/tests/test_client.py @@ -16,7 +16,7 @@ class TestClient: @pytest.fixture(autouse=True) def clean_state(self, client): # First set the hardware ROI to a known state - #client.stop_acquisition() + # client.stop_acquisition() wait_for_idle(client, timeout=10) client["Hardware ROI Offset X"] = 0 client["Hardware ROI Offset Y"] = 0 @@ -292,14 +292,20 @@ def test_set_xy_array(self, client): @pytest.mark.server def test_gain_reference(self, client): client["Test Pattern"] = "SW Constant 400" - client.TakeDarkReference(100, acquisitions =1) # take a dark reference first with 400 ADU + client.TakeDarkReference( + 100, acquisitions=1 + ) # take a dark reference first with 400 ADU client["Test Pattern"] = "SW Constant 1600" - client.take_gain_reference(100, target_electrons_per_pixel=10, counting=False, num_acq=1) + client.take_gain_reference( + 100, target_electrons_per_pixel=10, counting=False, num_acq=1 + ) @pytest.mark.server def test_gain_reference_too_bright(self, client): client["Test Pattern"] = "SW Constant 1" - client.TakeDarkReference(100, acquisitions=1) # take a dark reference first with 400 ADU + client.TakeDarkReference( + 100, acquisitions=1 + ) # take a dark reference first with 400 ADU client["Test Pattern"] = "SW Gaussian M1600 D200" with pytest.raises(ValueError): @@ -311,9 +317,13 @@ def test_gain_reference_too_bright(self, client): def test_get_trial_gain_reference(self, client): client["Scan - Enable"] = "Off" client["Test Pattern"] = "SW Constant 400" - client.take_dark_reference(100, acquisitions=1) # take a dark reference first with 400 ADU + client.take_dark_reference( + 100, acquisitions=1 + ) # take a dark reference first with 400 ADU client["Test Pattern"] = "SW Constant 1600" # others don't work?? - exposure, num_acquire, el = client.take_trial_gain_reference(10, target_electrons_per_pixel=10) + exposure, num_acquire, el = client.take_trial_gain_reference( + 10, target_electrons_per_pixel=10 + ) assert exposure == 1 assert el > 0 diff --git a/deapi/tests/test_file_saving/test_file_loading_libertem.py b/deapi/tests/test_file_saving/test_file_loading_libertem.py index b1509976..7854131a 100644 --- a/deapi/tests/test_file_saving/test_file_loading_libertem.py +++ b/deapi/tests/test_file_saving/test_file_loading_libertem.py @@ -39,6 +39,7 @@ def clean_state(self, client): @pytest.mark.server def test_save_4DSTEM(self, client, file_format): import libertem.api as lt + if not os.path.exists("D:\Temp"): os.mkdir("D:\Temp") temp_dir = "D:\Temp" diff --git a/deapi/tests/test_scanning/test_continual_scanning.py b/deapi/tests/test_scanning/test_continual_scanning.py index 67879f3b..c5fc9ed7 100644 --- a/deapi/tests/test_scanning/test_continual_scanning.py +++ b/deapi/tests/test_scanning/test_continual_scanning.py @@ -5,6 +5,7 @@ Expected behavior: """ + import os import numpy as np @@ -12,6 +13,7 @@ from time import sleep import glob + class TestContinualScanning: """Test class for continual scanning functionality.""" @@ -19,11 +21,13 @@ class TestContinualScanning: def tmp_path(self): import tempfile from pathlib import Path + temp_dir = Path("D:/temp") / f"test_{id(self)}" temp_dir.mkdir(parents=True, exist_ok=True) yield temp_dir # Optional: cleanup import shutil + shutil.rmtree(temp_dir, ignore_errors=True) @pytest.fixture(autouse=True) @@ -32,8 +36,8 @@ def clean_state(self, client): client.stop_acquisition() client["Hardware Binning X"] = 1 client["Hardware Binning Y"] = 1 - client.set_adaptive_roi(256, 256) # set to a reduced size for faster testing - client["Frames Per Second"] = 100000 # set to max (will be capped by sever) + client.set_adaptive_roi(256, 256) # set to a reduced size for faster testing + client["Frames Per Second"] = 100000 # set to max (will be capped by sever) client["Scan - Type"] = "Raster" # Set the software Binning to 1 client["Binning X"] = 1 @@ -45,7 +49,6 @@ def clean_state(self, client): client["Scan - Repeats"] = 1 client["Scan - Repeat Delay (seconds)"] = 0 - @pytest.mark.server def test_continual_scanning(self, client): """Test continual scanning logic. @@ -72,11 +75,10 @@ def test_continual_scanning(self, client): sleep(3) # wait for any finalization - #assert client["Frame Count"] == 8 * 8 * 3 - print("Frame Count:",client["Frame Count"]) + # assert client["Frame Count"] == 8 * 8 * 3 + print("Frame Count:", client["Frame Count"]) result = client.get_result() - print("Aq:",result.attributes.acqIndex) - + print("Aq:", result.attributes.acqIndex) @pytest.mark.server def test_sending_multiple_scan_patterns(self, client): @@ -84,7 +86,9 @@ def test_sending_multiple_scan_patterns(self, client): properly cycle through the patterns for the specified number of repeats. """ - scan1 = np.array([[0, 0], [1, 0], [1, 1], [0, 1],[2, 1], [2, 0], [0,2], [1,2]] *10) # 80 points + scan1 = np.array( + [[0, 0], [1, 0], [1, 1], [0, 1], [2, 1], [2, 0], [0, 2], [1, 2]] * 10 + ) # 80 points scans = [] for i in range(5): scans.append(scan1 + 2 * i) @@ -95,15 +99,16 @@ def test_sending_multiple_scan_patterns(self, client): client["Scan - Camera Frames Per Point"] = 1 client["Frames Per Second"] = 100 - # Extra frames calculation min_frame_per_buffer = client["Grabbing - Frames Per Buffer"] # initial delay count needs to be a multiple of Frames per buffer with a minimum of 1 Buffer len. - client["Scan - Initial Delay (microseconds)"] = 0 # --> - #assert client["Scan - Initial Delay Count"] == min_frame_per_buffer + client["Scan - Initial Delay (microseconds)"] = 0 # --> + # assert client["Scan - Initial Delay Count"] == min_frame_per_buffer actual_len_of_scan = len(scan1) + min_frame_per_buffer - frames_per_scan = np.ceil(actual_len_of_scan/min_frame_per_buffer) * min_frame_per_buffer + frames_per_scan = ( + np.ceil(actual_len_of_scan / min_frame_per_buffer) * min_frame_per_buffer + ) assert client["Frame Count"] == frames_per_scan * reps client.start_acquisition() @@ -113,43 +118,48 @@ def test_sending_multiple_scan_patterns(self, client): total_points = 80 * reps assert client["Scan - Points (Recorded)"] == total_points # Number of total frames Processed (Frames through GPU) - assert client["Number of Frames Processed"] == client["Scan - Points (Recorded)"] + client["Scan - Points (Not Recorded)"] + assert ( + client["Number of Frames Processed"] + == client["Scan - Points (Recorded)"] + + client["Scan - Points (Not Recorded)"] + ) assert client["Number of Frames Processed"] == frames_per_scan * reps # number of frames (after acquisition) - @pytest.mark.parametrize("index", (0,1,2)) + @pytest.mark.parametrize("index", (0, 1, 2)) @pytest.mark.server def test_multiple_scan_patterns_different_lengths(self, client, index): """Test sending multiple scan patterns of different lengths for continual scanning. Different patterns will be run depending on the index set by: `Scan - XY File Pattern ID` """ - #min points is buffer size + # min points is buffer size frames_per_buffer = client["Grabbing - Frames Per Buffer"] client["Test Pattern"] = "SW Frame Number" - scan1 = np.array([[0, 0], [1, 0], [1, 1], [0, 1]]*10) # 40 points - scan2 = np.array([[0, 0], [2, 0], [2, 2], [0, 2], [1, 1]]*10) # 50 points - scan3 = np.array([[0, 0], [3, 0], [3, 3], [0, 3], [1, 1], [2, 2]]*10) # 60 points + scan1 = np.array([[0, 0], [1, 0], [1, 1], [0, 1]] * 10) # 40 points + scan2 = np.array([[0, 0], [2, 0], [2, 2], [0, 2], [1, 1]] * 10) # 50 points + scan3 = np.array( + [[0, 0], [3, 0], [3, 3], [0, 3], [1, 1], [2, 2]] * 10 + ) # 60 points scans = [scan1, scan2, scan3] client.set_xy_array(scans, height=10, width=10) - num_points = [40,50,60] + num_points = [40, 50, 60] num_p = num_points[index] client["Scan - Repeats"] = 1 - client["Scan - XY File Pattern ID"] = index # 0, 1, 2 --> Set to Scan 1, Scan 2 and Scan 3 + client["Scan - XY File Pattern ID"] = ( + index # 0, 1, 2 --> Set to Scan 1, Scan 2 and Scan 3 + ) client["Scan - Enable"] = True client["Reference - Dark"] = "None" client.start_acquisition() while client.acquiring: sleep(0.1) res = client.get_result("SINGLEFRAME_INTEGRATED") - assert np.max(res.image) == num_p -1 + assert np.max(res.image) == num_p - 1 print(f"frame Count: {client['Frame Count']}") assert client["Scan - Points (Recorded)"] == num_p - - - @pytest.mark.server def test_send_100_patterns(self, client): """Test sending 100 scan patterns. @@ -170,8 +180,8 @@ def test_send_100_patterns(self, client): client.set_xy_array(coords, height=128, width=128) client["Scan - Repeats"] = 100 client["Scan - Enable"] = True - #client.start_acquisition() - #while client.acquiring: + # client.start_acquisition() + # while client.acquiring: # sleep(0.1) @pytest.mark.server @@ -195,7 +205,6 @@ def test_repeat_scanning_no_DE_camera(self, client): result = client.get_result("external_image1") - @pytest.mark.server def test_saving_virtual_images(self, client, tmp_path): """Test that virtual images are saved correctly during continual scanning. @@ -209,7 +218,7 @@ def test_saving_virtual_images(self, client, tmp_path): client["Scan - Enable"] = True client["Use DE Camera"] = "Use Frame Time" - client["Autosave Directory"] = str(tmp_path) + client["Autosave Directory"] = str(tmp_path) client["Autosave Virtual Image 0"] = "On" # Start the scan @@ -217,19 +226,21 @@ def test_saving_virtual_images(self, client, tmp_path): while client.acquiring: sleep(0.1) - sleep(3) # wait for any finalization + sleep(3) # wait for any finalization path = client["Autosave Virtual Image 0 File Path"] # get the file size osize = os.path.getsize(path) HEADER_SIZE = 1024 # Header size for a MRC file - expected_size = HEADER_SIZE + 2 * 32 * 32 * 4 # 2 repeats, 8x8 image, 4 bytes per pixel + expected_size = ( + HEADER_SIZE + 2 * 32 * 32 * 4 + ) # 2 repeats, 8x8 image, 4 bytes per pixel assert osize == expected_size @pytest.mark.server - def test_frame_repeats(self,client): + def test_frame_repeats(self, client): """Test that frame repeats work correctly during continual scanning. This should repeat each frame the specified number of times before moving to the next position. @@ -247,7 +258,7 @@ def test_frame_repeats(self,client): while client.acquiring: sleep(0.1) - res_1=client.get_result("virtual_image0", pixel_format="AUTO" ) + res_1 = client.get_result("virtual_image0", pixel_format="AUTO") # num pixels/frame n_pix = client["Image Size X (pixels)"] * client["Image Size Y (pixels)"] @@ -255,9 +266,8 @@ def test_frame_repeats(self,client): frame_number = (res_1.image / n_pix) / client["Scan - Camera Frames Per Point"] print(frame_number) - @pytest.mark.server - def test_frame_repeats_auto_save(self,client): + def test_frame_repeats_auto_save(self, client): """Test that frame repeats work correctly during continual scanning. This should repeat each frame the specified number of times before moving to the next position. @@ -280,7 +290,7 @@ def test_frame_repeats_auto_save(self,client): sleep(0.1) print(client["Acquisition Status"]) sleep(3) - res_1=client.get_result("virtual_image0", pixel_format="AUTO" ) + res_1 = client.get_result("virtual_image0", pixel_format="AUTO") # num pixels/frame n_pix = client["Image Size X (pixels)"] * client["Image Size Y (pixels)"] @@ -296,8 +306,10 @@ def test_frame_repeats_auto_save(self,client): sleep(0.1) sleep(3) print(client["Acquisition Status"]) - res_2=client.get_result("virtual_image0", pixel_format="AUTO" ) - frame_number_2 = (res_2.image / n_pix) / client["Scan - Camera Frames Per Point"] + res_2 = client.get_result("virtual_image0", pixel_format="AUTO") + frame_number_2 = (res_2.image / n_pix) / client[ + "Scan - Camera Frames Per Point" + ] print("frame2", frame_number_2) # test the size of the result... @@ -308,11 +320,15 @@ def test_frame_repeats_auto_save(self,client): osize = os.path.getsize(path) HEADER_SIZE = 1024 # Header size for a MRC file - image_size = client["Image Size X (pixels)"] * client["Image Size Y (pixels)"] * 2 # 4 bytes per pixel - expected_size = HEADER_SIZE + (image_size* - client["Scan - Size X"] * - client["Scan - Size Y"] * - client["Scan - Repeats"]) + image_size = ( + client["Image Size X (pixels)"] * client["Image Size Y (pixels)"] * 2 + ) # 4 bytes per pixel + expected_size = HEADER_SIZE + ( + image_size + * client["Scan - Size X"] + * client["Scan - Size Y"] + * client["Scan - Repeats"] + ) assert osize == expected_size @@ -324,8 +340,10 @@ def test_frame_repeats_auto_save(self,client): sleep(0.1) sleep(1) res_2 = client.get_result("virtual_image0", pixel_format="AUTO") - frame_number_2 = (res_2.image / n_pix) / client["Scan - Camera Frames Per Point"] - print("frame3",frame_number_2) + frame_number_2 = (res_2.image / n_pix) / client[ + "Scan - Camera Frames Per Point" + ] + print("frame3", frame_number_2) # test the size of the result... path = client["Autosave Movie Frames File Path"] @@ -334,18 +352,21 @@ def test_frame_repeats_auto_save(self,client): osize = os.path.getsize(path) HEADER_SIZE = 1024 # Header size for a MRC file - image_size = client["Image Size X (pixels)"] * client["Image Size Y (pixels)"] * 2 # 2 bytes per pixel - expected_size = HEADER_SIZE + (image_size * - client["Scan - Size X"] * - client["Scan - Size Y"] * - client["Scan - Repeats"]) # 2 bytes per pixel + image_size = ( + client["Image Size X (pixels)"] * client["Image Size Y (pixels)"] * 2 + ) # 2 bytes per pixel + expected_size = HEADER_SIZE + ( + image_size + * client["Scan - Size X"] + * client["Scan - Size Y"] + * client["Scan - Repeats"] + ) # 2 bytes per pixel assert osize == expected_size - @pytest.mark.parametrize("fly_back_time", [0, 1000, 4000]) @pytest.mark.server - def test_hidden_scan_points_single_scan(self, client,fly_back_time): + def test_hidden_scan_points_single_scan(self, client, fly_back_time): """Test that hidden scan points are properly ignored during continual scanning. This should ensure that points marked as hidden are not included in the scan pattern. @@ -363,7 +384,9 @@ def test_hidden_scan_points_single_scan(self, client,fly_back_time): client["Scan - Repeat Delay (seconds)"] = 0 client["Scan - Initial Delay (microseconds)"] = 0 fly_back_time = client["Scan - Flyback Time (microseconds)"] - points_per_row = np.ceil(fly_back_time/client["Scan - Dwell Time (microseconds)"]) + points_per_row = np.ceil( + fly_back_time / client["Scan - Dwell Time (microseconds)"] + ) assert client["Scan - Flyback Time Count"] == points_per_row total_points = size_x * size_y + size_y * points_per_row @@ -387,19 +410,19 @@ def test_initial_delay(self, client, initial_delay): client["Scan - Dwell Time (microseconds)"] = 1000 client["Scan - Initial Delay (microseconds)"] = initial_delay per_buf = client["Grabbing - Frames Per Buffer"] - extra_points = np.ceil(initial_delay/client["Scan - Dwell Time (microseconds)"]) + extra_points = np.ceil( + initial_delay / client["Scan - Dwell Time (microseconds)"] + ) - extra_points = np.ceil(extra_points/per_buf) * per_buf - total_points = size_x * size_y + extra_points + extra_points = np.ceil(extra_points / per_buf) * per_buf + total_points = size_x * size_y + extra_points print("Total points:", total_points) - assert client["Number of Frames Requested"] == total_points assert client["Scan - Points (Not Recorded)"] == extra_points assert client["Scan - Points (Recorded)"] == size_x * size_y - @pytest.mark.parametrize("initial_delay", [0, 1000, 5000, 10000]) @pytest.mark.server def test_initial_delay_repeats(self, client, initial_delay): @@ -410,7 +433,9 @@ def test_initial_delay_repeats(self, client, initial_delay): size_x = 16 size_y = 16 repeats = 5 - client["Scan - Use DE Camera"] = "On" # Not Related to number of frames per buffer if "Off" + client["Scan - Use DE Camera"] = ( + "On" # Not Related to number of frames per buffer if "Off" + ) client["Scan - Trigger Source"] = "DE-FreeScan" client["Scan - Size X"] = size_x client["Scan - Size Y"] = size_y @@ -424,24 +449,37 @@ def test_initial_delay_repeats(self, client, initial_delay): per_buf = client["Grabbing - Frames Per Buffer"] cam_frames_per_point = client["Scan - Camera Frames Per Point"] - extra_points = np.ceil(initial_delay/client["Scan - Dwell Time (microseconds)"]/cam_frames_per_point) *cam_frames_per_point + extra_points = ( + np.ceil( + initial_delay + / client["Scan - Dwell Time (microseconds)"] + / cam_frames_per_point + ) + * cam_frames_per_point + ) extra_points_init = extra_points if extra_points > per_buf else per_buf - extra_points = np.ceil(extra_points_init/per_buf) * per_buf + extra_points = np.ceil(extra_points_init / per_buf) * per_buf - total_points = size_x * size_y * repeats + extra_points * repeats + total_points = size_x * size_y * repeats + extra_points * repeats print("Total points:", total_points) - print("Extra points:", client["Scan - Points"] - total_points) + print("Extra points:", client["Scan - Points"] - total_points) print(f"Terminal Count: {client['Scan - Terminal Count']}") # initial delay counts is minimum 1 buffer + assert client["Scan - Initial Delay Count"] == extra_points_init # For 1 Scan - assert client["Scan - Initial Delay Count"] == extra_points_init # For 1 Scan - - assert client["Scan - Points (Recorded)"] + client["Scan - Points (Not Recorded)"] == client["Scan - Points"] - assert client["Scan - Points (Not Recorded)"] == extra_points * repeats # For all repeats - assert client["Scan - Points (Recorded)"] == size_x * size_y * repeats # For all repeats + assert ( + client["Scan - Points (Recorded)"] + client["Scan - Points (Not Recorded)"] + == client["Scan - Points"] + ) + assert ( + client["Scan - Points (Not Recorded)"] == extra_points * repeats + ) # For all repeats + assert ( + client["Scan - Points (Recorded)"] == size_x * size_y * repeats + ) # For all repeats # Actual Frames to Ignore = max(base, initialDelayCount, flybackCountPositive) + flybackTotalCount # With zero flyback this reduces to initialDelayCount — it is NOT scaled by repeats. @@ -463,13 +501,17 @@ def test_flyback_time_repeats(self, client, fly_back_time): client["Scan - Repeats"] = repeats client["Scan - Dwell Time (microseconds)"] = 1000 client["Scan - Flyback Time (microseconds)"] = fly_back_time - points_per_row = np.ceil(fly_back_time/client["Scan - Dwell Time (microseconds)"]) + points_per_row = np.ceil( + fly_back_time / client["Scan - Dwell Time (microseconds)"] + ) total_points = (size_x * size_y + size_y * points_per_row) * repeats print("Total points:", total_points) assert client["Number of Frames Requested"] == total_points - assert client["Scan - Points (Not Recorded)"] == size_y * points_per_row * repeats + assert ( + client["Scan - Points (Not Recorded)"] == size_y * points_per_row * repeats + ) assert client["Scan - Points (Recorded)"] == size_x * size_y * repeats - #assert client["Actual Frames to Ignore"] == size_y * points_per_row * repeats + # assert client["Actual Frames to Ignore"] == size_y * points_per_row * repeats - assert client["Number of Frames To Grab"] == total_points \ No newline at end of file + assert client["Number of Frames To Grab"] == total_points diff --git a/deapi/tests/test_utils/test_utils.py b/deapi/tests/test_utils/test_utils.py index 46429fb5..94560090 100644 --- a/deapi/tests/test_utils/test_utils.py +++ b/deapi/tests/test_utils/test_utils.py @@ -81,7 +81,10 @@ def test_get_gain(self, client): time.sleep(0.1) # Poll until gain is applied instead of sleeping deadline = time.time() + 10 - while client["Reference - Integrating Gain"] == prev_gain and time.time() < deadline: + while ( + client["Reference - Integrating Gain"] == prev_gain + and time.time() < deadline + ): time.sleep(0.2) assert ( client["Reference - Integrating Gain"] != prev_gain From 1dd1b31205edf7f4e620b9bda753412e1d564f08 Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Thu, 7 May 2026 14:57:24 -0500 Subject: [PATCH 14/20] Enhancement: Add virtual image buffer support with new methods and data structures --- deapi/__init__.py | 2 + deapi/client.py | 141 +++++++++++++++++++++++++- deapi/data_types.py | 46 +++++++++ deapi/simulated_server/fake_server.py | 59 +++++++++++ deapi/tests/test_client.py | 10 +- deapi/version.py | 2 +- 6 files changed, 249 insertions(+), 11 deletions(-) diff --git a/deapi/__init__.py b/deapi/__init__.py index a3082a8b..90045e90 100644 --- a/deapi/__init__.py +++ b/deapi/__init__.py @@ -7,6 +7,7 @@ DataType, MovieBufferStatus, MovieBufferInfo, + VirtualImageInfo, VirtualMask, ContrastStretchType, Attributes, @@ -24,6 +25,7 @@ "DataType", "MovieBufferStatus", "MovieBufferInfo", + "VirtualImageInfo", "VirtualMask", "ContrastStretchType", "Attributes", diff --git a/deapi/client.py b/deapi/client.py index 692f5f45..03245004 100644 --- a/deapi/client.py +++ b/deapi/client.py @@ -36,6 +36,7 @@ DataType, PropertyCollection, VirtualMask, + VirtualImageInfo, Result, ) @@ -204,8 +205,11 @@ def connect(self, host: str = "127.0.0.1", port: int = 13240, read_only=False): version = [int(part) for part in server_version[:4]] temp = version[2] + version[1] * 1000 + version[0] * 1000000 - if (temp >= 2007005 and version[3] < 11274) or temp >= 2008000: - ## version after 2.8.0 + if temp >= 2008000 and version[3] >= 11901: + ## version 2.8.0 build 11901+ — virtual image buffer support (SDK 5.3.0) + self.commandVersion = 16 + elif (temp >= 2007005 and version[3] < 11274) or temp >= 2008000: + ## version after 2.8.0 (older builds) self.commandVersion = 15 elif temp >= 2007004: ## version after 2.7.4 @@ -1215,6 +1219,7 @@ def start_acquisition( number_of_acquisitions: int = 1, request_movie_buffer: bool = False, update: bool = True, + queue_virtual_buffers: "bool | list[bool]" = False, ): """ Start acquiring images. Make sure all of the properties are set to the desired values. @@ -1226,7 +1231,18 @@ def start_acquisition( request_movie_buffer : bool, optional Request a movie buffer, by default False. If True, the movie buffer will be returned with all of the frames. + queue_virtual_buffers : bool or list[bool], optional + Controls which virtual detector buffers (0–4) are queued during acquisition. + - ``False`` (default): no virtual buffers are queued. + - ``True``: all 5 virtual buffers are queued. + - ``list[bool]``: a list of exactly 5 booleans; each element enables/disables + the corresponding virtual buffer (index 0–4). + Requires SDK >= 5.3.0 (server commandVer >= 16). + Raises + ------ + ValueError + If ``queue_virtual_buffers`` is a list whose length is not exactly 5. """ start_time = self.GetTime() @@ -1251,6 +1267,16 @@ def start_acquisition( log.debug(" Prepare Time: %.1f ms", lapsed) step_time = self.GetTime() + if isinstance(queue_virtual_buffers, bool): + vb = [queue_virtual_buffers] * 5 + else: + if len(queue_virtual_buffers) != 5: + raise ValueError( + f"queue_virtual_buffers must be a list of exactly 5 booleans, " + f"got {len(queue_virtual_buffers)}." + ) + vb = list(queue_virtual_buffers) + if self.width * self.height == 0: log.error(" Image size is 0! ") else: @@ -1258,7 +1284,7 @@ def start_acquisition( command = self._addSingleCommand( self.START_ACQUISITION, None, - [number_of_acquisitions, request_movie_buffer], + [number_of_acquisitions, request_movie_buffer] + vb, ) if logLevel == logging.DEBUG: @@ -1967,6 +1993,111 @@ def get_movie_buffer( return movieBufferStatus, totalBytes, numFrames, movieBuffer + def get_virtual_image_buffer_info(self) -> VirtualImageInfo: + """ + Get information about the virtual image buffer from DE-Server. + + Returns the buffer size, dimensions, and data type for the virtual image + produced by the active virtual detector configuration. + + Returns + ------- + VirtualImageInfo + Object containing: + - ``buffer_size`` : total bytes of one virtual image frame + - ``width`` : image width in pixels + - ``height`` : image height in pixels + - ``data_type`` : :class:`~deapi.DataType` of each pixel + """ + command = self._addSingleCommand(self.GET_VIRTUAL_IMAGE_INFO, None, None) + response = self._sendCommand(command) + + info = VirtualImageInfo() + if response: + values = self.__getParameters(response.acknowledge[0]) + if isinstance(values, list) and len(values) >= 4: + info.buffer_size = values[0] + info.width = values[1] + info.height = values[2] + try: + info.data_type = DataType(values[3]) + except ValueError: + info.data_type = DataType.DEUndef + + return info + + def get_virtual_image_buffer( + self, + virtual_image_id: int, + timeout_msec: int = 5000, + virtual_image_info: VirtualImageInfo = None, + ): + """ + Retrieve a single virtual image frame from the DE-Server virtual image buffer. + + The server queues a virtual image buffer when ``queue_virtual_buffers`` is set + in :meth:`start_acquisition`. Call this method after acquisition to pop and + receive the accumulated virtual image for the requested virtual detector channel. + + Parameters + ---------- + virtual_image_id : int + Index of the virtual detector buffer to retrieve (0–4). + timeout_msec : int, optional + How long to wait for a frame to become available, in milliseconds. + Default is 5000. + virtual_image_info : VirtualImageInfo, optional + Pre-fetched virtual image metadata (width, height, data type). If + ``None`` (default), :meth:`get_virtual_image_info` is called + automatically to obtain the shape needed to reshape the raw buffer. + + Returns + ------- + status : MovieBufferStatus + Result of the retrieval: + - ``MovieBufferStatus.OK`` (5) — image data is valid. + - Other values indicate timeout, failure, or finished state. + frame_index : int + The acquisition frame index associated with this virtual image. + image : numpy.ndarray or None + 2-D array of shape ``(height, width)`` on success, ``None`` otherwise. + """ + if virtual_image_info is None: + virtual_image_info = self.get_virtual_image_buffer_info() + + command = self._addSingleCommand( + self.GET_VIRTUAL_IMAGE, None, [virtual_image_id, timeout_msec] + ) + response = self._sendCommand(command) + + status = MovieBufferStatus.UNKNOWN + frame_index = 0 + image = None + + if response: + values = self.__getParameters(response.acknowledge[0]) + if isinstance(values, list) and len(values) >= 3: + status_int = values[0] + total_bytes = values[1] + frame_index = values[2] + try: + status = MovieBufferStatus(status_int) + except ValueError: + status = MovieBufferStatus.UNKNOWN + + if status == MovieBufferStatus.OK and total_bytes > 0: + raw = self._recvFromSocket(self.socket, total_bytes) + dtype = virtual_image_info.to_numpy_dtype() + image = numpy.frombuffer(raw, dtype=dtype) + if virtual_image_info.width > 0 and virtual_image_info.height > 0: + image = image.reshape( + (virtual_image_info.height, virtual_image_info.width) + ) + else: + status = MovieBufferStatus.FAILED + + return status, frame_index, image + def save_image(self, image, fileName, textSize=0): t0 = self.GetTime() filePath = self.debugImagesFolder + fileName + ".tif" @@ -3013,6 +3144,8 @@ def ParseChangedProperties(self, changedProperties, response): SetVirtualMask = set_virtual_mask GetMovieBufferInfo = get_movie_buffer_info GetMovieBuffer = get_movie_buffer + GetVirtualImageInfo = get_virtual_image_buffer_info + GetVirtualImage = get_virtual_image_buffer SaveImage = save_image PrintServerInfo = print_server_info PrintAcqInfo = print_acquisition_info @@ -3085,6 +3218,8 @@ def ParseChangedProperties(self, changedProperties, response): GET_REGISTER = 38 SET_REGISTER = 39 LIST_REGISTERS = 40 + GET_VIRTUAL_IMAGE_INFO = 41 + GET_VIRTUAL_IMAGE = 42 MMF_DATA_HEADER_SIZE = 24 diff --git a/deapi/data_types.py b/deapi/data_types.py index ba34f213..c512471e 100644 --- a/deapi/data_types.py +++ b/deapi/data_types.py @@ -496,6 +496,52 @@ def to_buffer(self): return bytearray(self.total_bytes) +class VirtualImageInfo: + """ + Structure to hold information about a virtual image buffer. + + Parameters + ---------- + buffer_size : int + Total number of bytes in the virtual image buffer. + width : int + Width of the virtual image in pixels. + height : int + Height of the virtual image in pixels. + data_type : DataType + Pixel data type of the virtual image. + """ + + def __init__( + self, + buffer_size: int = 0, + width: int = 0, + height: int = 0, + data_type: DataType = DataType.DEUndef, + ): + self.buffer_size = buffer_size + self.width = width + self.height = height + self.data_type = data_type + + def to_numpy_dtype(self): + """Return the numpy dtype that corresponds to this virtual image's data type.""" + _map = { + DataType.DE8u: np.uint8, + DataType.DE16u: np.uint16, + DataType.DE16s: np.int16, + DataType.DE32f: np.float32, + } + return _map.get(self.data_type, np.uint16) + + def __repr__(self): + return ( + f"VirtualImageInfo(buffer_size={self.buffer_size}, " + f"width={self.width}, height={self.height}, " + f"data_type={self.data_type})" + ) + + class PropertySpec: """Class to hold the specification of a property in the DE API diff --git a/deapi/simulated_server/fake_server.py b/deapi/simulated_server/fake_server.py index 579465ef..2b7fec43 100644 --- a/deapi/simulated_server/fake_server.py +++ b/deapi/simulated_server/fake_server.py @@ -314,6 +314,16 @@ def _respond_to_command(self, command=None): == self.SET_VIRTUAL_MASK + commandVersion * 100 ): return self._fake_set_virtual_mask(command) + elif ( + command.command[0].command_id + == self.GET_VIRTUAL_IMAGE_INFO + commandVersion * 100 + ): + return self._fake_get_virtual_image_info(command) + elif ( + command.command[0].command_id + == self.GET_VIRTUAL_IMAGE + commandVersion * 100 + ): + return self._fake_get_virtual_image(command) else: raise NotImplementedError( f"Command {command.command[0].command_id} not implemented" @@ -829,6 +839,53 @@ def _fake_get_movie_buffer(self, command): return ans + # Virtual image dimensions used by both fake virtual image handlers + VIRTUAL_IMAGE_W = 64 + VIRTUAL_IMAGE_H = 64 + + def _fake_get_virtual_image_info(self, command): + """Return fixed virtual image metadata: 64×64 uint16.""" + acknowledge_return = pb.DEPacket() + acknowledge_return.type = pb.DEPacket.P_ACKNOWLEDGE + ack1 = acknowledge_return.acknowledge.add() + ack1.command_id = command.command[0].command_id + + w = self.VIRTUAL_IMAGE_W + h = self.VIRTUAL_IMAGE_H + bytes_per_pixel = 2 # uint16 + buffer_size = w * h * bytes_per_pixel + + for val in [buffer_size, w, h, 5]: # 5 == ipp16u ≈ DataType.DE16u + p = ack1.parameter.add() + p.type = pb.AnyParameter.P_INT + p.p_int = val + + return (acknowledge_return,) + + def _fake_get_virtual_image(self, command): + """Return a synthetic 64×64 uint16 virtual image with status OK (5).""" + acknowledge_return = pb.DEPacket() + acknowledge_return.type = pb.DEPacket.P_ACKNOWLEDGE + ack1 = acknowledge_return.acknowledge.add() + ack1.command_id = command.command[0].command_id + + virtual_image_id = command.command[0].parameter[0].p_int + + w = self.VIRTUAL_IMAGE_W + h = self.VIRTUAL_IMAGE_H + data = np.arange(w * h, dtype=np.uint16).reshape(h, w) + raw_bytes = data.tobytes() + total_bytes = len(raw_bytes) + frame_index = self.current_movie_index + + STATUS_OK = 5 + for val in [STATUS_OK, total_bytes, frame_index]: + p = ack1.parameter.add() + p.type = pb.AnyParameter.P_INT + p.p_int = val + + return (acknowledge_return, raw_bytes) + # command lists LIST_CAMERAS = 0 LIST_PROPERTIES = 1 @@ -856,3 +913,5 @@ def _fake_get_movie_buffer(self, command): SET_SCAN_SIZE_AND_GET_CHANGED_PROPERTIES = 29 SET_SCAN_ROI__AND_GET_CHANGED_PROPERTIES = 30 SET_CLIENT_READ_ONLY = 31 + GET_VIRTUAL_IMAGE_INFO = 41 + GET_VIRTUAL_IMAGE = 42 diff --git a/deapi/tests/test_client.py b/deapi/tests/test_client.py index 7c5a4081..95ead1a1 100644 --- a/deapi/tests/test_client.py +++ b/deapi/tests/test_client.py @@ -7,6 +7,8 @@ VirtualMask, MovieBufferStatus, ContrastStretchType, + VirtualImageInfo, + DataType, ) from deapi.tests.conftest import wait_for_idle @@ -345,11 +347,5 @@ def test_flip_dark_reference(self, client): wait_for_idle(client) image = client.get_result()[0] np.testing.assert_array_equal(image, 0) - client["Image Processing - Flip Horizontally"] = "Off" - client["Binning X"] = 2 - client["Binning Y"] = 2 - client.start_acquisition(1) - wait_for_idle(client) - image = client.get_result()[0] - np.testing.assert_array_equal(image, 0) + diff --git a/deapi/version.py b/deapi/version.py index 95ed8fb0..679372b9 100644 --- a/deapi/version.py +++ b/deapi/version.py @@ -1,3 +1,3 @@ version = "5.3.0" versionInfo = list(map(int, version.split("."))) -commandVersion = (versionInfo[0] - 4) * 10 + versionInfo[1] + 2 +commandVersion = 16 From d8fb09140cdf101244a36db42b1f006c29d88922 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 7 May 2026 19:58:14 +0000 Subject: [PATCH 15/20] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- deapi/tests/test_client.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/deapi/tests/test_client.py b/deapi/tests/test_client.py index 95ead1a1..b5ce3cac 100644 --- a/deapi/tests/test_client.py +++ b/deapi/tests/test_client.py @@ -347,5 +347,3 @@ def test_flip_dark_reference(self, client): wait_for_idle(client) image = client.get_result()[0] np.testing.assert_array_equal(image, 0) - - From ab1bdc5cda03af9352c7d9efd280a1b87808f607 Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Fri, 8 May 2026 10:04:22 -0500 Subject: [PATCH 16/20] Enhancement: Validate positions array shape and update server software version handling --- deapi/client.py | 10 ++++++++-- deapi/simulated_server/fake_server.py | 5 ++++- deapi/tests/test_client.py | 2 -- deapi/tests/test_fake_server/test_server.py | 3 ++- .../test_file_loading_libertem.py | 1 + .../test_scanning/test_continual_scanning.py | 13 ------------- deapi/version.py | 19 +++++++++++++++++++ 7 files changed, 34 insertions(+), 19 deletions(-) diff --git a/deapi/client.py b/deapi/client.py index 03245004..7f1e1460 100644 --- a/deapi/client.py +++ b/deapi/client.py @@ -1451,13 +1451,19 @@ def set_xy_array(self, positions, width=None, height=None): new_height = max(new_height, np.max(pos[:, 1]) + 1) # For an array handle both 2 and 3d cases... elif isinstance(positions, np.ndarray): - if positions.ndim > 3: + if positions.ndim > 3 or positions.ndim < 2: log.error( "Positions must be a 2D array of shape (N, 2) or 3D array of shape (M, N, 2)" ) return False elif positions.ndim == 2: + if positions.shape[1] != 2: + log.error("Positions must be of shape (N, 2)") + return False positions = positions[np.newaxis, :, :] + elif positions.shape[-1] != 2: + log.error("Positions must be of shape (M, N, 2)") + return False if positions.dtype != np.int32: log.error("Positions must be integers... Casting to int") positions = positions.astype(np.int32) @@ -2048,7 +2054,7 @@ def get_virtual_image_buffer( Default is 5000. virtual_image_info : VirtualImageInfo, optional Pre-fetched virtual image metadata (width, height, data type). If - ``None`` (default), :meth:`get_virtual_image_info` is called + ``None`` (default), :meth:`get_virtual_image_buffer_info` is called automatically to obtain the shape needed to reshape the raw buffer. Returns diff --git a/deapi/simulated_server/fake_server.py b/deapi/simulated_server/fake_server.py index 2b7fec43..6ef260b3 100644 --- a/deapi/simulated_server/fake_server.py +++ b/deapi/simulated_server/fake_server.py @@ -7,7 +7,7 @@ from importlib import resources import deapi import numpy as np -from deapi.version import commandVersion +from deapi.version import commandVersion, fake_server_software_version from deapi.fake_data.grains import TiltGrains from skimage.transform import resize from sympy import parse_expr @@ -171,6 +171,9 @@ def __init__(self, dataset="grains", socket=None): set_also_expressions=values[v].get("set_also", None), ) self._values = property_dict + # Keep "Server Software Version" in sync with the module-level commandVersion + # so the client's version-detection logic always picks the right commandVersion. + self._values["server_software_version"].value = fake_server_software_version self._number_of_frames_requested = 0 self.current_socket_result = None diff --git a/deapi/tests/test_client.py b/deapi/tests/test_client.py index b5ce3cac..8d6d0d95 100644 --- a/deapi/tests/test_client.py +++ b/deapi/tests/test_client.py @@ -7,8 +7,6 @@ VirtualMask, MovieBufferStatus, ContrastStretchType, - VirtualImageInfo, - DataType, ) from deapi.tests.conftest import wait_for_idle diff --git a/deapi/tests/test_fake_server/test_server.py b/deapi/tests/test_fake_server/test_server.py index f8f495ac..633b78d3 100644 --- a/deapi/tests/test_fake_server/test_server.py +++ b/deapi/tests/test_fake_server/test_server.py @@ -33,4 +33,5 @@ def test_set_virtual_image_calculation(self, fake_server): assert fake_server["Scan - Virtual Detector 1 Calculation"] == "Sum" def test_server_software_version(self, fake_server): - assert fake_server["Server Software Version"] == "2.7.5.1352" + from deapi.version import fake_server_software_version + assert fake_server["Server Software Version"] == fake_server_software_version diff --git a/deapi/tests/test_file_saving/test_file_loading_libertem.py b/deapi/tests/test_file_saving/test_file_loading_libertem.py index 7854131a..89955073 100644 --- a/deapi/tests/test_file_saving/test_file_loading_libertem.py +++ b/deapi/tests/test_file_saving/test_file_loading_libertem.py @@ -10,6 +10,7 @@ import glob import time +pytest.importorskip("libertem") class TestLoadingLiberTEM: @pytest.fixture(autouse=True) diff --git a/deapi/tests/test_scanning/test_continual_scanning.py b/deapi/tests/test_scanning/test_continual_scanning.py index c5fc9ed7..fbfefd57 100644 --- a/deapi/tests/test_scanning/test_continual_scanning.py +++ b/deapi/tests/test_scanning/test_continual_scanning.py @@ -17,19 +17,6 @@ class TestContinualScanning: """Test class for continual scanning functionality.""" - @pytest.fixture - def tmp_path(self): - import tempfile - from pathlib import Path - - temp_dir = Path("D:/temp") / f"test_{id(self)}" - temp_dir.mkdir(parents=True, exist_ok=True) - yield temp_dir - # Optional: cleanup - import shutil - - shutil.rmtree(temp_dir, ignore_errors=True) - @pytest.fixture(autouse=True) def clean_state(self, client): # First set the hardware ROI to a known state diff --git a/deapi/version.py b/deapi/version.py index 679372b9..f1f82a06 100644 --- a/deapi/version.py +++ b/deapi/version.py @@ -1,3 +1,22 @@ version = "5.3.0" versionInfo = list(map(int, version.split("."))) commandVersion = 16 + +# Maps each commandVersion to a representative server software version string. +# Used by FakeServer so its reported "Server Software Version" always matches +# the commandVersion used for dispatch, making tests version-agnostic. +_command_version_to_server_version = { + 16: "2.8.0.11901", + 15: "2.7.5.1000", + 13: "2.7.4.10590", + 12: "2.7.4.1000", + 11: "2.7.3.1000", + 10: "2.7.2.1000", + 4: "2.5.25.1000", + 3: "2.1.17.1000", +} + +# The server version string that corresponds to the current commandVersion. +fake_server_software_version = _command_version_to_server_version.get( + commandVersion, "2.8.0.11901" +) From 8fa8b47e468c33a0506976c847b0f45c5244385f Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Fri, 8 May 2026 13:04:26 -0500 Subject: [PATCH 17/20] Enhancement: Update CI configuration for multiple OS support and improve continual scanning tests --- .github/workflows/build.yaml | 33 +- deapi/simulated_server/fake_server.py | 3 + deapi/tests/conftest.py | 9 +- .../test_scanning/test_continual_scanning.py | 30 +- doc/reference/scan_design.rst | 599 +++++++++++++++++- pyproject.toml | 4 +- 6 files changed, 611 insertions(+), 67 deletions(-) diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 6cd65d6f..26669083 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -9,26 +9,25 @@ env: MPLBACKEND: agg jobs: - build-with-pip: + build-with-uv: name: ${{ matrix.os }}-py${{ matrix.python-version }}${{ matrix.LABEL }} runs-on: ${{ matrix.os }} - timeout-minutes: 15 + timeout-minutes: 20 strategy: fail-fast: false matrix: - os: [ubuntu-latest, windows-latest] - python-version: ["3.12", "3.13", "3.14"] + os: [ubuntu-latest, windows-latest, macos-latest] + python-version: ["3.12", "3.13"] include: - os: ubuntu-latest - python-version: 3.11 - + python-version: "3.11" DEPENDENCIES: protobuf==5.26.0 LABEL: -oldest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v4 + - name: Install uv + uses: astral-sh/setup-uv@v5 with: python-version: ${{ matrix.python-version }} @@ -45,34 +44,32 @@ jobs: shell: python - name: Install dependencies and package - shell: bash run: | - pip install -U -e .'[tests]' + uv sync --extra tests - name: Install oldest supported versions if: contains(matrix.LABEL, 'oldest') run: | - pip install ${{ matrix.DEPENDENCIES }} + uv pip install ${{ matrix.DEPENDENCIES }} - name: Display Python, pip and package versions run: | - python -V - pip -V - pip list + uv run python -V + uv pip list - name: Run docstring tests continue-on-error: true run: | - pytest --doctest-modules --doctest-continue-on-failure --ignore-glob=deapi/tests deapi + uv run pytest --doctest-modules --doctest-continue-on-failure --ignore-glob=deapi/tests deapi - name: Run tests run: | - pytest --cov=. --cov-report=xml + uv run pytest --timeout=60 --cov=. --cov-report=xml - name: Generate line coverage if: ${{ matrix.os == 'ubuntu-latest' }} run: | - coverage report --show-missing + uv run coverage report --show-missing - name: Upload coverage to Codecov if: ${{ always() }} diff --git a/deapi/simulated_server/fake_server.py b/deapi/simulated_server/fake_server.py index 6ef260b3..829141e4 100644 --- a/deapi/simulated_server/fake_server.py +++ b/deapi/simulated_server/fake_server.py @@ -610,6 +610,9 @@ def _fake_get_result(self, command): ack1.command_id = command.command[0].command_id frame_type = command.command[0].parameter[0].p_int pixel_format = command.command[0].parameter[1].p_int + # AUTO pixel format (-1) — resolve to UINT16 (5) + if pixel_format not in (1, 5, 13): + pixel_format = 5 center_x = command.command[0].parameter[2].p_int center_y = command.command[0].parameter[3].p_int zoom = command.command[0].parameter[4].p_float diff --git a/deapi/tests/conftest.py b/deapi/tests/conftest.py index 141f6755..cd0dfbc7 100644 --- a/deapi/tests/conftest.py +++ b/deapi/tests/conftest.py @@ -144,8 +144,12 @@ def server(xprocess, request): class Starter(ProcessStarter): timeout = 60 pattern = "started" + # -u disables Python's stdout/stderr buffering so xprocess sees + # the "started" banner immediately — critical on macOS where the + # loopback interface flushes less aggressively than on Linux. args = [ sys.executable, + "-u", curdir / "simulated_server/initialize_server.py", port, ] @@ -181,10 +185,13 @@ def client(xprocess, request): curdir = pathlib.Path(__file__).parent.parent class Starter(ProcessStarter): - timeout = 50 + timeout = 60 pattern = "started" + # -u: unbuffered output so the "started" banner is flushed + # immediately to the xprocess log file on all platforms. args = [ sys.executable, + "-u", curdir / "simulated_server/initialize_server.py", port, ] diff --git a/deapi/tests/test_scanning/test_continual_scanning.py b/deapi/tests/test_scanning/test_continual_scanning.py index fbfefd57..83f8607a 100644 --- a/deapi/tests/test_scanning/test_continual_scanning.py +++ b/deapi/tests/test_scanning/test_continual_scanning.py @@ -13,6 +13,8 @@ from time import sleep import glob +from deapi.tests.conftest import wait_for_idle + class TestContinualScanning: """Test class for continual scanning functionality.""" @@ -56,8 +58,7 @@ def test_continual_scanning(self, client): # Start the scan client.start_acquisition() - while client.acquiring: - sleep(0.1) + wait_for_idle(client) # After scan completion, verify the scan parameters sleep(3) # wait for any finalization @@ -99,8 +100,7 @@ def test_sending_multiple_scan_patterns(self, client): assert client["Frame Count"] == frames_per_scan * reps client.start_acquisition() - while client.acquiring: - sleep(0.1) + wait_for_idle(client) total_points = 80 * reps assert client["Scan - Points (Recorded)"] == total_points @@ -140,8 +140,7 @@ def test_multiple_scan_patterns_different_lengths(self, client, index): client["Scan - Enable"] = True client["Reference - Dark"] = "None" client.start_acquisition() - while client.acquiring: - sleep(0.1) + wait_for_idle(client) res = client.get_result("SINGLEFRAME_INTEGRATED") assert np.max(res.image) == num_p - 1 print(f"frame Count: {client['Frame Count']}") @@ -186,8 +185,7 @@ def test_repeat_scanning_no_DE_camera(self, client): # Start the scan client.start_acquisition() - while client.acquiring: - sleep(0.1) + wait_for_idle(client) # After scan completion, verify the scan parameters result = client.get_result("external_image1") @@ -210,8 +208,7 @@ def test_saving_virtual_images(self, client, tmp_path): # Start the scan client.start_acquisition() - while client.acquiring: - sleep(0.1) + wait_for_idle(client) sleep(3) # wait for any finalization path = client["Autosave Virtual Image 0 File Path"] @@ -242,8 +239,7 @@ def test_frame_repeats(self, client): # Start the scan client.start_acquisition() - while client.acquiring: - sleep(0.1) + wait_for_idle(client) res_1 = client.get_result("virtual_image0", pixel_format="AUTO") @@ -272,9 +268,7 @@ def test_frame_repeats_auto_save(self, client): # Start the scan client.start_acquisition() - while client.acquiring: - - sleep(0.1) + wait_for_idle(client) print(client["Acquisition Status"]) sleep(3) res_1 = client.get_result("virtual_image0", pixel_format="AUTO") @@ -289,8 +283,7 @@ def test_frame_repeats_auto_save(self, client): client["Scan - Repeats"] = 3 client.start_acquisition() - while client.acquiring: - sleep(0.1) + wait_for_idle(client) sleep(3) print(client["Acquisition Status"]) res_2 = client.get_result("virtual_image0", pixel_format="AUTO") @@ -323,8 +316,7 @@ def test_frame_repeats_auto_save(self, client): client["Scan - Repeats"] = 2 client.start_acquisition() - while client.acquiring: - sleep(0.1) + wait_for_idle(client) sleep(1) res_2 = client.get_result("virtual_image0", pixel_format="AUTO") frame_number_2 = (res_2.image / n_pix) / client[ diff --git a/doc/reference/scan_design.rst b/doc/reference/scan_design.rst index 2f0c5429..97851072 100644 --- a/doc/reference/scan_design.rst +++ b/doc/reference/scan_design.rst @@ -34,20 +34,25 @@ during acquisition, and the dynamic definition of virtual images. As a compromis the properties ``Scan - Offset X (points)`` and ``Scan - Offset Y (points)`` can shift the beam by some fraction of a scan point — analogous to using image shift for drift correction in a TEM. This is a beta feature that requires additional -testing. +testing. There is a short delay between setting these properties and the beam shift +taking effect as the offset is applied to the next batch of scan points sent to the +scan generator from the control computer. -Custom scan patterns may also be defined. In this case, the 3V ↔ −3V area is -subdivided into a grid and only specified positions are scanned. The returned -virtual image will contain the scanned values at those positions and zeros -elsewhere. +Custom scan patterns may also be defined. In this case, the voltage range for the +microscope (usually a ~3V ↔ −3V area) is subdivided into a grid and only specified +positions are scanned. The returned virtual image will contain the scanned values at +those positions and zeros elsewhere. --------------------------- +----------------------------- Defining Custom Scan Patterns --------------------------- +----------------------------- Lists of X/Y scan points define scan patterns. The total scan area (when no ROI is active) is defined by the full voltage range of the microscope. Finer voltage -control is achieved by increasing ``Scan - Size X`` and ``Scan - Size Y``. +control is achieved by increasing the width and height of the scan pattern, which +subdivides the voltage range into a finer grid. For example, width/height of 10×10 patterns +subdivides the voltage range into 100 points, while a 100×100 pattern subdivides it into +10,000 points. In most cases, scan patterns are pre-defined (``Raster``, ``Serpentine``, ``Distributed``, etc.) and a single pattern is used. When ``Scan - Repeats`` > 1, @@ -59,12 +64,16 @@ For custom X/Y scan patterns, it is possible to: * Cycle through multiple scan patterns in sequence. .. note:: - The underlying ``height`` and ``width`` must be equal for all patterns in a set. + The underlying ``height`` and ``width`` for a set of scan patterns must be the same, + even if the number of points in each pattern differs. + Example: Sending Multiple Patterns (Scan Repeat == 1) ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -This example sends three scan patterns and runs each one individually. +This example sends three scan patterns and runs each one individually. Each pattern +probes a different number of positions within the same 10×10 grid. After every +acquisition the recorded frame count is verified against the expected point count. .. tabs:: @@ -74,34 +83,136 @@ This example sends three scan patterns and runs each one individually. import numpy as np from time import sleep + import deapi + + # Connect to the DE-Server (defaults to localhost:13240) + client = deapi.Client() + client.connect() - scan1 = np.array([[0, 0], [1, 0], [1, 1], [0, 1]]) # 4 points - scan2 = np.array([[0, 0], [2, 0], [2, 2], [0, 2], [1, 1]]) # 5 points - scan3 = np.array([[0, 0], [3, 0], [3, 3], [0, 3], [1, 1], [2, 2]]) # 6 points - scans = [scan3, scan2, scan1] + # Define three custom scan patterns on a 10×10 grid. + # Each row is an [x, y] integer coordinate in the grid. + scan1 = np.array([[0, 0], [1, 0], [1, 1], [0, 1]], dtype=np.int32) # 4 points + scan2 = np.array([[0, 0], [2, 0], [2, 2], [0, 2], [1, 1]], dtype=np.int32) # 5 points + scan3 = np.array([[0, 0], [3, 0], [3, 3], [0, 3], [1, 1], [2, 2]], dtype=np.int32) # 6 points - client.set_xy_array(scans, height=10, width=10) # Send all 3 scans to DE-Server + # Pack all three patterns into the list in index order (index 0, 1, 2). + # height and width define the voltage-grid resolution; all patterns in a + # batch must share the same grid dimensions. + scans = [scan1, scan2, scan3] + client.set_xy_array(scans, height=10, width=10) # Upload all 3 patterns to DE-Server + # Run each pattern exactly once and confirm the recorded frame count. for i, num_points in enumerate([4, 5, 6]): - client["Scan - Repeats"] = 1 # Single scan, no repeats - client["Scan - XY File Pattern ID"] = i # Select pattern by index + client["Scan - Repeats"] = 1 # Single pass — no looping + client["Scan - XY File Pattern ID"] = i # Select which pattern to execute client["Scan - Enable"] = True client.start_acquisition() + + # Poll until the acquisition is complete while client.acquiring: sleep(0.1) - assert client["Scan - Frames (Recorded)"] == num_points # 4, 5, or 6 + + # The server records one frame per scan point, so the count must match. + assert client["Scan - Frames (Recorded)"] == num_points, ( + f"Pattern {i}: expected {num_points} frames, " + f"got {client['Scan - Frames (Recorded)']}" + ) + print(f"Pattern {i} OK — {num_points} frames recorded.") .. tab:: C# .. code-block:: csharp - // TODO: Add C# implementation + using System; + using System.Threading; + using DirectElectron.DEAPI; + + // Connect to the DE-Server (defaults to localhost:13240) + Client client = new Client(); + client.Connect("127.0.0.1"); + + // Define three custom scan patterns on a 10x10 grid. + // Each row is an {x, y} integer coordinate in the grid. + int[,] scan1 = { {0,0}, {1,0}, {1,1}, {0,1} }; // 4 points + int[,] scan2 = { {0,0}, {2,0}, {2,2}, {0,2}, {1,1} }; // 5 points + int[,] scan3 = { {0,0}, {3,0}, {3,3}, {0,3}, {1,1}, {2,2} }; // 6 points + + // Pack all three patterns into the array in index order (0, 1, 2). + // height and width define the voltage-grid resolution; all patterns in a + // batch must share the same grid dimensions. + int[][,] scans = { scan1, scan2, scan3 }; + client.SetXYArray(scans, height: 10, width: 10); // Upload all 3 patterns to DE-Server + + // Run each pattern exactly once and confirm the recorded frame count. + int[] pointCounts = { 4, 5, 6 }; + for (int i = 0; i < scans.Length; i++) + { + client.SetProperty("Scan - Repeats", "1"); // Single pass — no looping + client.SetProperty("Scan - XY File Pattern ID", i.ToString()); // Select pattern + client.SetProperty("Scan - Enable", "On"); + client.StartAcquisition(); + + // Poll until the acquisition is complete + while (client.IsAcquiring()) + Thread.Sleep(100); + + // The server records one frame per scan point, so the count must match. + int recorded = int.Parse(client.GetProperty("Scan - Frames (Recorded)")); + if (recorded != pointCounts[i]) + throw new Exception($"Pattern {i}: expected {pointCounts[i]} frames, got {recorded}"); + + Console.WriteLine($"Pattern {i} OK — {pointCounts[i]} frames recorded."); + } .. tab:: C++ .. code-block:: cpp - // TODO: Add C++ implementation + #include + #include + #include + #include + #include + #include "deapi/client.hpp" + + int main() { + // Connect to the DE-Server (defaults to localhost:13240) + deapi::Client client; + client.connect("127.0.0.1"); + + // Define three custom scan patterns on a 10x10 grid. + // Each inner vector holds {x, y} integer coordinates. + std::vector>> scans = { + { {0,0}, {1,0}, {1,1}, {0,1} }, // scan1 — 4 points + { {0,0}, {2,0}, {2,2}, {0,2}, {1,1} }, // scan2 — 5 points + { {0,0}, {3,0}, {3,3}, {0,3}, {1,1}, {2,2} } // scan3 — 6 points + }; + + // Upload all three patterns to DE-Server. + // height and width define the voltage-grid resolution; every pattern in a + // batch must share the same grid dimensions. + client.setXYArray(scans, /*height=*/10, /*width=*/10); + + // Run each pattern exactly once and confirm the recorded frame count. + int pointCounts[] = { 4, 5, 6 }; + for (int i = 0; i < static_cast(scans.size()); ++i) { + client.setProperty("Scan - Repeats", "1"); // Single pass — no looping + client.setProperty("Scan - XY File Pattern ID", std::to_string(i)); // Select pattern + client.setProperty("Scan - Enable", "On"); + client.startAcquisition(); + + // Poll until the acquisition is complete + while (client.isAcquiring()) + std::this_thread::sleep_for(std::chrono::milliseconds(100)); + + // The server records one frame per scan point, so the count must match. + int recorded = std::stoi(client.getProperty("Scan - Frames (Recorded)")); + assert(recorded == pointCounts[i]); + std::cout << "Pattern " << i << " OK — " << pointCounts[i] + << " frames recorded.\n"; + } + return 0; + } Example: Cycling Through Multiple Patterns (Scan Repeat > 1) ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -117,39 +228,155 @@ through them for 100 repeats. import numpy as np from time import sleep + import deapi + # Connect to the DE-Server (defaults to localhost:13240) + client = deapi.Client() + client.connect() + + # Base pattern — 8 corner/edge positions on the grid. + # Patterns are shifted by 2*i each iteration to probe different regions. scan1 = np.array([ [0, 0], [1, 0], [1, 1], [0, 1], [2, 1], [2, 0], [0, 2], [1, 2] - ]) # 8 points + ], dtype=np.int32) # 8 points + # Generate 5 patterns by translating the base pattern diagonally. + # All patterns fall within a 10×10 voltage grid. scans = [scan1 + 2 * i for i in range(5)] + + # Upload all 5 patterns; the server will cycle through them in order. client.set_xy_array(scans, height=10, width=10) + # Configure cycling: the server repeats the entire pattern set 100 times. + # With Scan - Repeat Delay = 0, patterns are executed back-to-back. client["Scan - Repeats"] = 100 client["Scan - Enable"] = True - client["Scan - Camera Frames Per Point"] = 1 - client["Scan - Repeat Delay"] = 0 # seconds + client["Scan - Camera Frames Per Point"] = 1 # One detector frame per scan point + client["Scan - Repeat Delay"] = 0 # No pause between repeats (seconds) client.start_acquisition() + + # Wait for all 100 repeat cycles to complete while client.acquiring: sleep(0.1) + # Allow the server a moment to finish any final bookkeeping sleep(5) .. tab:: C# .. code-block:: csharp - // TODO: Add C# implementation + using System; + using System.Threading; + using DirectElectron.DEAPI; + + // Connect to the DE-Server (defaults to localhost:13240) + Client client = new Client(); + client.Connect("127.0.0.1"); + + // Base pattern — 8 corner/edge positions on the grid. + int[,] scan1 = { + {0,0}, {1,0}, {1,1}, {0,1}, + {2,1}, {2,0}, {0,2}, {1,2} + }; // 8 points + + // Generate 5 patterns by translating the base pattern diagonally. + // all patterns lie within a 10×10 voltage grid. + int numPatterns = 5; + int[][,] scans = new int[numPatterns][,]; + for (int i = 0; i < numPatterns; i++) + { + int rows = scan1.GetLength(0); + scans[i] = new int[rows, 2]; + for (int r = 0; r < rows; r++) + { + scans[i][r, 0] = scan1[r, 0] + 2 * i; // shift X + scans[i][r, 1] = scan1[r, 1] + 2 * i; // shift Y + } + } + + // Upload all 5 patterns; the server cycles through them in order. + client.SetXYArray(scans, height: 10, width: 10); + + // Configure cycling: the server repeats the full pattern set 100 times. + client.SetProperty("Scan - Repeats", "100"); + client.SetProperty("Scan - Enable", "On"); + client.SetProperty("Scan - Camera Frames Per Point", "1"); // One detector frame per point + client.SetProperty("Scan - Repeat Delay", "0"); // No pause between repeats + client.StartAcquisition(); + + // Wait for all 100 repeat cycles to complete + while (client.IsAcquiring()) + Thread.Sleep(100); + + // Allow the server a moment to finish any final bookkeeping + Thread.Sleep(5000); .. tab:: C++ .. code-block:: cpp - // TODO: Add C++ implementation - -Cycling through patterns is important for reducing latency with repeated scans, -particularly with regard to allocating memory for results. + #include + #include + #include + #include + #include + #include "deapi/client.hpp" + + int main() { + // Connect to the DE-Server (defaults to localhost:13240) + deapi::Client client; + client.connect("127.0.0.1"); + + // Base pattern — 8 corner/edge positions on the grid. + std::vector> scan1 = { + {0,0}, {1,0}, {1,1}, {0,1}, + {2,1}, {2,0}, {0,2}, {1,2} + }; // 8 points + + // Generate 5 patterns by translating the base pattern diagonally. + // All patterns lie within a 10×10 voltage grid. + std::vector>> scans; + for (int i = 0; i < 5; ++i) { + std::vector> pat = scan1; + for (auto& pt : pat) { + pt[0] += 2 * i; // shift X + pt[1] += 2 * i; // shift Y + } + scans.push_back(pat); + } + + // Upload all 5 patterns; the server cycles through them in order. + client.setXYArray(scans, /*height=*/10, /*width=*/10); + + // Configure cycling: the server repeats the full pattern set 100 times. + client.setProperty("Scan - Repeats", "100"); + client.setProperty("Scan - Enable", "On"); + client.setProperty("Scan - Camera Frames Per Point", "1"); // One detector frame per point + client.setProperty("Scan - Repeat Delay", "0"); // No pause between repeats + client.startAcquisition(); + + // Wait for all 100 repeat cycles to complete + while (client.isAcquiring()) + std::this_thread::sleep_for(std::chrono::milliseconds(100)); + + // Allow the server a moment to finish any final bookkeeping + std::this_thread::sleep_for(std::chrono::seconds(5)); + return 0; + } + +Cycling through patterns allows for more complex scan patterns. For example you can define + +Pattern A: A small scan over a fiduciary mark for drift correction +Pattern B: A large scan over the area of interest for data collection + +... and cycle through A-B-A-B-A-B-… using Pattern A for drift correction and Pattern B for +data collection. + +For inpainting a collection of scan patterns can be defined and sent in a batch. For +example you can define patterns A,B, C, D, E, F, G, H, I. Then you can cycle through +A-B-C-D-E-F-G-H-I-A-B-C-D-E-F-G-H-I-… -------------------------- Returning Virtual Images @@ -176,3 +403,319 @@ There are three ways to access virtual images: The client subscribes to a stream of images from the server, and every virtual image is delivered. Use this when all frames must be captured. + +In most cases 3 is the best option for continual updates in order to make sure that each +frame is captured. + + +.. tabs:: + + .. tab:: Python + + .. code-block:: python + + import numpy as np + from time import sleep + import deapi + from deapi.data_types import MovieBufferStatus + + # Connect to the DE-Server (defaults to localhost:13240) + client = deapi.Client() + client.connect() + + num_repeats = 10 + + # ── Build sparse scan patterns ──────────────────────────────────────────── + # Create 100 patterns that are 128×128 in size, each containing ~5% of the + # total grid positions chosen at random (sparse / inpainting acquisition). + state = np.random.RandomState(0) + coords = [] + for i in range(100): + mask = np.ones((128, 128), dtype=bool) + # Randomly remove 95% of the positions to create a sparse pattern + mask[state.random(mask.shape) < 0.95] = False + # Convert the boolean mask to a list of [x, y] integer coordinates + coords.append(np.argwhere(mask).astype(np.int32)) + + # Upload all 100 sparse patterns (each shares the same 128×128 grid). + client.set_xy_array(coords, height=128, width=128) + + # ── Configure acquisition ───────────────────────────────────────────────── + client["Scan - Repeats"] = num_repeats + assert client["Scan - Repeats"] == num_repeats + + # Query the virtual image dimensions before starting so we know how to + # reshape the raw bytes returned by get_virtual_image_buffer(). + info = client.get_virtual_image_buffer_info() + assert info.width == 128 + assert info.height == 128 + + # ── Set up three virtual detectors ──────────────────────────────────────── + # Virtual detectors 1–3 collect signal in an elliptical annulus. + # Virtual detector 4 is disabled. + NUM_VIRTUAL_BUFFERS = 3 # Number of active virtual detector channels (1, 2, 3) + client["Scan - Virtual Detector 1 Shape"] = "Ellipse" + client["Scan - Virtual Detector 2 Shape"] = "Ellipse" + client["Scan - Virtual Detector 3 Shape"] = "Ellipse" + client["Scan - Virtual Detector 4 Shape"] = "Off" + + # ── Start acquisition with virtual buffer streaming enabled ─────────────── + # queue_virtual_buffers=True enables buffered delivery for all 5 virtual + # channels; only the 3 active detectors will actually produce frames. + client.start_acquisition( + number_of_acquisitions=1, + queue_virtual_buffers=True, + ) + + # ── Collect streamed virtual image frames ───────────────────────────────── + # Each entry is (buf_id, frame_index, image_array). + received_frames: list[tuple[int, int, np.ndarray]] = [] + + # MovieBufferStatus meanings: + # UNKNOWN = 0 — status not yet determined + # FAILED = 1 — retrieval error (e.g. detector not initialized) + # TIMEOUT = 3 — no frame available within timeout_msec; retry + # FINISHED = 4 — this channel has no more frames (acquisition done) + # OK = 5 — frame retrieved successfully; image data is valid + + finished = False + while not finished: + # Iterate over each active virtual detector channel + for buf_id in range(NUM_VIRTUAL_BUFFERS): + got_frame = False + while not got_frame: + status, frame_index, image = client.get_virtual_image_buffer( + buf_id, + virtual_image_info=info, + timeout_msec=1000, # Wait up to 1 s for a frame + ) + + if status == MovieBufferStatus.OK: + # Frame retrieved — store it for downstream processing + received_frames.append((buf_id, frame_index, image)) + got_frame = True + + elif status == MovieBufferStatus.FINISHED: + # No more frames on any channel — acquisition is complete + finished = True + got_frame = True + + elif status == MovieBufferStatus.TIMEOUT: + # Frame not yet available; loop and try again + got_frame = False + + elif status == MovieBufferStatus.FAILED: + print( + f"buf_id={buf_id} frame={frame_index}: failed to retrieve frame — " + f"likely this virtual image channel is not initialized." + ) + got_frame = True # Skip this frame and continue + + .. tab:: C# + + .. code-block:: csharp + + using System; + using System.Collections.Generic; + using System.Threading; + using DirectElectron.DEAPI; + + // Connect to the DE-Server (defaults to localhost:13240) + Client client = new Client(); + client.Connect("127.0.0.1"); + + int numRepeats = 10; + + // ── Build sparse scan patterns ──────────────────────────────────────────── + // Create 100 patterns that are 128×128 in size, each with ~5% of grid + // positions chosen at random (sparse / inpainting acquisition). + var rng = new Random(0); + var coords = new List(); + for (int p = 0; p < 100; p++) + { + var points = new List(); + for (int y = 0; y < 128; y++) + for (int x = 0; x < 128; x++) + if (rng.NextDouble() >= 0.95) // keep ~5% of positions + points.Add(new int[] { x, y }); + + int[,] arr = new int[points.Count, 2]; + for (int k = 0; k < points.Count; k++) + { + arr[k, 0] = points[k][0]; + arr[k, 1] = points[k][1]; + } + coords.Add(arr); + } + + // Upload all 100 sparse patterns (each shares the same 128×128 grid). + client.SetXYArray(coords.ToArray(), height: 128, width: 128); + + // ── Configure acquisition ───────────────────────────────────────────────── + client.SetProperty("Scan - Repeats", numRepeats.ToString()); + + // Query virtual image dimensions before starting so we know how to interpret + // the raw bytes returned by GetVirtualImageBuffer(). + VirtualImageInfo info = client.GetVirtualImageBufferInfo(); + if (info.Width != 128 || info.Height != 128) + throw new Exception($"Unexpected virtual image size: {info.Width}x{info.Height}"); + + // ── Set up three virtual detectors ──────────────────────────────────────── + const int NUM_VIRTUAL_BUFFERS = 3; // Active detector channels (1, 2, 3) + client.SetProperty("Scan - Virtual Detector 1 Shape", "Ellipse"); + client.SetProperty("Scan - Virtual Detector 2 Shape", "Ellipse"); + client.SetProperty("Scan - Virtual Detector 3 Shape", "Ellipse"); + client.SetProperty("Scan - Virtual Detector 4 Shape", "Off"); + + // ── Start acquisition with virtual buffer streaming enabled ─────────────── + client.StartAcquisition(numberOfAcquisitions: 1, queueVirtualBuffers: true); + + // ── Collect streamed virtual image frames ───────────────────────────────── + // Each entry stores which detector channel produced the frame, its index, + // and the pixel data as a 2-D float array. + var receivedFrames = new List<(int BufId, int FrameIndex, float[,] Image)>(); + + // MovieBufferStatus values: + // UNKNOWN = 0 — status not yet determined + // FAILED = 1 — retrieval error (e.g. detector not initialized) + // TIMEOUT = 3 — no frame available within timeoutMsec; retry + // FINISHED = 4 — no more frames on this channel + // OK = 5 — frame retrieved successfully + + bool finished = false; + while (!finished) + { + for (int bufId = 0; bufId < NUM_VIRTUAL_BUFFERS; bufId++) + { + bool gotFrame = false; + while (!gotFrame) + { + var (status, frameIndex, image) = + client.GetVirtualImageBuffer(bufId, info, timeoutMsec: 1000); + + if (status == MovieBufferStatus.OK) + { + receivedFrames.Add((bufId, frameIndex, image)); + gotFrame = true; + } + else if (status == MovieBufferStatus.Finished) + { + finished = true; + gotFrame = true; + } + else if (status == MovieBufferStatus.Timeout) + { + // Frame not yet available — retry + gotFrame = false; + } + else // FAILED or UNKNOWN + { + Console.WriteLine( + $"bufId={bufId} frame={frameIndex}: failed to retrieve frame — " + + $"likely this virtual image channel is not initialized."); + gotFrame = true; + } + } + } + } + + .. tab:: C++ + + .. code-block:: cpp + + #include + #include + #include + #include + #include + #include "deapi/client.hpp" + #include "deapi/data_types.hpp" + + int main() { + // Connect to the DE-Server (defaults to localhost:13240) + deapi::Client client; + client.connect("127.0.0.1"); + + const int numRepeats = 10; + + // ── Build sparse scan patterns ──────────────────────────────────────── + // Create 100 patterns that are 128×128 in size, each containing ~5% of + // the total grid positions chosen at random (sparse / inpainting). + std::mt19937 rng(0); + std::uniform_real_distribution dist(0.0, 1.0); + + std::vector>> coords; + for (int p = 0; p < 100; ++p) { + std::vector> pts; + for (int y = 0; y < 128; ++y) + for (int x = 0; x < 128; ++x) + if (dist(rng) >= 0.95) // keep ~5% of positions + pts.push_back({x, y}); + coords.push_back(pts); + } + + // Upload all 100 sparse patterns (each shares the same 128×128 grid). + client.setXYArray(coords, /*height=*/128, /*width=*/128); + + // ── Configure acquisition ───────────────────────────────────────────── + client.setProperty("Scan - Repeats", std::to_string(numRepeats)); + + // Query virtual image dimensions before starting so we know how to + // reshape the raw bytes returned by getVirtualImageBuffer(). + deapi::VirtualImageInfo info = client.getVirtualImageBufferInfo(); + assert(info.width == 128 && info.height == 128); + + // ── Set up three virtual detectors ──────────────────────────────────── + constexpr int NUM_VIRTUAL_BUFFERS = 3; // Active channels (0, 1, 2) + client.setProperty("Scan - Virtual Detector 1 Shape", "Ellipse"); + client.setProperty("Scan - Virtual Detector 2 Shape", "Ellipse"); + client.setProperty("Scan - Virtual Detector 3 Shape", "Ellipse"); + client.setProperty("Scan - Virtual Detector 4 Shape", "Off"); + + // ── Start acquisition with virtual buffer streaming enabled ─────────── + client.startAcquisition(/*numberOfAcquisitions=*/1, + /*requestMovieBuffer=*/false, + /*queueVirtualBuffers=*/true); + + // ── Collect streamed virtual image frames ───────────────────────────── + // Each entry stores the detector channel, frame index, and pixel data. + using Frame = std::tuple>; + std::vector receivedFrames; + + // deapi::MovieBufferStatus values: + // UNKNOWN = 0 — status not yet determined + // FAILED = 1 — retrieval error (e.g. detector not initialized) + // TIMEOUT = 3 — no frame available within timeoutMsec; retry + // FINISHED = 4 — no more frames on this channel + // OK = 5 — frame retrieved successfully + + bool finished = false; + while (!finished) { + for (int bufId = 0; bufId < NUM_VIRTUAL_BUFFERS; ++bufId) { + bool gotFrame = false; + while (!gotFrame) { + auto [status, frameIndex, image] = + client.getVirtualImageBuffer(bufId, info, /*timeoutMsec=*/1000); + + if (status == deapi::MovieBufferStatus::OK) { + receivedFrames.emplace_back(bufId, frameIndex, image); + gotFrame = true; + } else if (status == deapi::MovieBufferStatus::FINISHED) { + finished = true; + gotFrame = true; + } else if (status == deapi::MovieBufferStatus::TIMEOUT) { + // Frame not yet available — retry + gotFrame = false; + } else { // FAILED or UNKNOWN + std::cerr << "bufId=" << bufId + << " frame=" << frameIndex + << ": failed to retrieve frame — " + "likely this virtual image channel is not initialized.\n"; + gotFrame = true; + } + } + } + } + return 0; + } + diff --git a/pyproject.toml b/pyproject.toml index 6498aaf9..54824964 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,7 +17,7 @@ classifiers = [ ] dependencies = [ "numpy>=1.20.0", - "protobuf", + "protobuf>=3.20.0", "pillow", "matplotlib", "scipy", @@ -53,7 +53,9 @@ tests = [ "setuptools_scm", "pytest-cov", "pytest-xprocess", + "pytest-timeout", "hyperspy", + "psutil", ] service = [ "pyqt6", From 75f94299f247de2392c715627c1e058ff4d23d58 Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Fri, 8 May 2026 13:34:31 -0500 Subject: [PATCH 18/20] Enhancement: Remove unnecessary blank lines in multiple files for cleaner code --- deapi/__init__.py | 1 - deapi/buffer_protocols/pb_3_19_3.py | 1 + deapi/buffer_protocols/pb_3_23_3.py | 1 + deapi/conf.py | 1 - deapi/tests/original_tests/05_swhwBinning.py | 1 - deapi/tests/original_tests/10_imageStatistics.py | 1 - deapi/tests/test_fake_server/test_server.py | 1 + deapi/tests/test_file_saving/test_file_loading_libertem.py | 1 + examples/live_imaging/bright_spot_intensity.py | 1 - 9 files changed, 4 insertions(+), 5 deletions(-) diff --git a/deapi/__init__.py b/deapi/__init__.py index 90045e90..af85f8e4 100644 --- a/deapi/__init__.py +++ b/deapi/__init__.py @@ -16,7 +16,6 @@ PropertyCollection, ) - __all__ = [ "Client", "__version__", diff --git a/deapi/buffer_protocols/pb_3_19_3.py b/deapi/buffer_protocols/pb_3_19_3.py index 25575360..54ae7bf2 100644 --- a/deapi/buffer_protocols/pb_3_19_3.py +++ b/deapi/buffer_protocols/pb_3_19_3.py @@ -2,6 +2,7 @@ # Generated by the protocol buffer compiler. DO NOT EDIT! # source: DE.proto """Generated protocol buffer code.""" + from google.protobuf import descriptor as _descriptor from google.protobuf import descriptor_pool as _descriptor_pool from google.protobuf import message as _message diff --git a/deapi/buffer_protocols/pb_3_23_3.py b/deapi/buffer_protocols/pb_3_23_3.py index 1fdb8b69..f5de9354 100644 --- a/deapi/buffer_protocols/pb_3_23_3.py +++ b/deapi/buffer_protocols/pb_3_23_3.py @@ -2,6 +2,7 @@ # Generated by the protocol buffer compiler. DO NOT EDIT! # source: DE.proto """Generated protocol buffer code.""" + from google.protobuf import descriptor as _descriptor from google.protobuf import descriptor_pool as _descriptor_pool from google.protobuf import symbol_database as _symbol_database diff --git a/deapi/conf.py b/deapi/conf.py index 50ef784c..9ed76134 100644 --- a/deapi/conf.py +++ b/deapi/conf.py @@ -9,7 +9,6 @@ import os import deapi - # If extensions (or modules to document with autodoc) are in another directory, # add these directories to sys.path here. If the directory is relative to the # documentation root, use os.path.abspath to make it absolute, like shown here. diff --git a/deapi/tests/original_tests/05_swhwBinning.py b/deapi/tests/original_tests/05_swhwBinning.py index 199298ea..4c9e9c56 100644 --- a/deapi/tests/original_tests/05_swhwBinning.py +++ b/deapi/tests/original_tests/05_swhwBinning.py @@ -5,7 +5,6 @@ import deapi as DEAPI from deapi.tests.original_tests import func, propertyName - # Connect to the server deClient = DEAPI.Client() deClient.Connect() diff --git a/deapi/tests/original_tests/10_imageStatistics.py b/deapi/tests/original_tests/10_imageStatistics.py index f09d1c25..8758a345 100644 --- a/deapi/tests/original_tests/10_imageStatistics.py +++ b/deapi/tests/original_tests/10_imageStatistics.py @@ -4,7 +4,6 @@ import deapi as DEAPI from deapi.tests.original_tests import func, propertyName - # Total e- = e-/pix * ROI Size # image->m_stats.physicalPixels = m_params.hw_frame.w * m_params.hw_frame.h; # image->m_stats.frameCount = static_cast(image->GetFrameCount()); diff --git a/deapi/tests/test_fake_server/test_server.py b/deapi/tests/test_fake_server/test_server.py index 633b78d3..49334231 100644 --- a/deapi/tests/test_fake_server/test_server.py +++ b/deapi/tests/test_fake_server/test_server.py @@ -34,4 +34,5 @@ def test_set_virtual_image_calculation(self, fake_server): def test_server_software_version(self, fake_server): from deapi.version import fake_server_software_version + assert fake_server["Server Software Version"] == fake_server_software_version diff --git a/deapi/tests/test_file_saving/test_file_loading_libertem.py b/deapi/tests/test_file_saving/test_file_loading_libertem.py index 89955073..1826bdd2 100644 --- a/deapi/tests/test_file_saving/test_file_loading_libertem.py +++ b/deapi/tests/test_file_saving/test_file_loading_libertem.py @@ -12,6 +12,7 @@ pytest.importorskip("libertem") + class TestLoadingLiberTEM: @pytest.fixture(autouse=True) def clean_state(self, client): diff --git a/examples/live_imaging/bright_spot_intensity.py b/examples/live_imaging/bright_spot_intensity.py index cfee434a..7b5ac01d 100644 --- a/examples/live_imaging/bright_spot_intensity.py +++ b/examples/live_imaging/bright_spot_intensity.py @@ -9,7 +9,6 @@ import time import sys - client = deapi.Client() if not sys.platform.startswith("win"): From a2071f7c399ad46da9e62a67de84ecc2d59badce Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Fri, 8 May 2026 13:34:37 -0500 Subject: [PATCH 19/20] Enhancement: Add schematic image for DE-Freescan scan design in documentation --- doc/_static/ScanPatterns.png | Bin 0 -> 39644 bytes doc/reference/scan_design.rst | 5 +++++ 2 files changed, 5 insertions(+) create mode 100644 doc/_static/ScanPatterns.png diff --git a/doc/_static/ScanPatterns.png b/doc/_static/ScanPatterns.png new file mode 100644 index 0000000000000000000000000000000000000000..acaa888322516d0e00ff6ff7a7a12905bce348e0 GIT binary patch literal 39644 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 Date: Mon, 11 May 2026 08:48:06 -0500 Subject: [PATCH 20/20] Enhancement: Implement virtual image buffer support and add tests for buffer acquisition --- deapi/client.py | 5 +- .../test_virtual_image_buffers.py | 268 ++++++++++++++++++ doc/reference/scan_design.rst | 2 +- 3 files changed, 272 insertions(+), 3 deletions(-) create mode 100644 deapi/tests/test_scanning/test_virtual_image_buffers.py diff --git a/deapi/client.py b/deapi/client.py index 7f1e1460..e3647925 100644 --- a/deapi/client.py +++ b/deapi/client.py @@ -1267,7 +1267,9 @@ def start_acquisition( log.debug(" Prepare Time: %.1f ms", lapsed) step_time = self.GetTime() - if isinstance(queue_virtual_buffers, bool): + if commandVersion < 16: + vb = [] + elif isinstance(queue_virtual_buffers, bool): vb = [queue_virtual_buffers] * 5 else: if len(queue_virtual_buffers) != 5: @@ -1440,7 +1442,6 @@ def set_xy_array(self, positions, width=None, height=None): return False else: if not pos.dtype == np.int32: - log.error("Positions must be integers... Casting to int") positions[i] = pos.astype(np.int32) elif pos.ndim != 2 or pos.shape[1] != 2: log.error("Positions must be of shape (N, 2)") diff --git a/deapi/tests/test_scanning/test_virtual_image_buffers.py b/deapi/tests/test_scanning/test_virtual_image_buffers.py new file mode 100644 index 00000000..033d3aa3 --- /dev/null +++ b/deapi/tests/test_scanning/test_virtual_image_buffers.py @@ -0,0 +1,268 @@ +""" +Tests for virtual image buffer acquisition. + +Verifies that, while an acquisition is running with ``queue_virtual_buffers=True`` +and ``Scan - Repeats = 5``, all 5 virtual detector buffers (ids 0–4) can be +streamed and contain valid image data. +""" + +import numpy as np +import pytest + +from deapi.data_types import MovieBufferStatus, VirtualImageInfo, DataType + +NUM_VIRTUAL_BUFFERS = 5 + + +class TestVirtualImageBuffers: + + @pytest.fixture(autouse=True) + def clean_state(self, client): + """Reset relevant properties to a known state before each test.""" + client.stop_acquisition() + client["Hardware Binning X"] = 1 + client["Hardware Binning Y"] = 1 + client["Hardware ROI Offset X"] = 0 + client["Hardware ROI Offset Y"] = 0 + client["Hardware ROI Size X"] = 1024 + client["Hardware ROI Size Y"] = 1024 + client["Binning X"] = 1 + client["Binning Y"] = 1 + client["Frames Per Second"] = 1000 + client["Scan - Type"] = "Raster" + client["Scan - Size X"] = 4 + client["Scan - Size Y"] = 4 + client["Scan - Enable"] = "On" + client["Scan - Repeats"] = 1 + client["Scan - Repeat Delay (seconds)"] = 0 + + # ------------------------------------------------------------------ + # Tests + # ------------------------------------------------------------------ + + @pytest.mark.server + def test_virtual_image_info_has_valid_dimensions(self, client): + """get_virtual_image_buffer_info() returns valid dimensions once acquisition starts.""" + info = client.get_virtual_image_buffer_info() + + client.start_acquisition( + number_of_acquisitions=1, + queue_virtual_buffers=True, + ) + + + assert isinstance(info, VirtualImageInfo) + assert info.width > 0, "Virtual image width must be > 0" + assert info.height > 0, "Virtual image height must be > 0" + assert info.buffer_size > 0, "Virtual image buffer_size must be > 0" + byte_num = 4 if info.data_type == DataType.DE32f else 2 + assert info.buffer_size == info.width * info.height * byte_num + + + @pytest.mark.server + def test_streaming_all_virtual_buffers(self, client): + """Stream virtual image buffers for all detector channels while acquiring. + + With ``Scan - Repeats = 5`` and 5 virtual detector channels (ids 0–4), + we expect exactly 5 frames × 5 channels = 25 valid images before the + server signals ``FINISHED``. + """ + num_repeats = 5 + client["Scan - Repeats"] = num_repeats + assert client["Scan - Repeats"] == num_repeats + # Fetch metadata immediately after starting — the server knows image + # dimensions before frames arrive. + + info = client.get_virtual_image_buffer_info() + assert info.width > 0 and info.height > 0, "Invalid virtual image dimensions" + + client["Scan - Virtual Detector 1 Shape"] = "Ellipse" + client["Scan - Virtual Detector 2 Shape"] = "Ellipse" + client["Scan - Virtual Detector 3 Shape"] = "Ellipse" + client["Scan - Virtual Detector 4 Shape"] = "Ellipse" + client.start_acquisition( + number_of_acquisitions=1, + queue_virtual_buffers=True, + ) + + received_frames: list[tuple[int, int, np.ndarray]] = [] + # UNKNOWN = 0, FAILED = 1, TIMEOUT = 3, FINISHED = 4, OK = 5 + # OK --> Still more frames to access + + finshed = False + + while not finshed: + for buf_id in range(NUM_VIRTUAL_BUFFERS): + status, frame_index, image = client.get_virtual_image_buffer( + buf_id, virtual_image_info=info + ) + print(f"buf_id={buf_id} frame={frame_index} status={status} image shape: {image.shape if image is not None else None}") + + if status == MovieBufferStatus.OK: + assert image is not None, ( + f"buf_id={buf_id} frame={frame_index}: status OK but image is None" + ) + assert image.shape == (info.height, info.width), ( + f"buf_id={buf_id} frame={frame_index}: unexpected shape {image.shape}" + ) + received_frames.append((buf_id, frame_index, image)) + + elif status == MovieBufferStatus.FINISHED: + finshed = True + # This channel is done — mark finished. + + elif status == MovieBufferStatus.TIMEOUT: + print(f"buf_id={buf_id} frame={frame_index}: timeout waiting for frame") + # This can happen if we check a channel before its first frame is ready. + # Just ignore and check again in the next loop iteration. + + elif status == MovieBufferStatus.FAILED: + print(f"buf_id={buf_id} frame={frame_index}: failed to retrieve frame-- Likely this" + f"virtual image is not initialized.") + + + expected_total = num_repeats * NUM_VIRTUAL_BUFFERS + assert len(received_frames) == expected_total, ( + f"Expected {expected_total} frames, got {len(received_frames)}" + ) + + @pytest.mark.server + def test_streaming_all_virtual_buffers_one_off(self, client): + """Stream virtual image buffers for all detector channels while acquiring. + + With ``Scan - Repeats = 5`` and 5 virtual detector channels (ids 0–4), + we expect exactly 5 frames × 5 channels = 25 valid images before the + server signals ``FINISHED``. + """ + num_repeats = 5 + client["Scan - Repeats"] = num_repeats + assert client["Scan - Repeats"] == num_repeats + # Fetch metadata immediately after starting — the server knows image + # dimensions before frames arrive. + + info = client.get_virtual_image_buffer_info() + assert info.width > 0 and info.height > 0, "Invalid virtual image dimensions" + + client["Scan - Virtual Detector 1 Shape"] = "Ellipse" + client["Scan - Virtual Detector 2 Shape"] = "Ellipse" + client["Scan - Virtual Detector 3 Shape"] = "Ellipse" + client["Scan - Virtual Detector 4 Shape"] = "Off" + client.start_acquisition( + number_of_acquisitions=1, + queue_virtual_buffers=True, + ) + + received_frames: list[tuple[int, int, np.ndarray]] = [] + # UNKNOWN = 0, FAILED = 1, TIMEOUT = 3, FINISHED = 4, OK = 5 + # OK --> Still more frames to access + + finshed = False + + while not finshed: + for buf_id in range(NUM_VIRTUAL_BUFFERS): + status, frame_index, image = client.get_virtual_image_buffer( + buf_id, virtual_image_info=info + ) + print( + f"buf_id={buf_id} frame={frame_index} status={status} image shape: {image.shape if image is not None else None}") + + if buf_id == 4: + assert status ==MovieBufferStatus.FAILED + if status == MovieBufferStatus.OK: + assert image is not None, ( + f"buf_id={buf_id} frame={frame_index}: status OK but image is None" + ) + assert image.shape == (info.height, info.width), ( + f"buf_id={buf_id} frame={frame_index}: unexpected shape {image.shape}" + ) + received_frames.append((buf_id, frame_index, image)) + + elif status == MovieBufferStatus.FINISHED: + finshed = True + # This channel is done — mark finished. + + elif status == MovieBufferStatus.TIMEOUT: + print(f"buf_id={buf_id} frame={frame_index}: timeout waiting for frame") + # This can happen if we check a channel before its first frame is ready. + # Just ignore and check again in the next loop iteration. + + elif status == MovieBufferStatus.FAILED: + print(f"buf_id={buf_id} frame={frame_index}: failed to retrieve frame-- Likely this" + f"virtual image is not initialized.") + + expected_total = num_repeats * (NUM_VIRTUAL_BUFFERS-1) + assert len(received_frames) == expected_total, ( + f"Expected {expected_total} frames, got {len(received_frames)}" + ) + + + + @pytest.mark.server + def test_streaming_multiple_xy_arrays(self, client): + """Stream virtual image buffers from multiple XY arrays + + """ + num_repeats = 10 + + # create 100 patterns that are 128 x 128 in size with only 10% of the points filled in. + state = np.random.RandomState(0) + coords = [] + + for i in range(100): + mask = np.ones((128, 128), dtype=bool) + # randomly remove 95% of the points + mask[state.random(mask.shape) < 0.95] = False + # turn the mask into a list of xy coordinates + coords.append(np.argwhere(mask).astype(int)) + + client.set_xy_array(coords, height=128, width=128) + + client["Scan - Repeats"] = num_repeats + assert client["Scan - Repeats"] == num_repeats + + info = client.get_virtual_image_buffer_info() + assert info.width == 128 + assert info.height == 128 + + # Set up the virtual images... + + client["Scan - Virtual Detector 1 Shape"] = "Ellipse" + client["Scan - Virtual Detector 2 Shape"] = "Ellipse" + client["Scan - Virtual Detector 3 Shape"] = "Ellipse" + client["Scan - Virtual Detector 4 Shape"] = "Off" + + client.start_acquisition( + number_of_acquisitions=1, + queue_virtual_buffers=True, + ) + + received_frames: list[tuple[int, int, np.ndarray]] = [] + # UNKNOWN = 0, FAILED = 1, TIMEOUT = 3, FINISHED = 4, OK = 5 + # OK --> Still more frames to access + + finished = False + + while not finished: + # cycle though and get the virtual images and get the buffer. + for buf_id in range(NUM_VIRTUAL_BUFFERS): + got_frame = False + while not got_frame: + status, frame_index, image = client.get_virtual_image_buffer( + buf_id, virtual_image_info=info, timeout_msec=1000 # 1 sec + ) + if status == MovieBufferStatus.OK: + received_frames.append((buf_id, frame_index, image)) # Do whatever with the frame. + got_frame = True + + elif status == MovieBufferStatus.FINISHED: + finished = True + # This channel is done — mark finished. + got_frame = True + + elif status == MovieBufferStatus.TIMEOUT: + got_frame = False + elif status == MovieBufferStatus.FAILED: + print(f"buf_id={buf_id} frame={frame_index}: failed to retrieve frame-- Likely this" + f" virtual image is not initialized.") + got_frame = True + diff --git a/doc/reference/scan_design.rst b/doc/reference/scan_design.rst index 6f2b5637..b868f55f 100644 --- a/doc/reference/scan_design.rst +++ b/doc/reference/scan_design.rst @@ -387,7 +387,7 @@ A-B-C-D-E-F-G-H-I-A-B-C-D-E-F-G-H-I-… Returning Virtual Images -------------------------- -When ``Scan - Repeats`` > 1 and ``Scan - Repeat Delay (seconds)`` > 5, the camera +When ``Scan - Repeats`` > 1 and ``Scan - Repeat Delay (seconds)`` < 5, the camera will operate continuously: the shutter remains open and the probe moves to the park position for the duration of the delay. The beam is **not** blanked during this time.