From 2bdbf9d6293c02939ae06e1e70b24669dcf716dc Mon Sep 17 00:00:00 2001 From: Bernardus Rendy <37468936+bernardusrendy@users.noreply.github.com> Date: Thu, 10 Jul 2025 13:31:58 -0700 Subject: [PATCH 01/11] Working addition, update, or removal of task, devices, and sample positions, need test --- alab_management/device_manager.py | 127 ++++++++++++++++++++- alab_management/device_view/__init__.py | 7 +- alab_management/device_view/device.py | 26 +++++ alab_management/device_view/device_view.py | 21 +++- alab_management/sample_view/__init__.py | 7 +- alab_management/sample_view/sample.py | 32 +++++- alab_management/sample_view/sample_view.py | 16 ++- alab_management/scripts/setup_lab.py | 84 ++++++++++++-- docs/source/best_practices.md | 16 ++- 9 files changed, 314 insertions(+), 22 deletions(-) diff --git a/alab_management/device_manager.py b/alab_management/device_manager.py index c8cd1706..0592e231 100644 --- a/alab_management/device_manager.py +++ b/alab_management/device_manager.py @@ -24,8 +24,12 @@ from pika.adapters.blocking_connection import BlockingChannel from pika.spec import Basic +from alab_management.sample_view.sample_view import SamplePositionStatus +from alab_management.scripts.setup_lab import setup_lab + from .config import AlabOSConfig -from .device_view.device_view import DeviceTaskStatus, DeviceView +from .device_view.device_view import DevicePauseStatus, DeviceTaskStatus, DeviceView +from .sample_view import SampleView from .utils.data_objects import get_rabbitmq_connection from .utils.module_ops import load_definition @@ -127,18 +131,137 @@ def __init__(self, _check_status: bool = True): AlabOSConfig()["general"]["name"] + DEFAULT_SERVER_QUEUE_SUFFIX ) self._device_view = DeviceView(connect_to_devices=True) + self._sample_view = SampleView() self._check_status = _check_status self.threads = [] + self.device_names_to_be_removed = [] + self.device_names_to_be_removed_thread = Thread( + target=self._check_device_status_and_remove_if_not_occupied + ) + self.device_names_to_be_removed_thread.daemon = True + self.device_names_to_be_removed_thread.start() + self.device_names_to_be_removed_that_is_paused = [] + self.sample_positions_to_be_removed = [] + self.sample_positions_to_be_removed_thread = Thread( + target=self._check_sample_positions_status_and_remove_if_not_occupied + ) + self.sample_positions_to_be_removed_thread.daemon = True + self.sample_positions_to_be_removed_thread.start() def refresh_devices(self): """Re-connect the devices in the device view.""" self._device_view.close() + # call alabos setup_lab to refresh the devices and standalone sample positions + print( + "Installing new devices and sample positions. " + "Adding discrepancies to the watchers for deletion once unoccupied." + ) + removed_devices_and_sample_positions_names = setup_lab( + reload=True, return_removed_devices_and_sample_positions_names=True + ) + for device_name in removed_devices_and_sample_positions_names[ + "removed_devices_names" + ]: + if device_name not in self.device_names_to_be_removed: + self.device_names_to_be_removed.append(device_name) + for sample_position_name in removed_devices_and_sample_positions_names[ + "removed_sample_positions_names" + ]: + if sample_position_name not in self.sample_positions_to_be_removed: + self.sample_positions_to_be_removed.append(sample_position_name) print("Connecting to devices again...") - load_definition(reload=True) self._device_view = DeviceView( connect_to_devices=True ) # create a new device view + def _check_device_status_and_remove_if_not_occupied(self): + """Check devices status and remove them if they are not occupied.""" + while True: + time.sleep(10) + for device_name in self.device_names_to_be_removed: + device_entry: dict[str, Any] | None = self._device_view.get_device( + device_name + ) + # check if the device is not occupied, and sample positions that are related to the device are also not occupied + if device_entry[ + "status" + ] == DeviceTaskStatus.IDLE.name and not self._sample_view.get_samples_on_device( + device_name + ): + print( + f"Device {device_name} is not occupied and has no samples on it, pausing it and then removing it from the device view." + ) + self._device_view.pause_device(device_name) + self.device_names_to_be_removed_that_is_paused.append(device_name) + self.device_names_to_be_removed.remove(device_name) + print( + f"Device {device_name} has been paused and will be removed from the device view after 10 seconds." + ) + elif device_entry["status"] == DeviceTaskStatus.OCCUPIED.name: + print( + f"Device {device_name} is occupied, skipping and waiting for it to be unoccupied." + ) + else: + print( + f"Device {device_name} is in status {device_entry['status']}, skipping and waiting for it to be unoccupied." + ) + time.sleep(10) + for device_name in self.device_names_to_be_removed_that_is_paused: + device_entry: dict[str, Any] | None = self._device_view.get_device( + device_name + ) + if ( + device_entry["status"] == DeviceTaskStatus.IDLE.name + and device_entry["pause_status"] == DevicePauseStatus.PAUSED.name + and not self._sample_view.get_samples_on_device(device_name) + ): + print( + f"Device {device_name} is not occupied, paused, and has no samples on it, removing it from the device view." + ) + self._device_view.remove_device(device_name) + # also remove the sample positions that are related to the device + for ( + sample_position_name + ) in self._sample_view.get_sample_positions_names_by_device( + device_name + ): + self.sample_positions_to_be_removed.append(sample_position_name) + self.device_names_to_be_removed_that_is_paused.remove(device_name) + print( + f"Device {device_name} has been removed from the device view." + ) + elif device_entry["status"] == DeviceTaskStatus.OCCUPIED.name: + print( + f"Device {device_name} is occupied, skipping and waiting for it to be unoccupied." + ) + else: + print( + f"Device {device_name} is in status {device_entry['status']}, skipping and waiting for it to be unoccupied." + ) + + def _check_sample_positions_status_and_remove_if_not_occupied(self): + """Check the sample positions status and remove it if it is not occupied.""" + while True: + time.sleep(10) + for sample_position in self.sample_positions_to_be_removed: + sample_position_status, _ = ( + self._sample_view.get_sample_position_status(sample_position) + ) + if sample_position_status == SamplePositionStatus.EMPTY: + print( + f"Sample position {sample_position} is empty, removing it from the sample view." + ) + self._sample_view.remove_sample_position(sample_position) + self.sample_positions_to_be_removed.remove(sample_position) + print( + f"Sample position {sample_position} has been removed from the sample view." + ) + else: + print( + f"Sample position {sample_position} is not empty, current status: {sample_position_status}, \ + skipping and waiting for it to be empty." + ) + @contextmanager def pause_all_devices(self): """Pause all devices, so that no device can be used during the context.""" diff --git a/alab_management/device_view/__init__.py b/alab_management/device_view/__init__.py index 5f1fcc88..2b01eefe 100644 --- a/alab_management/device_view/__init__.py +++ b/alab_management/device_view/__init__.py @@ -1,5 +1,10 @@ """Things related to the devices.""" from .dbattributes import DictInDatabase, ListInDatabase, value_in_database -from .device import BaseDevice, add_device, get_all_devices +from .device import ( + BaseDevice, + add_device, + get_all_devices, + reset_current_device_registry, +) from .device_view import DeviceTaskStatus, DeviceView diff --git a/alab_management/device_view/device.py b/alab_management/device_view/device.py index 27ca662c..5e660752 100644 --- a/alab_management/device_view/device.py +++ b/alab_management/device_view/device.py @@ -618,14 +618,24 @@ def retrieve_signal(self, signal_name, within: datetime.timedelta | None = None) ) +# _device_registry is used to store all the devices that are defined in the __init__.py when setting up the lab +# it is only pooled with new devices that are added to the lab during reload. _device_registry: dict[str, BaseDevice] = {} +# _current_device_registry is used to store all the devices that are defined in the __init__.py +# when alabos setup is called, regardless of whether it is a reload or not. +# this is used to check if a device is still in the lab during reload. +# if not, it will be removed from the lab once unoccupied and samples positions that are related to it are also not occupied. +# this is not visible to the device_manager +_current_device_registry: dict[str, BaseDevice] = {} + def add_device(device: BaseDevice): """Register a device instance. It is stored in a global dictionary.""" if device.name in _device_registry and not os.environ.get("ALABOS_RELOAD", None): raise KeyError(f"Duplicated device name {device.name}") _device_registry[device.name] = device + _current_device_registry[device.name] = device def get_all_devices() -> dict[str, BaseDevice]: @@ -638,3 +648,19 @@ def get_all_devices() -> dict[str, BaseDevice]: and the values are the device instances. """ return _device_registry.copy() + + +def get_current_devices() -> dict[str, BaseDevice]: + """Get all the current devices in the device registry.""" + return _current_device_registry.copy() + + +def remove_device(device_name: str): + """Remove a device from the device registry.""" + _device_registry.pop(device_name, None) + _current_device_registry.pop(device_name, None) + + +def reset_current_device_registry(): + """Reset the current device registry.""" + _current_device_registry.clear() diff --git a/alab_management/device_view/device_view.py b/alab_management/device_view/device_view.py index 45ed2c41..2bdcec13 100644 --- a/alab_management/device_view/device_view.py +++ b/alab_management/device_view/device_view.py @@ -12,7 +12,7 @@ from alab_management.sample_view import SamplePosition, SampleView from alab_management.utils.data_objects import get_collection, get_lock -from .device import BaseDevice, get_all_devices +from .device import BaseDevice, get_all_devices, remove_device _DeviceType = TypeVar("_DeviceType", bound=BaseDevice) # pylint: disable=invalid-name @@ -110,7 +110,7 @@ def sync_device_status(self): task_id=None, ) - def add_devices_to_db(self): + def add_devices_to_db(self, devices: dict[str, BaseDevice] | None = None): """ Insert device definitions to db, which includes devices' name, descriptions, parameters, type (class name). @@ -118,7 +118,9 @@ def add_devices_to_db(self): When one device's name has already appeared in the database, a ``NameError`` will be raised. Device name is a unique identifier for a device """ - for device in self._device_list.values(): + for device in ( + devices.values() if devices is not None else self._device_list.values() + ): if self._device_collection.find_one({"name": device.name}) is not None: raise NameError( f"Duplicated device name {device.name}, did you cleanup the database?" @@ -604,3 +606,16 @@ def __del__(self): """Ensure that we disconnect from all devices when the object is deleted.""" if self.__connected_to_devices: self.__disconnect_all_devices() + + def remove_device(self, device_name: str): + """Remove a device from the device view and the registry.""" + # disconnect the device + print(f"Disconnecting from {device_name}...") + device = self._device_list[device_name] + device._disconnect_wrapper() + self._device_list.pop(device_name, None) + remove_device(device_name) + self._device_collection.delete_one({"name": device_name}) + print( + f"Device {device_name} has been removed from the device view and the registry." + ) diff --git a/alab_management/sample_view/__init__.py b/alab_management/sample_view/__init__.py index 07d34ffa..cbed5575 100644 --- a/alab_management/sample_view/__init__.py +++ b/alab_management/sample_view/__init__.py @@ -1,5 +1,10 @@ """Sample related things.""" from .completed_sample_view import CompletedSampleView -from .sample import Sample, SamplePosition, add_standalone_sample_position +from .sample import ( + Sample, + SamplePosition, + add_standalone_sample_position, + reset_current_standalone_sample_position_registry, +) from .sample_view import SampleView diff --git a/alab_management/sample_view/sample.py b/alab_management/sample_view/sample.py index 7ac8fbe2..6b3e5691 100644 --- a/alab_management/sample_view/sample.py +++ b/alab_management/sample_view/sample.py @@ -55,7 +55,18 @@ def __post_init__(self): ) -_standalone_sample_position_registry: dict[str, SamplePosition] = {} +# _standalone_sample_position_registry is used to store all the standalone sample positions that are defined in the __init__.py +# when setting up the lab. +# it is only pooled with new sample positions that are added to the lab during reload. +if not os.environ.get("ALABOS_RELOAD", None): + _standalone_sample_position_registry: dict[str, SamplePosition] = {} + +# _current_standalone_sample_position_registry is used to store all the standalone sample positions that are defined in the __init__.py +# when alabos setup is called, regardless of whether it is a reload or not. +# this is used to check if a sample position is still in the lab during reload. +# if not, it will be removed from the lab once unoccupied and devices that are related to it are also not occupied. +# this is not visible to the sample_view +_current_standalone_sample_position_registry: dict[str, SamplePosition] = {} def add_standalone_sample_position(position: SamplePosition): @@ -69,8 +80,25 @@ def add_standalone_sample_position(position: SamplePosition): ): raise KeyError(f"Duplicated standalone sample position name {position.name}") _standalone_sample_position_registry[position.name] = position + _current_standalone_sample_position_registry[position.name] = position def get_all_standalone_sample_positions() -> dict[str, SamplePosition]: - """Get all the device names in the device registry.""" + """Get all the sample position names in the registry.""" return _standalone_sample_position_registry.copy() + + +def get_current_standalone_sample_positions() -> dict[str, SamplePosition]: + """Get all the current standalone sample positions in the registry.""" + return _current_standalone_sample_position_registry.copy() + + +def remove_standalone_sample_position(position_name: str): + """Remove a standalone sample position from the registry.""" + _standalone_sample_position_registry.pop(position_name, None) + _current_standalone_sample_position_registry.pop(position_name, None) + + +def reset_current_standalone_sample_position_registry(): + """Reset the current standalone sample position registry.""" + _current_standalone_sample_position_registry.clear() diff --git a/alab_management/sample_view/sample_view.py b/alab_management/sample_view/sample_view.py index 38330610..2088bc5f 100644 --- a/alab_management/sample_view/sample_view.py +++ b/alab_management/sample_view/sample_view.py @@ -12,7 +12,7 @@ from alab_management.utils.data_objects import get_collection, get_lock -from .sample import Sample, SamplePosition +from .sample import Sample, SamplePosition, remove_standalone_sample_position class SamplePositionRequest(BaseModel): @@ -485,6 +485,15 @@ def move_sample(self, sample_id: ObjectId, position: str | None): }, ) + def get_sample_positions_names_by_device(self, device_name: str) -> list[str]: + """Get all the sample positions names that are related to a device.""" + return [ + sample_position["name"] + for sample_position in self._sample_positions_collection.find( + {"parent_device": device_name} + ) + ] + def get_samples_on_device(self, device_name: str) -> dict[str, list[ObjectId]]: """Get all the samples on a device.""" samples = self._sample_collection.find( @@ -511,3 +520,8 @@ def exists(self, sample_id: ObjectId | str) -> bool: bool: True if sample exists in the database """ return self._sample_collection.count_documents({"_id": ObjectId(sample_id)}) > 0 + + def remove_sample_position(self, position_name: str): + """Remove a sample position from the database.""" + remove_standalone_sample_position(position_name) + self._sample_positions_collection.delete_one({"name": position_name}) diff --git a/alab_management/scripts/setup_lab.py b/alab_management/scripts/setup_lab.py index 1f439001..7bd3cf94 100644 --- a/alab_management/scripts/setup_lab.py +++ b/alab_management/scripts/setup_lab.py @@ -5,18 +5,60 @@ from alab_management.alarm import Alarm from alab_management.config import AlabOSConfig +from alab_management.device_view.device import get_current_devices +from alab_management.sample_view.sample import get_current_standalone_sample_positions -def setup_lab(): +def setup_lab( + reload: bool = False, + return_removed_devices_and_sample_positions_names: bool = False, +): """Cleanup the db and then import all the definitions and set up the db.""" - from alab_management.device_view import DeviceView, get_all_devices - from alab_management.sample_view import SampleView + from alab_management.device_view import ( + DeviceView, + get_all_devices, + reset_current_device_registry, + ) + from alab_management.sample_view import ( + SampleView, + reset_current_standalone_sample_position_registry, + ) from alab_management.sample_view.sample import get_all_standalone_sample_positions from alab_management.utils.module_ops import load_definition - load_definition() - devices = get_all_devices().values() - DeviceView().add_devices_to_db() + if reload: + reset_current_device_registry() + reset_current_standalone_sample_position_registry() + load_definition(reload=reload) + devices = ( + get_all_devices().values() + ) # all device from the __init__.py and the ones that are currently connected + current_devices = ( + get_current_devices() + ) # all device from the __init__.py during this setup (only equals to devices if not reload) + device_dict = ( + get_all_devices() + ) # all device from the __init__.py and the ones that are currently connected + all_available_devices = DeviceView().get_all() # all device from the db + available_devices_names = [ + device["name"] for device in all_available_devices + ] # all device names from the db + filtered_devices = {} # all device from the __init__.py that are not in the db + for device_name in device_dict: + if device_name not in available_devices_names: + filtered_devices[device_name] = device_dict[ + device_name + ] # all device from the __init__.py that are not in the db + print(f"current_devices_names: {list(current_devices.keys())}") + print(f"available_devices_names: {available_devices_names}") + removed_devices_names = ( + [] + ) # all device names from the db that are not in the __init__.py + for device_name in available_devices_names: + if device_name not in list(current_devices.keys()): + removed_devices_names.append(device_name) + print(f"Devices to be removed from the db and registry: {removed_devices_names}") + DeviceView().add_devices_to_db(filtered_devices) for device_instance in devices: device_instance._apply_default_db_values() # trigger database updates for Device values stored within db. These are not executed upon instantiation @@ -25,7 +67,25 @@ def setup_lab(): sample_view = SampleView() # start with all standalone sample positions - sample_positions = list(get_all_standalone_sample_positions().values()) + sample_positions = list( + get_all_standalone_sample_positions().values() + ) # all sample positions from the __init__.py and the ones that are currently in the lab + sample_positions_names = list( + get_all_standalone_sample_positions().keys() + ) # all sample positions names from the __init__.py and the ones that are currently in the lab + current_sample_positions_names = list( + get_current_standalone_sample_positions().keys() + ) # all sample positions from the __init__.py during this setup (only equals to sample_positions if not reload) + + # remove sample positions that are not in the lab anymore + removed_sample_positions_names = [] + for sample_position_name in sample_positions_names: + if sample_position_name not in current_sample_positions_names: + removed_sample_positions_names.append(sample_position_name) + print( + f"Sample positions to be removed from the db and registry: {removed_sample_positions_names}" + ) + sample_view.add_sample_positions_to_db( sample_positions=sample_positions, parent_device_name=None ) @@ -38,4 +98,12 @@ def setup_lab(): # print the alarm configuration alarm_config = AlabOSConfig().get("alarm", {}) Alarm(**alarm_config).print_configuration() - return True + + return ( + { + "removed_devices_names": removed_devices_names, + "removed_sample_positions_names": removed_sample_positions_names, + } + if return_removed_devices_and_sample_positions_names + else True + ) diff --git a/docs/source/best_practices.md b/docs/source/best_practices.md index 0d80caca..2faeb7c8 100644 --- a/docs/source/best_practices.md +++ b/docs/source/best_practices.md @@ -1,7 +1,5 @@ -# Best Practices - -To ease the deployment of AlabOS, below are several example solutions to common problems or challenges found during -implementation: +# Experimental processes definition best practices +To ease the deployment of AlabOS, below are several example solutions to common problems or challenges found during implementation: ## 1. Minimizing duration for time-sensitive samples @@ -184,3 +182,13 @@ consideration is to adjust resource request priorities based on the number of an ```{note} More content will be added soon. ``` +# File structure Best Practices +## 1. Structuring your definition +The type of the files define the structure. Device low-level control should be defined in a separate because all the functionality should only be called when the device is already booked by the task. If any functionality involves data generation, it should be directed to the PC where the alabos is running. + +## +It was not clear why we have device and alab_control. Two level: device and driver. +1. Public sharing for drivers can be used for the same instrument at low-level. For example: loading SEM samples, controlling SEM operational mode. +2. Define exception types in device. A device should only have few type of exception to be handled by alabOS if you need the user input request. If you have any error recovery routine that needs user input request, you shall define it in task and the exception is imported from device. +3. Driver should be configuration agnostic. For any function that requires local configuration (e.g., saving directory to Sauron), you should define in Device instead of driver. + From a31ca794312f40a8f696500d4ec73bd425341850 Mon Sep 17 00:00:00 2001 From: Bernardus Rendy <37468936+bernardusrendy@users.noreply.github.com> Date: Thu, 10 Jul 2025 21:42:17 -0700 Subject: [PATCH 02/11] debug, working for manual tests 1. adding and removing slots on a standalone sample position/changing the numbers 2. adding and removing a device and its sample position 3. adding and removing whole sample position 4. adding and removing a sample position prefix in a device 5. changing the numbers in a sample position in a device --- alab_management/device_manager.py | 373 +++++++++++++++++++-- alab_management/device_view/device_view.py | 17 +- alab_management/sample_view/sample.py | 9 +- alab_management/sample_view/sample_view.py | 18 +- alab_management/scripts/launch_lab.py | 5 +- alab_management/scripts/setup_lab.py | 179 ++++++++-- 6 files changed, 522 insertions(+), 79 deletions(-) diff --git a/alab_management/device_manager.py b/alab_management/device_manager.py index 0592e231..68769525 100644 --- a/alab_management/device_manager.py +++ b/alab_management/device_manager.py @@ -134,19 +134,39 @@ def __init__(self, _check_status: bool = True): self._sample_view = SampleView() self._check_status = _check_status self.threads = [] + self.device_names_to_be_removed = [] + self.device_names_to_be_removed_that_is_paused = [] self.device_names_to_be_removed_thread = Thread( target=self._check_device_status_and_remove_if_not_occupied ) self.device_names_to_be_removed_thread.daemon = True self.device_names_to_be_removed_thread.start() - self.device_names_to_be_removed_that_is_paused = [] - self.sample_positions_to_be_removed = [] - self.sample_positions_to_be_removed_thread = Thread( - target=self._check_sample_positions_status_and_remove_if_not_occupied + + self.sample_positions_objects_to_be_updated = ( + [] + ) # this will be pooled by a list of sample positions objects + self.sample_positions_prefixes_to_be_removed = ( + [] + ) # this will be pooled by a list of sample positions prefixes, that is corresponding to SamplePosition.name + # (e.g., "Filled_vial_rack"), not the full name (Filled_vial_rack/1) + self.sample_positions_in_devices_to_be_updated = ( + [] + ) # this will be pooled by a dict[str,SamplePosition] where the key is the device name and the value is the sample position object + self.sample_positions_in_devices_to_be_updated_that_is_paused = ( + [] + ) # this will be pooled by a dict[str,SamplePosition] where the key is the device name and the value is the sample position object + self.sample_positions_in_devices_to_be_removed = ( + [] + ) # this will be pooled by a dict[str,SamplePosition] where the key is the device name and the value is the sample position object + self.sample_positions_in_devices_to_be_removed_that_is_paused = ( + [] + ) # this will be pooled by a dict[str,SamplePosition] where the key is the device name and the value is the sample position object + self.sample_positions_to_be_updated_or_removed_thread = Thread( + target=self._check_sample_positions_status_and_update_or_remove_if_not_occupied ) - self.sample_positions_to_be_removed_thread.daemon = True - self.sample_positions_to_be_removed_thread.start() + self.sample_positions_to_be_updated_or_removed_thread.daemon = True + self.sample_positions_to_be_updated_or_removed_thread.start() def refresh_devices(self): """Re-connect the devices in the device view.""" @@ -156,19 +176,44 @@ def refresh_devices(self): "Installing new devices and sample positions. " "Adding discrepancies to the watchers for deletion once unoccupied." ) - removed_devices_and_sample_positions_names = setup_lab( - reload=True, return_removed_devices_and_sample_positions_names=True - ) - for device_name in removed_devices_and_sample_positions_names[ - "removed_devices_names" - ]: + update_and_removal_todo = setup_lab(reload=True) + for device_name in update_and_removal_todo["removed_devices_names"]: if device_name not in self.device_names_to_be_removed: self.device_names_to_be_removed.append(device_name) - for sample_position_name in removed_devices_and_sample_positions_names[ - "removed_sample_positions_names" + for updated_sample_position in update_and_removal_todo[ + "updated_sample_positions" ]: - if sample_position_name not in self.sample_positions_to_be_removed: - self.sample_positions_to_be_removed.append(sample_position_name) + if ( + updated_sample_position + not in self.sample_positions_objects_to_be_updated + ): + self.sample_positions_objects_to_be_updated.append( + updated_sample_position + ) + for removed_sample_position_prefix in update_and_removal_todo[ + "removed_sample_positions_prefixes" + ]: + if ( + removed_sample_position_prefix + not in self.sample_positions_prefixes_to_be_removed + ): + self.sample_positions_prefixes_to_be_removed.append( + removed_sample_position_prefix + ) + if ( + update_and_removal_todo["updated_sample_positions_in_devices"] + not in self.sample_positions_in_devices_to_be_updated + ): + self.sample_positions_in_devices_to_be_updated.append( + update_and_removal_todo["updated_sample_positions_in_devices"] + ) + if ( + update_and_removal_todo["removed_sample_positions_in_devices"] + not in self.sample_positions_in_devices_to_be_removed + ): + self.sample_positions_in_devices_to_be_removed.append( + update_and_removal_todo["removed_sample_positions_in_devices"] + ) print("Connecting to devices again...") self._device_view = DeviceView( connect_to_devices=True @@ -177,7 +222,7 @@ def refresh_devices(self): def _check_device_status_and_remove_if_not_occupied(self): """Check devices status and remove them if they are not occupied.""" while True: - time.sleep(10) + time.sleep(5) for device_name in self.device_names_to_be_removed: device_entry: dict[str, Any] | None = self._device_view.get_device( device_name @@ -205,7 +250,6 @@ def _check_device_status_and_remove_if_not_occupied(self): print( f"Device {device_name} is in status {device_entry['status']}, skipping and waiting for it to be unoccupied." ) - time.sleep(10) for device_name in self.device_names_to_be_removed_that_is_paused: device_entry: dict[str, Any] | None = self._device_view.get_device( device_name @@ -220,12 +264,7 @@ def _check_device_status_and_remove_if_not_occupied(self): ) self._device_view.remove_device(device_name) # also remove the sample positions that are related to the device - for ( - sample_position_name - ) in self._sample_view.get_sample_positions_names_by_device( - device_name - ): - self.sample_positions_to_be_removed.append(sample_position_name) + self._sample_view.remove_sample_position_by_prefix(device_name) self.device_names_to_be_removed_that_is_paused.remove(device_name) print( f"Device {device_name} has been removed from the device view." @@ -234,33 +273,299 @@ def _check_device_status_and_remove_if_not_occupied(self): print( f"Device {device_name} is occupied, skipping and waiting for it to be unoccupied." ) + self._device_view.pause_device(device_name) else: print( f"Device {device_name} is in status {device_entry['status']}, skipping and waiting for it to be unoccupied." ) + self._device_view.pause_device(device_name) - def _check_sample_positions_status_and_remove_if_not_occupied(self): + def _check_sample_positions_status_and_update_or_remove_if_not_occupied(self): """Check the sample positions status and remove it if it is not occupied.""" while True: time.sleep(10) - for sample_position in self.sample_positions_to_be_removed: - sample_position_status, _ = ( - self._sample_view.get_sample_position_status(sample_position) + for sample_position_object in self.sample_positions_objects_to_be_updated: + prefix = sample_position_object.name + sample_positions_full_names = ( + self._sample_view.get_sample_positions_names_by_prefix(prefix) ) - if sample_position_status == SamplePositionStatus.EMPTY: + sample_positions_status = [] + for sample_position_full_name in sample_positions_full_names: + sample_positions_status.append( + self._sample_view.get_sample_position_status( + sample_position_full_name + )[0] + ) + if all( + status == SamplePositionStatus.EMPTY + for status in sample_positions_status + ): print( - f"Sample position {sample_position} is empty, removing it from the sample view." + f"Sample position with prefix {prefix} is empty, removing it from the sample view and \ + adding it back with the new number of slots." + ) + self._sample_view.remove_sample_position_by_prefix(prefix) + self._sample_view.add_sample_positions_to_db( + sample_positions=[sample_position_object], + parent_device_name=None, + ) + self.sample_positions_objects_to_be_updated.remove( + sample_position_object ) - self._sample_view.remove_sample_position(sample_position) - self.sample_positions_to_be_removed.remove(sample_position) print( - f"Sample position {sample_position} has been removed from the sample view." + f"Sample position with prefix {prefix} has been updated in the sample view." ) else: print( - f"Sample position {sample_position} is not empty, current status: {sample_position_status}, \ + f"Sample position with prefix {prefix} is not empty, current status of some sample positions: {sample_positions_status}, \ skipping and waiting for it to be empty." ) + for prefix in self.sample_positions_prefixes_to_be_removed: + sample_positions_full_names = ( + self._sample_view.get_sample_positions_names_by_prefix(prefix) + ) + sample_positions_status = [] + for sample_position_full_name in sample_positions_full_names: + sample_positions_status.append( + self._sample_view.get_sample_position_status( + sample_position_full_name + )[0] + ) + if all( + status == SamplePositionStatus.EMPTY + for status in sample_positions_status + ): + print( + f"Sample position with prefix {prefix} is empty, removing it from the sample view." + ) + self._sample_view.remove_sample_position_by_prefix(prefix) + self.sample_positions_prefixes_to_be_removed.remove(prefix) + print( + f"Sample position with prefix {prefix} has been removed from the sample view." + ) + else: + print( + f"Sample position with prefix {prefix} is not empty, current status of some sample positions: {sample_positions_status}, \ + skipping and waiting for it to be empty." + ) + + for ( + device_sample_position_dict + ) in self.sample_positions_in_devices_to_be_updated: + processed = False + for device_name in list(device_sample_position_dict.keys()): + sample_positions_names_in_device = ( + self._sample_view.get_sample_positions_names_by_device( + device_name + ) + ) + sample_positions_status = [] + for sample_position_name in sample_positions_names_in_device: + sample_positions_status.append( + self._sample_view.get_sample_position_status( + sample_position_name + )[0] + ) + if all( + status == SamplePositionStatus.EMPTY + for status in sample_positions_status + ): + # pause the device + device_entry: dict[str, Any] | None = ( + self._device_view.get_device(device_name) + ) + # check if the device is not occupied, and sample positions that are related to the device are also not occupied + if device_entry["status"] == DeviceTaskStatus.IDLE.name: + print( + f"Device {device_name} is not occupied and has no samples on it, pausing it to update the sample positions." + ) + self._device_view.pause_device(device_name) + self.sample_positions_in_devices_to_be_updated_that_is_paused.append( + device_sample_position_dict + ) + processed = True + print( + f"Device {device_name} has been paused to update the sample positions." + ) + elif device_entry["status"] == DeviceTaskStatus.OCCUPIED.name: + print( + f"Device {device_name} is occupied, skipping and waiting for it to be unoccupied." + ) + self._device_view.pause_device(device_name) + else: + print( + f"Device {device_name} is in status {device_entry['status']}, skipping and waiting for it to be unoccupied." + ) + if processed: + self.sample_positions_in_devices_to_be_updated.remove( + device_sample_position_dict + ) + + for ( + device_sample_position_dict + ) in self.sample_positions_in_devices_to_be_updated_that_is_paused: + processed = False + for device_name in list(device_sample_position_dict.keys()): + sample_positions_names_in_device = ( + self._sample_view.get_sample_positions_names_by_device( + device_name + ) + ) + sample_positions_status = [] + for sample_position_name in sample_positions_names_in_device: + sample_positions_status.append( + self._sample_view.get_sample_position_status( + sample_position_name + )[0] + ) + if all( + status == SamplePositionStatus.EMPTY + for status in sample_positions_status + ): + if ( + device_entry["status"] == DeviceTaskStatus.IDLE.name + and device_entry["pause_status"] + == DevicePauseStatus.PAUSED.name + ): + print( + f"Device {device_name} is not occupied, paused, and has no samples on it, updating the sample positions." + ) + for sample_position_object in device_sample_position_dict[ + device_name + ]: + self._sample_view.remove_sample_position_by_prefix( + f"{device_name}{sample_position_object.SEPARATOR}{sample_position_object.name}" + ) + # add the sample positions back to the device + for sample_position_object in device_sample_position_dict[ + device_name + ]: + self._sample_view.add_sample_positions_to_db( + sample_positions=[sample_position_object], + parent_device_name=device_name, + ) + processed = True + print( + f"Sample positions in device {device_name} have been updated." + ) + elif device_entry["status"] == DeviceTaskStatus.OCCUPIED.name: + print( + f"Device {device_name} is occupied, skipping and waiting for it to be unoccupied." + ) + self._device_view.pause_device(device_name) + else: + print( + f"Device {device_name} is in status {device_entry['status']}, skipping and waiting for it to be unoccupied." + ) + self._device_view.pause_device(device_name) + if processed: + self.sample_positions_in_devices_to_be_updated_that_is_paused.remove( + device_sample_position_dict + ) + for ( + device_sample_position_dict + ) in self.sample_positions_in_devices_to_be_removed: + processed = False + for device_name in list(device_sample_position_dict.keys()): + sample_positions_names_in_device = ( + self._sample_view.get_sample_positions_names_by_device( + device_name + ) + ) + sample_positions_status = [] + for sample_position_name in sample_positions_names_in_device: + sample_positions_status.append( + self._sample_view.get_sample_position_status( + sample_position_name + )[0] + ) + if all( + status == SamplePositionStatus.EMPTY + for status in sample_positions_status + ): + # pause the device + device_entry: dict[str, Any] | None = ( + self._device_view.get_device(device_name) + ) + # check if the device is not occupied, and sample positions that are related to the device are also not occupied + if device_entry["status"] == DeviceTaskStatus.IDLE.name: + print( + f"Device {device_name} is not occupied and has no samples on it, pausing it to update the sample positions." + ) + self._device_view.pause_device(device_name) + self.sample_positions_in_devices_to_be_removed_that_is_paused.append( + device_sample_position_dict + ) + processed = True + print( + f"Device {device_name} has been paused to update the sample positions." + ) + elif device_entry["status"] == DeviceTaskStatus.OCCUPIED.name: + print( + f"Device {device_name} is occupied, skipping and waiting for it to be unoccupied." + ) + self._device_view.pause_device(device_name) + else: + print( + f"Device {device_name} is in status {device_entry['status']}, skipping and waiting for it to be unoccupied." + ) + if processed: + self.sample_positions_in_devices_to_be_removed.remove( + device_sample_position_dict + ) + for ( + device_sample_position_dict + ) in self.sample_positions_in_devices_to_be_removed_that_is_paused: + processed = False + for device_name in list(device_sample_position_dict.keys()): + sample_positions_names_in_device = ( + self._sample_view.get_sample_positions_names_by_device( + device_name + ) + ) + sample_positions_status = [] + for sample_position_name in sample_positions_names_in_device: + sample_positions_status.append( + self._sample_view.get_sample_position_status( + sample_position_name + )[0] + ) + if all( + status == SamplePositionStatus.EMPTY + for status in sample_positions_status + ): + if ( + device_entry["status"] == DeviceTaskStatus.IDLE.name + and device_entry["pause_status"] + == DevicePauseStatus.PAUSED.name + ): + print( + f"Device {device_name} is not occupied, paused, and has no samples on it, updating the sample positions." + ) + for sample_position_object in device_sample_position_dict[ + device_name + ]: + self._sample_view.remove_sample_position_by_prefix( + f"{device_name}{sample_position_object.SEPARATOR}{sample_position_object.name}" + ) + processed = True + print( + f"Sample positions in device {device_name} have been updated." + ) + elif device_entry["status"] == DeviceTaskStatus.OCCUPIED.name: + print( + f"Device {device_name} is occupied, skipping and waiting for it to be unoccupied." + ) + self._device_view.pause_device(device_name) + else: + print( + f"Device {device_name} is in status {device_entry['status']}, skipping and waiting for it to be unoccupied." + ) + self._device_view.pause_device(device_name) + if processed: + self.sample_positions_in_devices_to_be_removed_that_is_paused.remove( + device_sample_position_dict + ) @contextmanager def pause_all_devices(self): diff --git a/alab_management/device_view/device_view.py b/alab_management/device_view/device_view.py index 2bdcec13..d35fbd96 100644 --- a/alab_management/device_view/device_view.py +++ b/alab_management/device_view/device_view.py @@ -611,11 +611,12 @@ def remove_device(self, device_name: str): """Remove a device from the device view and the registry.""" # disconnect the device print(f"Disconnecting from {device_name}...") - device = self._device_list[device_name] - device._disconnect_wrapper() - self._device_list.pop(device_name, None) - remove_device(device_name) - self._device_collection.delete_one({"name": device_name}) - print( - f"Device {device_name} has been removed from the device view and the registry." - ) + with self._lock(): + device = self._device_list[device_name] + device._disconnect_wrapper() + self._device_list.pop(device_name, None) + remove_device(device_name) + self._device_collection.delete_one({"name": device_name}) + print( + f"Device {device_name} has been removed from the device view and the registry." + ) diff --git a/alab_management/sample_view/sample.py b/alab_management/sample_view/sample.py index 6b3e5691..d3a2ab2f 100644 --- a/alab_management/sample_view/sample.py +++ b/alab_management/sample_view/sample.py @@ -58,8 +58,7 @@ def __post_init__(self): # _standalone_sample_position_registry is used to store all the standalone sample positions that are defined in the __init__.py # when setting up the lab. # it is only pooled with new sample positions that are added to the lab during reload. -if not os.environ.get("ALABOS_RELOAD", None): - _standalone_sample_position_registry: dict[str, SamplePosition] = {} +_standalone_sample_position_registry: dict[str, SamplePosition] = {} # _current_standalone_sample_position_registry is used to store all the standalone sample positions that are defined in the __init__.py # when alabos setup is called, regardless of whether it is a reload or not. @@ -93,10 +92,10 @@ def get_current_standalone_sample_positions() -> dict[str, SamplePosition]: return _current_standalone_sample_position_registry.copy() -def remove_standalone_sample_position(position_name: str): +def remove_standalone_sample_position(prefix: str): """Remove a standalone sample position from the registry.""" - _standalone_sample_position_registry.pop(position_name, None) - _current_standalone_sample_position_registry.pop(position_name, None) + _standalone_sample_position_registry.pop(prefix, None) + _current_standalone_sample_position_registry.pop(prefix, None) def reset_current_standalone_sample_position_registry(): diff --git a/alab_management/sample_view/sample_view.py b/alab_management/sample_view/sample_view.py index 2088bc5f..9d846a0b 100644 --- a/alab_management/sample_view/sample_view.py +++ b/alab_management/sample_view/sample_view.py @@ -521,7 +521,19 @@ def exists(self, sample_id: ObjectId | str) -> bool: """ return self._sample_collection.count_documents({"_id": ObjectId(sample_id)}) > 0 - def remove_sample_position(self, position_name: str): + def remove_sample_position_by_prefix(self, prefix: str): """Remove a sample position from the database.""" - remove_standalone_sample_position(position_name) - self._sample_positions_collection.delete_one({"name": position_name}) + with self._lock(): + remove_standalone_sample_position(prefix) + self._sample_positions_collection.delete_many( + {"name": {"$regex": f"^{re.escape(prefix)}"}} + ) + + def get_sample_positions_names_by_prefix(self, prefix: str) -> list[str]: + """Get all the sample positions names that are related to a device.""" + return [ + sample_position["name"] + for sample_position in self._sample_positions_collection.find( + {"name": {"$regex": f"^{re.escape(prefix)}"}} + ) + ] diff --git a/alab_management/scripts/launch_lab.py b/alab_management/scripts/launch_lab.py index 099fcec7..d0c3cf1b 100644 --- a/alab_management/scripts/launch_lab.py +++ b/alab_management/scripts/launch_lab.py @@ -120,8 +120,9 @@ def system_refresh(): while task_manager.check_number_of_running_tasks(): time.sleep(10) time.sleep(10) # give some time for tasks to finish - task_manager.refresh_tasks() + # important to refresh device first because device manager will compare current state with future state device_manager.refresh_devices() + task_manager.refresh_tasks() time.sleep(10) @@ -180,6 +181,6 @@ def launch_lab(host, port, debug): sys.exit(1005) counter += 1 - if counter % 20 == 0: # check every 30 s + if counter % 10 == 0: # check every 15 s system_refresh() counter = 0 diff --git a/alab_management/scripts/setup_lab.py b/alab_management/scripts/setup_lab.py index 7bd3cf94..e93795af 100644 --- a/alab_management/scripts/setup_lab.py +++ b/alab_management/scripts/setup_lab.py @@ -6,12 +6,16 @@ from alab_management.alarm import Alarm from alab_management.config import AlabOSConfig from alab_management.device_view.device import get_current_devices -from alab_management.sample_view.sample import get_current_standalone_sample_positions +from alab_management.sample_view.sample import ( + SamplePosition, + get_current_standalone_sample_positions, +) def setup_lab( reload: bool = False, - return_removed_devices_and_sample_positions_names: bool = False, +) -> ( + dict[str, list[str] | list[SamplePosition] | dict[str, list[SamplePosition]]] | bool ): """Cleanup the db and then import all the definitions and set up the db.""" from alab_management.device_view import ( @@ -26,75 +30,193 @@ def setup_lab( from alab_management.sample_view.sample import get_all_standalone_sample_positions from alab_management.utils.module_ops import load_definition + # step 0: get all the devices and sample positions before reload to calculate the negative delta of sample positions numbers if reload: reset_current_device_registry() reset_current_standalone_sample_position_registry() + devices_before_reload = get_all_devices().values() + sample_positions_dict_before_reload = get_all_standalone_sample_positions() + + # Goal: calculate positive and negative delta of: devices, sample positions, and device sample positions. + # Apply positive delta directly. Negative delta will be applied by device manager once the devices, sample positions, + # and device sample positions are not used anymore. + + # step 1: load the definitions load_definition(reload=reload) + + # step 2: get all the devices from the __init__.py and the ones that are currently connected [current state] devices = ( get_all_devices().values() ) # all device from the __init__.py and the ones that are currently connected - current_devices = ( - get_current_devices() - ) # all device from the __init__.py during this setup (only equals to devices if not reload) device_dict = ( get_all_devices() ) # all device from the __init__.py and the ones that are currently connected + + # step 3: get all the devices from the __init__.py during this setup (only equals to devices if not reload) [future state] + current_devices = get_current_devices().values() + current_devices_dict = ( + get_current_devices() + ) # all device from the __init__.py during this setup (only equals to devices if not reload) all_available_devices = DeviceView().get_all() # all device from the db available_devices_names = [ device["name"] for device in all_available_devices ] # all device names from the db + + # step 4: filter out the devices that are not in the db [positive delta of devices] filtered_devices = {} # all device from the __init__.py that are not in the db for device_name in device_dict: if device_name not in available_devices_names: filtered_devices[device_name] = device_dict[ device_name ] # all device from the __init__.py that are not in the db - print(f"current_devices_names: {list(current_devices.keys())}") - print(f"available_devices_names: {available_devices_names}") + + # step 5: filter out the devices that are not in the __init__.py + # [negative delta of devices, will be applied by device manager once the devices are not used anymore] removed_devices_names = ( [] ) # all device names from the db that are not in the __init__.py for device_name in available_devices_names: - if device_name not in list(current_devices.keys()): + if device_name not in list(current_devices_dict.keys()): removed_devices_names.append(device_name) print(f"Devices to be removed from the db and registry: {removed_devices_names}") + + # step 6: add the new devices to the db [apply positive delta of devices] DeviceView().add_devices_to_db(filtered_devices) - for device_instance in devices: + for device_instance in filtered_devices.values(): device_instance._apply_default_db_values() # trigger database updates for Device values stored within db. These are not executed upon instantiation # because the device documents were not yet created within the Device collection. + print(f"Devices added to the db and registry: {filtered_devices}") + # step 7: get all the standalone sample positions from the __init__.py and the ones that are currently active in the lab [current state] sample_view = SampleView() - - # start with all standalone sample positions + # get the standalone sample positions in the registry + # note that a SamplePosition object consist of a name and a number + # where the number is the number of slots in the sample position + mapped_sample_positions = get_all_standalone_sample_positions() sample_positions = list( - get_all_standalone_sample_positions().values() - ) # all sample positions from the __init__.py and the ones that are currently in the lab - sample_positions_names = list( - get_all_standalone_sample_positions().keys() - ) # all sample positions names from the __init__.py and the ones that are currently in the lab - current_sample_positions_names = list( - get_current_standalone_sample_positions().keys() + mapped_sample_positions.values() + ) # all sample positions from the __init__.py and the ones that are currently active in the lab + # step 8: get all the standalone sample positions from the __init__.py during this setup [future state] + mapped_current_sample_positions = get_current_standalone_sample_positions() + current_sample_positions = list( + mapped_current_sample_positions.values() ) # all sample positions from the __init__.py during this setup (only equals to sample_positions if not reload) + sample_positions_names = [ + sample_position.name for sample_position in sample_positions + ] + current_sample_positions_names = [ + sample_position.name for sample_position in current_sample_positions + ] - # remove sample positions that are not in the lab anymore - removed_sample_positions_names = [] + # step 9: add new sample positions to the db [apply positive delta of prefix of sample positions] + # do not need to worry about the sample positions that are already in the db + # because the adding will only be done for new sample positions or slots within existing sample positions + sample_view.add_sample_positions_to_db( + sample_positions=sample_positions, parent_device_name=None + ) + # TODO: calculate positive delta of sample positions to print out + # step 10: remove prefix of sample positions names that are not in the lab anymore [calculate negative delta of prefix of sample positions] + removed_sample_positions_prefixes = [] for sample_position_name in sample_positions_names: if sample_position_name not in current_sample_positions_names: - removed_sample_positions_names.append(sample_position_name) + removed_sample_positions_prefixes.append(sample_position_name) print( - f"Sample positions to be removed from the db and registry: {removed_sample_positions_names}" + f"Standalone sample positions to be removed from the db and registry: {removed_sample_positions_prefixes}" ) - sample_view.add_sample_positions_to_db( - sample_positions=sample_positions, parent_device_name=None - ) + # step 11: find the current sample positions that has the same name but different number of slots + # [calculate negative delta of sample positions numbers] + # than the ones in the sample_positions + sample_positions_before_reload = list( + sample_positions_dict_before_reload.values() + ) # all sample positions information not including __init__.py. This is used to check if there is any negative delta of sample positions numbers + sample_positions_names_before_reload = [ + sample_position.name for sample_position in sample_positions_before_reload + ] + updated_sample_positions = [] + for current_sample_position_name in current_sample_positions_names: + try: + if ( + current_sample_position_name in sample_positions_names_before_reload + and ( + sample_positions_dict_before_reload[ + current_sample_position_name + ].number + != mapped_current_sample_positions[ + current_sample_position_name + ].number + ) + ): + # if the sample position is in the sample_positions, but the number of slots is different, + # then it is an updated sample position + updated_sample_positions.append( + mapped_current_sample_positions[current_sample_position_name] + ) + except KeyError: + continue + print(f"Updated standalone sample positions: {updated_sample_positions}") + + # step 12: add device sample positions to the db [apply positive delta of device prefix of sample positions] # next add positions within devices for device in devices: # prepend device name to device sample positions sample_view.add_sample_positions_to_db( sample_positions=device.sample_positions, parent_device_name=device.name ) + # step 13: find the current sample positions in devices that are not in the db [calculate negative delta of device sample positions] + # positive is already handled in step 12, so we only need to calculate negative delta to be handled by device manager + # once the devices are not used anymore + devices_sample_positions_before_reload = { + device.name: device.sample_positions for device in devices_before_reload + } + current_devices_sample_positions = { + device.name: device.sample_positions for device in current_devices + } + removed_sample_positions_in_devices = {} + for device_name in devices_sample_positions_before_reload: + for current_device_name in current_devices_sample_positions: + if current_device_name == device_name: + for sample_position in devices_sample_positions_before_reload[ + device_name + ]: + if sample_position.name not in [ + current_device_sample_position.name + for current_device_sample_position in current_devices_sample_positions[ + current_device_name + ] + ]: + if device_name not in removed_sample_positions_in_devices: + removed_sample_positions_in_devices[device_name] = [] + removed_sample_positions_in_devices[device_name].append( + sample_position + ) + print(f"Removed sample positions in devices: {removed_sample_positions_in_devices}") + + # step 14: check if there is any update of sample positions from the devices [calculate negative delta of sample positions numbers] + # positive is already handled in step 12, so we only need to calculate negative delta to be handled by device manager + # once the devices are not used anymore + updated_sample_positions_in_devices = {} + for device_name in devices_sample_positions_before_reload: + for current_device_name in current_devices_sample_positions: + if current_device_name == device_name: + for sample_position in devices_sample_positions_before_reload[ + device_name + ]: + for current_sample_position in current_devices_sample_positions[ + current_device_name + ]: + if ( + sample_position.name == current_sample_position.name + and sample_position.number != current_sample_position.number + ): + if device_name not in updated_sample_positions_in_devices: + updated_sample_positions_in_devices[device_name] = [] + updated_sample_positions_in_devices[device_name].append( + current_sample_position + ) + print(f"Updated sample positions in devices: {updated_sample_positions_in_devices}") + # print the alarm configuration alarm_config = AlabOSConfig().get("alarm", {}) Alarm(**alarm_config).print_configuration() @@ -102,8 +224,11 @@ def setup_lab( return ( { "removed_devices_names": removed_devices_names, - "removed_sample_positions_names": removed_sample_positions_names, + "updated_sample_positions": updated_sample_positions, + "removed_sample_positions_prefixes": removed_sample_positions_prefixes, + "updated_sample_positions_in_devices": updated_sample_positions_in_devices, + "removed_sample_positions_in_devices": removed_sample_positions_in_devices, } - if return_removed_devices_and_sample_positions_names + if reload else True ) From 6597ece21a8d9677b9ffda8a53a3bee61f366e37 Mon Sep 17 00:00:00 2001 From: Bernardus Rendy <37468936+bernardusrendy@users.noreply.github.com> Date: Fri, 11 Jul 2025 13:32:07 -0700 Subject: [PATCH 03/11] update auto_refresh documentation --- docs/source/auto_refresh.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/source/auto_refresh.md b/docs/source/auto_refresh.md index 92e3df5d..87c1e4d5 100644 --- a/docs/source/auto_refresh.md +++ b/docs/source/auto_refresh.md @@ -23,7 +23,7 @@ the device and task manager will be re-imported. It is a process with the follow 3. Device manager will try to pause all the devices. 4. Wait for all the tasks are NOT in RUNNING state (no communication with the devices). 5. All the devices will be disconnected. -6. Device and task manager will be re-imported. +6. Device, sample positions (including the ones defined inside the devices), and task manager will be re-imported. For device and sample positions, differences will be automatically calculated and applied (by the device manager) once the devices and sample positions are unoccupied. However, the system will continue with the following steps as the waiting for the unoccupation is done in parallel to the system running time. 7. Connect to the devices again. 8. Device manager will try to resume all the devices. 9. Resource manager will resume to allocate new resources. @@ -48,4 +48,5 @@ The `auto_refresh` feature has some limitations: - New devices and tasks: If you add new devices or tasks, you will need to manually refresh the lab or there may be some unexpected behaviors. - For the tasks that are already running, the changes will not be reflected until the task is refreshed. This means that if you change the device or task definition, the running tasks will continue to use the old definitions. - Keep in mind that the best practice is to ensure devices and tasks definitions are backward compatible to the older versions so that the running tasks can continue to run without any issues and not have a conflict with the new definitions. \ No newline at end of file + Keep in mind that the best practice is to ensure devices and tasks definitions are backward compatible to the older versions so that the running tasks can continue to run without any issues and not have a conflict with the new definitions. +- No user input should be done during the refresh process to avoid unexpected behaviors. No blocking is currently implemented. \ No newline at end of file From f646825a0a9945f332f879df370767d97ba99400 Mon Sep 17 00:00:00 2001 From: Bernardus Rendy <37468936+bernardusrendy@users.noreply.github.com> Date: Sat, 12 Jul 2025 15:17:37 -0700 Subject: [PATCH 04/11] docs and tests --- alab_management/device_view/device_view.py | 37 + alab_management/sample_view/sample_view.py | 41 ++ alab_management/scripts/launch_lab.py | 3 + alab_management/task_view/task_view.py | 27 + docs/source/auto_refresh.md | 15 +- tests/test_auto_refresh_scenarios.py | 792 +++++++++++++++++++++ 6 files changed, 914 insertions(+), 1 deletion(-) create mode 100644 tests/test_auto_refresh_scenarios.py diff --git a/alab_management/device_view/device_view.py b/alab_management/device_view/device_view.py index d35fbd96..00d2b79e 100644 --- a/alab_management/device_view/device_view.py +++ b/alab_management/device_view/device_view.py @@ -620,3 +620,40 @@ def remove_device(self, device_name: str): print( f"Device {device_name} has been removed from the device view and the registry." ) + + def get_all_devices_from_db(self) -> dict[str, dict[str, Any]]: + """ + Get all devices from the database directly. + + Returns a dictionary mapping device names to their database entries. + """ + devices = {} + for device_doc in self._device_collection.find(): + devices[device_doc["name"]] = device_doc + return devices + + def get_device_by_name_from_db(self, device_name: str) -> dict[str, Any] | None: + """ + Get a specific device from the database by name. + + Args: + device_name: The name of the device to find + + Returns + ------- + The device document from the database, or None if not found + """ + return self._device_collection.find_one({"name": device_name}) + + def device_exists_in_db(self, device_name: str) -> bool: + """ + Check if a device exists in the database. + + Args: + device_name: The name of the device to check + + Returns + ------- + True if the device exists in the database, False otherwise + """ + return self._device_collection.count_documents({"name": device_name}) > 0 diff --git a/alab_management/sample_view/sample_view.py b/alab_management/sample_view/sample_view.py index 9d846a0b..0f0f5017 100644 --- a/alab_management/sample_view/sample_view.py +++ b/alab_management/sample_view/sample_view.py @@ -537,3 +537,44 @@ def get_sample_positions_names_by_prefix(self, prefix: str) -> list[str]: {"name": {"$regex": f"^{re.escape(prefix)}"}} ) ] + + def get_all_sample_positions_from_db(self) -> dict[str, dict[str, Any]]: + """ + Get all sample positions from the database directly. + + Returns a dictionary mapping position names to their database entries. + This includes both standalone and device-associated sample positions. + """ + sample_positions = {} + for position_doc in self._sample_positions_collection.find(): + sample_positions[position_doc["name"]] = position_doc + return sample_positions + + def get_sample_position_max_number_by_prefix(self, prefix: str) -> int: + """ + Get the maximum number of sample positions for a given prefix from the database. + + Args: + prefix: The prefix to search for (e.g., "furnace_temp") + + Returns + ------- + The maximum number found for positions with this prefix + """ + positions = self._sample_positions_collection.find( + {"name": {"$regex": f"^{re.escape(prefix)}"}} + ) + + max_number = 0 + for position in positions: + # Extract number from position name (e.g., "furnace_temp/1" -> 1) + name = position["name"] + try: + number_part = name.split(SamplePosition.SEPARATOR)[-1] + number = int(number_part) + max_number = max(max_number, number) + except (ValueError, IndexError): + # If we can't parse the number, count this as position 1 + max_number = max(max_number, 1) + + return max_number diff --git a/alab_management/scripts/launch_lab.py b/alab_management/scripts/launch_lab.py index d0c3cf1b..74afffcd 100644 --- a/alab_management/scripts/launch_lab.py +++ b/alab_management/scripts/launch_lab.py @@ -2,6 +2,7 @@ import contextlib import multiprocessing +import os import sys import time from threading import Thread @@ -117,6 +118,7 @@ def system_refresh(): resource_manager.pause_resource_assigning(), device_manager.pause_all_devices(), ): + os.environ["ALABOS_RELOAD"] = "1" while task_manager.check_number_of_running_tasks(): time.sleep(10) time.sleep(10) # give some time for tasks to finish @@ -124,6 +126,7 @@ def system_refresh(): device_manager.refresh_devices() task_manager.refresh_tasks() time.sleep(10) + os.environ.pop("ALABOS_RELOAD", None) def launch_lab(host, port, debug): diff --git a/alab_management/task_view/task_view.py b/alab_management/task_view/task_view.py index ad22f403..5362b972 100644 --- a/alab_management/task_view/task_view.py +++ b/alab_management/task_view/task_view.py @@ -515,3 +515,30 @@ def get_tasks_to_be_canceled( def exists(self, task_id: ObjectId | str) -> bool: """Check if a task id exists.""" return self._task_collection.find_one({"_id": ObjectId(task_id)}) is not None + + def get_all_tasks_from_db(self) -> dict[str, list[dict[str, Any]]]: + """ + Get all tasks from the database directly. + + Returns a dictionary mapping task types to lists of their database entries. + """ + tasks = {} + for task_doc in self._task_collection.find(): + task_type = task_doc.get("type", "unknown") + if task_type not in tasks: + tasks[task_type] = [] + tasks[task_type].append(task_doc) + return tasks + + def task_type_exists_in_db(self, task_type: str) -> bool: + """ + Check if a task type exists in the database. + + Args: + task_type: The type of task to check + + Returns + ------- + True if the task type exists in the database, False otherwise + """ + return self._task_collection.count_documents({"type": task_type}) > 0 diff --git a/docs/source/auto_refresh.md b/docs/source/auto_refresh.md index 87c1e4d5..a47a7399 100644 --- a/docs/source/auto_refresh.md +++ b/docs/source/auto_refresh.md @@ -49,4 +49,17 @@ The `auto_refresh` feature has some limitations: - For the tasks that are already running, the changes will not be reflected until the task is refreshed. This means that if you change the device or task definition, the running tasks will continue to use the old definitions. Keep in mind that the best practice is to ensure devices and tasks definitions are backward compatible to the older versions so that the running tasks can continue to run without any issues and not have a conflict with the new definitions. -- No user input should be done during the refresh process to avoid unexpected behaviors. No blocking is currently implemented. \ No newline at end of file +- No user input should be done during the refresh process to avoid unexpected behaviors. No blocking is currently implemented. +- No support for task removal has been tested. + +## Tests +The `auto_refresh` feature is tested by: +1. adding and removing slots on a standalone sample position/changing the numbers +2. adding and removing a device and its sample position +3. adding and removing whole sample position +4. adding and removing a sample position prefix in a device +5. changing the numbers in a sample position in a device +6. changing the definition of a task. +7. adding a new task. + + diff --git a/tests/test_auto_refresh_scenarios.py b/tests/test_auto_refresh_scenarios.py new file mode 100644 index 00000000..7a1443e5 --- /dev/null +++ b/tests/test_auto_refresh_scenarios.py @@ -0,0 +1,792 @@ +import subprocess +import time +import unittest +from pathlib import Path +from typing import Any + +import requests + +from alab_management.experiment_view import ExperimentView +from alab_management.scripts.cleanup_lab import cleanup_lab +from alab_management.scripts.setup_lab import setup_lab +from alab_management.task_view import TaskView + + +class TestAutoRefreshScenarios(unittest.TestCase): + """Test auto refresh functionality for various scenarios.""" + + def setUp(self) -> None: + """Set up the test environment.""" + time.sleep(0.5) + cleanup_lab( + all_collections=True, + _force_i_know_its_dangerous=True, + sim_mode=True, + database_name="Alab_sim", + user_confirmation="y", + ) + setup_lab() + self.task_view = TaskView() + self.experiment_view = ExperimentView() + + # Start the lab and worker processes + self.main_process = subprocess.Popen( + ["alabos", "launch", "--port", "8897"], shell=False + ) + self.worker_process = subprocess.Popen( + ["alabos", "launch_worker", "--processes", "4", "--threads", "128"], + shell=False, + ) + time.sleep(5) # waiting for starting up + + if self.main_process.poll() is not None: + raise RuntimeError("Main process failed to start") + if self.worker_process.poll() is not None: + raise RuntimeError("Worker process failed to start") + + def tearDown(self) -> None: + """Clean up the test environment.""" + # Kill processes more robustly + self.main_process.terminate() + self.worker_process.terminate() + subprocess.run( + "lsof -ti :8897 | xargs kill -9 2>/dev/null || true", + check=False, + shell=True, + ) + subprocess.run( + "ps aux | grep 'dramatiq' | grep 'alab_management.task_actor' | awk '{print $2}' | xargs -r kill", + check=False, + shell=True, + ) + subprocess.run("pkill -f 'alabos launch_worker'", check=False, shell=True) + time.sleep(5) + cleanup_lab( + all_collections=True, + _force_i_know_its_dangerous=True, + sim_mode=True, + database_name="Alab_sim", + user_confirmation="y", + ) + + def _submit_test_experiment(self) -> dict[str, Any]: + """Submit a test experiment and return the response.""" + experiment = { + "name": "Auto refresh test experiment", + "tags": [], + "metadata": {}, + "samples": [{"name": "test_sample", "tags": [], "metadata": {}}], + "tasks": [ + { + "type": "Starting", + "prev_tasks": [], + "parameters": {"dest": "furnace_temp"}, + "samples": ["test_sample"], + }, + { + "type": "Heating", + "prev_tasks": [0], + "parameters": {"setpoints": ((1, 5),)}, # 30 second heating + "samples": ["test_sample"], + }, + { + "type": "Ending", + "prev_tasks": [1], + "parameters": {}, + "samples": ["test_sample"], + }, + ], + } + + resp = requests.post( + "http://127.0.0.1:8897/api/experiment/submit", json=experiment + ) + return resp + + def wait_for_refresh_cycle(self, timeout: int = 35) -> None: + """Wait for the refresh cycle to complete (system checks every 20 seconds).""" + time.sleep(timeout) + + def _wait_for_experiment_completion(self, timeout: int = 120) -> None: + """Wait for the current experiment to complete.""" + start_time = time.time() + while time.time() - start_time < timeout: + task_view = TaskView() + all_tasks = list(task_view._task_collection.find()) + completed_tasks = [ + task for task in all_tasks if task.get("status") == "COMPLETED" + ] + if len(completed_tasks) == 3: + return + time.sleep(5) + raise TimeoutError(f"Experiment did not complete within {timeout} seconds") + + def _wait_and_verify_sample_position_change( + self, position_name: str, expected_number: int, timeout: int = 120 + ) -> None: + """Wait for sample position change and verify it's applied.""" + start_time = time.time() + while time.time() - start_time < timeout: + try: + # Check if the sample position has the expected number from database + from alab_management.sample_view.sample_view import SampleView + + sample_view = SampleView() + max_number = sample_view.get_sample_position_max_number_by_prefix( + position_name + ) + print( + f"Sample position {position_name} max number from DB: {max_number}" + ) + + if max_number == expected_number: + print( + f"Sample position {position_name} number change to {expected_number} detected" + ) + return # Change detected + + time.sleep(5) + except Exception: + time.sleep(5) + + self.fail( + f"Sample position {position_name} number change to {expected_number} not detected within {timeout} seconds" + ) + + def _wait_and_verify_device_addition( + self, device_name: str, timeout: int = 60 + ) -> None: + """Wait for device addition and verify it's applied.""" + start_time = time.time() + while time.time() - start_time < timeout: + try: + # Check if the device exists from database + from alab_management.device_view.device_view import DeviceView + + device_view = DeviceView() + if device_view.device_exists_in_db(device_name): + return # Device found + + time.sleep(5) + except Exception: + time.sleep(5) + + self.fail( + f"Device {device_name} addition not detected within {timeout} seconds" + ) + + def _wait_and_verify_device_removal( + self, device_name: str, timeout: int = 60 + ) -> None: + """Wait for device removal and verify it's applied.""" + start_time = time.time() + while time.time() - start_time < timeout: + try: + # Check if the device no longer exists from database + from alab_management.device_view.device_view import DeviceView + + device_view = DeviceView() + if not device_view.device_exists_in_db(device_name): + return # Device removed + + time.sleep(5) + except Exception: + time.sleep(5) + + self.fail(f"Device {device_name} removal not detected within {timeout} seconds") + + def _wait_and_verify_sample_position_addition( + self, position_name: str, expected_number: int, timeout: int = 60 + ) -> None: + """Wait for sample position addition and verify it's applied.""" + start_time = time.time() + while time.time() - start_time < timeout: + try: + # Check if the sample position exists from database + from alab_management.sample_view.sample_view import SampleView + + sample_view = SampleView() + max_number = sample_view.get_sample_position_max_number_by_prefix( + position_name + ) + + if max_number == expected_number: + return # Position found + + time.sleep(5) + except Exception: + time.sleep(5) + + self.fail( + f"Sample position {position_name} addition not detected within {timeout} seconds" + ) + + def _wait_and_verify_sample_position_removal( + self, position_name: str, timeout: int = 60 + ) -> None: + """Wait for sample position removal and verify it's applied.""" + start_time = time.time() + while time.time() - start_time < timeout: + try: + # Check if the sample position no longer exists from database + from alab_management.sample_view.sample_view import SampleView + + sample_view = SampleView() + all_positions = sample_view.get_all_sample_positions_from_db() + + position_found = False + for position_name_db in all_positions: + if position_name_db.startswith(position_name): + position_found = True + break + + if not position_found: + return # Position removed + + time.sleep(5) + except Exception: + time.sleep(5) + + self.fail( + f"Sample position {position_name} removal not detected within {timeout} seconds" + ) + + # TODO: wrong way to search for device sample position. + # Search for sample position of the new device sample position alongside the parent device name. + def _wait_and_verify_device_sample_position_addition( + self, + device_name: str, + position_name: str, + expected_number: int, + timeout: int = 60, + ) -> None: + """Wait for device sample position addition and verify it's applied.""" + start_time = time.time() + while time.time() - start_time < timeout: + try: + # Check if the device has the new sample position from database + from alab_management.sample_view.sample_view import SampleView + + sample_view = SampleView() + device_position_prefix = f"{device_name}/{position_name}" + max_number = sample_view.get_sample_position_max_number_by_prefix( + device_position_prefix + ) + + if max_number == expected_number: + return # Position found in device + + time.sleep(5) + except Exception: + time.sleep(5) + + self.fail( + f"Device {device_name} sample position {position_name} addition not detected within {timeout} seconds" + ) + + # TODO: wrong way to search for device sample position. + def _wait_and_verify_device_sample_position_removal( + self, device_name: str, position_name: str, timeout: int = 60 + ) -> None: + """Wait for device sample position removal and verify it's applied.""" + start_time = time.time() + while time.time() - start_time < timeout: + try: + # Check if the device no longer has the sample position from database + from alab_management.sample_view.sample_view import SampleView + + sample_view = SampleView() + device_position_prefix = f"{device_name}/{position_name}" + all_positions = sample_view.get_all_sample_positions_from_db() + + position_found = False + for position_name_db in all_positions: + if position_name_db.startswith(device_position_prefix): + position_found = True + break + + if not position_found: + return # Position removed from device + + time.sleep(5) + except Exception: + time.sleep(5) + + self.fail( + f"Device {device_name} sample position {position_name} removal not detected within {timeout} seconds" + ) + + # TODO: wrong way to search for device sample position. + # Search for sample position of the new device sample position alongside the parent device name. + def _wait_and_verify_device_sample_position_change( + self, + device_name: str, + position_name: str, + expected_number: int, + timeout: int = 60, + ) -> None: + """Wait for device sample position number change and verify it's applied.""" + start_time = time.time() + while time.time() - start_time < timeout: + try: + # Check if the device sample position has the expected number from database + from alab_management.sample_view.sample_view import SampleView + + sample_view = SampleView() + device_position_prefix = f"{device_name}/{position_name}" + max_number = sample_view.get_sample_position_max_number_by_prefix( + device_position_prefix + ) + + if max_number == expected_number: + return # Change detected + + time.sleep(5) + except Exception: + time.sleep(5) + + self.fail( + f"Device {device_name} sample position {position_name} number change to {expected_number} not detected within {timeout} seconds" + ) + + def _wait_and_verify_task_addition(self, task_name: str, timeout: int = 60) -> None: + """Wait for task addition and verify it's applied.""" + start_time = time.time() + while time.time() - start_time < timeout: + try: + # Check if the task exists from database + from alab_management.task_view.task_view import TaskView + + task_view = TaskView() + if task_view.task_type_exists_in_db(task_name): + return # Task found + + time.sleep(5) + except Exception: + time.sleep(5) + + self.fail(f"Task {task_name} addition not detected within {timeout} seconds") + + def _wait_and_verify_task_removal(self, task_name: str, timeout: int = 60) -> None: + """Wait for task removal and verify it's applied.""" + start_time = time.time() + while time.time() - start_time < timeout: + try: + # Check if the task no longer exists from database + from alab_management.task_view.task_view import TaskView + + task_view = TaskView() + if not task_view.task_type_exists_in_db(task_name): + return # Task removed + + time.sleep(5) + except Exception: + time.sleep(5) + + self.fail(f"Task {task_name} removal not detected within {timeout} seconds") + + def test_1_adding_removing_slots_standalone_sample_position(self): + """Test 1: Adding and removing slots on a standalone sample position/changing the numbers.""" + # Get the original fake_lab __init__.py content + init_file = Path("tests/fake_lab/__init__.py") + original_content = init_file.read_text() + + try: + # Submit an experiment to have tasks running + resp = self._submit_test_experiment() + self.assertEqual(resp.status_code, 200) + time.sleep(5) # Wait for tasks to start + + # Modify the standalone sample position - change number from 64 to 32 + modified_content = original_content.replace("number=64,", "number=32,") + init_file.write_text(modified_content) + + # Wait for refresh cycle and verify change is applied + self._wait_and_verify_sample_position_change("furnace_temp", 32) + + # Change back to original + modified_content = original_content.replace("number=32,", "number=64,") + init_file.write_text(modified_content) + + # Wait for refresh cycle and verify change is applied + self._wait_and_verify_sample_position_change("furnace_temp", 64) + + finally: + # Restore original content + init_file.write_text(original_content) + + def test_2_adding_removing_device_and_sample_position(self): + """Test 2: Adding and removing a device and its sample position.""" + # Get the original fake_lab __init__.py content + init_file = Path("tests/fake_lab/__init__.py") + original_content = init_file.read_text() + + try: + # Submit an experiment to have tasks running + resp = self._submit_test_experiment() + self.assertEqual(resp.status_code, 200) + time.sleep(5) # Wait for tasks to start + + # Wait for experiment to complete + self._wait_for_experiment_completion() + + # Add a new device (furnace_17) to the configuration + add_device_line = 'add_device(Furnace(name="furnace_16"))' + + modified_content = original_content.replace( + add_device_line, + f'{add_device_line}\nadd_device(Furnace(name="furnace_17"))', + ) + init_file.write_text(modified_content) + + # Wait for refresh cycle and verify device is added + self._wait_and_verify_device_addition("furnace_17") + + # Remove the device by restoring original content + init_file.write_text(original_content) + + # Wait for refresh cycle and verify device is removed + self._wait_and_verify_device_removal("furnace_17") + + # Verify the system is still functional + resp = self._submit_test_experiment() + self.assertEqual(resp.status_code, 200) + + finally: + # Restore original content + init_file.write_text(original_content) + + def test_3_adding_removing_whole_sample_position(self): + """Test 3: Adding and removing whole sample position.""" + # Get the original fake_lab __init__.py content + init_file = Path("tests/fake_lab/__init__.py") + original_content = init_file.read_text() + + try: + # Submit an experiment to have tasks running + resp = self._submit_test_experiment() + self.assertEqual(resp.status_code, 200) + time.sleep(5) # Wait for tasks to start + + # Wait for experiment to complete + self._wait_for_experiment_completion() + + # Add a new standalone sample position + new_sample_position = """ +add_standalone_sample_position( + SamplePosition( + "test_position", + number=16, + description="Test position for auto refresh", + ) +) +""" + # Find the last add_standalone_sample_position and add after it + lines = original_content.split("\n") + last_sample_pos_idx = -1 + for i, line in enumerate(lines): + if "add_standalone_sample_position" in line: + last_sample_pos_idx = i + + if last_sample_pos_idx != -1: + # Find the end of the last sample position block + end_idx = last_sample_pos_idx + brace_count = 0 + for i in range(last_sample_pos_idx, len(lines)): + if "(" in lines[i]: + brace_count += lines[i].count("(") + if ")" in lines[i]: + brace_count -= lines[i].count(")") + if brace_count == 0: + end_idx = i + break + + # Insert new sample position after the last one + lines.insert(end_idx + 1, new_sample_position) + modified_content = "\n".join(lines) + init_file.write_text(modified_content) + + # Wait for refresh cycle and verify sample position is added + self._wait_and_verify_sample_position_addition("test_position", 16) + + # Remove the sample position by restoring original content + init_file.write_text(original_content) + + # Wait for refresh cycle and verify sample position is removed + self._wait_and_verify_sample_position_removal("test_position") + + # Verify the system is still functional + resp = self._submit_test_experiment() + self.assertEqual(resp.status_code, 200) + + finally: + # Restore original content + init_file.write_text(original_content) + + def test_4_adding_removing_sample_position_prefix_in_device(self): + """Test 4: Adding and removing a sample position prefix in a device.""" + # Get the original furnace device content + furnace_file = Path("tests/fake_lab/devices/furnace.py") + original_content = furnace_file.read_text() + + try: + # Submit an experiment to have tasks running + resp = self._submit_test_experiment() + self.assertEqual(resp.status_code, 200) + time.sleep(5) # Wait for tasks to start + + # Wait for experiment to complete + self._wait_for_experiment_completion() + + # Add a new sample position to the furnace device + new_position = """ + SamplePosition( + "test_prefix", + description="Test position prefix in furnace", + number=2, + ),""" + + # Find the sample_positions property and add the new position + lines = original_content.split("\n") + sample_positions_start = -1 + for i, line in enumerate(lines): + if "def sample_positions(self):" in line: + sample_positions_start = i + break + + if sample_positions_start != -1: + # Find the return statement + return_idx = -1 + for i in range(sample_positions_start, len(lines)): + if "return [" in lines[i]: + return_idx = i + break + + if return_idx != -1: + # Add the new position before the closing bracket + lines.insert(return_idx + 1, new_position) + modified_content = "\n".join(lines) + furnace_file.write_text(modified_content) + + # Wait for refresh cycle and verify device sample position is added + self._wait_and_verify_device_sample_position_addition( + "furnace_1", "test_prefix", 2 + ) + + # Remove the position by restoring original content + furnace_file.write_text(original_content) + + # Wait for refresh cycle and verify device sample position is removed + self._wait_and_verify_device_sample_position_removal( + "furnace_1", "test_prefix" + ) + + # Verify the system is still functional + resp = self._submit_test_experiment() + self.assertEqual(resp.status_code, 200) + + finally: + # Restore original content + furnace_file.write_text(original_content) + + def test_5_changing_numbers_in_sample_position_in_device(self): + """Test 5: Changing the numbers in a sample position in a device.""" + # Get the original furnace device content + furnace_file = Path("tests/fake_lab/devices/furnace.py") + original_content = furnace_file.read_text() + + try: + # Submit an experiment to have tasks running + resp = self._submit_test_experiment() + self.assertEqual(resp.status_code, 200) + time.sleep(5) # Wait for tasks to start + + # Wait for experiment to complete + self._wait_for_experiment_completion() + + # Change the number of samples in the "inside" position from 8 to 4 + modified_content = original_content.replace( + "number=8, # eight samples can be heated at the same time", + "number=4, # four samples can be heated at the same time", + ) + furnace_file.write_text(modified_content) + + # Wait for refresh cycle and verify device sample position number change + self._wait_and_verify_device_sample_position_change( + "furnace_1", "inside", 4 + ) + + # Change back to original + modified_content = original_content.replace( + "number=4, # four samples can be heated at the same time", + "number=8, # eight samples can be heated at the same time", + ) + furnace_file.write_text(modified_content) + + # Wait for refresh cycle and verify device sample position number change + self._wait_and_verify_device_sample_position_change( + "furnace_1", "inside", 8 + ) + + # Verify the system is still functional + resp = self._submit_test_experiment() + self.assertEqual(resp.status_code, 200) + + finally: + # Restore original content + furnace_file.write_text(original_content) + + def test_7_adding_new_task(self): + """Test 7: Adding a new task.""" + # Create a new task file + new_task_file = Path("tests/fake_lab/tasks/test_task.py") + new_task_content = '''import time + +from alab_management.task_view import BaseTask + + +class TestTask(BaseTask): + """Test task for auto refresh.""" + + def __init__(self, samples, *args, **kwargs): + """Test task. + + Args: + samples: List of sample names or ids. + """ + super().__init__(samples=samples, *args, **kwargs) + + def run(self): + """Run the test task.""" + # Import Furnace locally to avoid circular import + from .. import Furnace + + with self.lab_view.request_resources({Furnace: {"inside": 1}}) as ( + inner_devices, + sample_positions, + ): + # Just wait for a short time to simulate work + time.sleep(2) + return self.task_id +''' + + # Get the original fake_lab __init__.py content + init_file = Path("tests/fake_lab/__init__.py") + original_content = init_file.read_text() + + try: + # Submit an experiment to have tasks running + resp = self._submit_test_experiment() + self.assertEqual(resp.status_code, 200) + time.sleep(5) # Wait for tasks to start + + # Wait for experiment to complete + self._wait_for_experiment_completion() + + # Create the new task file + new_task_file.write_text(new_task_content) + + # Add the task to the __init__.py file + add_task_line = "add_task(TakePictureWithoutSpecifiedResult)" + + # Add import for the new task + import_line = "from .tasks import (" + new_import_line = "from .tasks import (\n TestTask," + + # Also add the import to the tasks __init__.py file + tasks_init_file = Path("tests/fake_lab/tasks/__init__.py") + tasks_init_content = tasks_init_file.read_text() + + # Add the import if it doesn't already exist + if "from .test_task import TestTask" not in tasks_init_content: + tasks_init_content += "\nfrom .test_task import TestTask\n" + tasks_init_file.write_text(tasks_init_content) + + modified_content = original_content.replace( + import_line, new_import_line + ).replace(add_task_line, f"{add_task_line}\nadd_task(TestTask)") + init_file.write_text(modified_content) + + # Wait for refresh cycle and verify task is added + self._wait_and_verify_task_addition("TestTask") + + # Test that the new task can be used in an experiment + new_experiment = { + "name": "Test new task experiment", + "tags": [], + "metadata": {}, + "samples": [{"name": "test_sample_2", "tags": [], "metadata": {}}], + "tasks": [ + { + "type": "Starting", + "prev_tasks": [], + "parameters": {"dest": "furnace_temp"}, + "samples": ["test_sample_2"], + }, + { + "type": "TestTask", + "prev_tasks": [0], + "parameters": {}, + "samples": ["test_sample_2"], + }, + { + "type": "Ending", + "prev_tasks": [1], + "parameters": {}, + "samples": ["test_sample_2"], + }, + ], + } + + resp = requests.post( + "http://127.0.0.1:8897/api/experiment/submit", json=new_experiment + ) + self.assertEqual(resp.status_code, 200) + + # Remove the task by restoring original content and deleting the file + init_file.write_text(original_content) + if new_task_file.exists(): + new_task_file.unlink() + + # Also remove the import from tasks __init__.py + tasks_init_file = Path("tests/fake_lab/tasks/__init__.py") + if tasks_init_file.exists(): + tasks_init_content = tasks_init_file.read_text() + # Remove the TestTask import + tasks_init_content = tasks_init_content.replace( + "\nfrom .test_task import TestTask\n", "" + ) + tasks_init_content = tasks_init_content.replace( + "from .test_task import TestTask\n", "" + ) + tasks_init_file.write_text(tasks_init_content) + + # Wait for refresh cycle and verify task is removed + self._wait_and_verify_task_removal("TestTask") + + # Verify the system is still functional + resp = self._submit_test_experiment() + self.assertEqual(resp.status_code, 200) + + finally: + # Restore original content and clean up + init_file.write_text(original_content) + if new_task_file.exists(): + new_task_file.unlink() + + # Also remove the import from tasks __init__.py in cleanup + tasks_init_file = Path("tests/fake_lab/tasks/__init__.py") + if tasks_init_file.exists(): + tasks_init_content = tasks_init_file.read_text() + # Remove the TestTask import + tasks_init_content = tasks_init_content.replace( + "\nfrom .test_task import TestTask\n", "" + ) + tasks_init_content = tasks_init_content.replace( + "from .test_task import TestTask\n", "" + ) + tasks_init_file.write_text(tasks_init_content) + + +if __name__ == "__main__": + unittest.main() From fd46067ab85d4851cfc9209a90f7987dc2a37585 Mon Sep 17 00:00:00 2001 From: Bernardus Rendy <37468936+bernardusrendy@users.noreply.github.com> Date: Sun, 13 Jul 2025 17:21:06 -0700 Subject: [PATCH 05/11] tested adding new class of task and devices --- alab_management/experiment_manager.py | 32 +- alab_management/scripts/launch_lab.py | 2 + alab_management/utils/module_ops.py | 82 +++++ docs/source/auto_refresh.md | 5 +- tests/fake_lab/tasks/moving.py | 5 +- tests/test_auto_refresh_scenarios.py | 507 +++++++++++++++++++++----- 6 files changed, 533 insertions(+), 100 deletions(-) diff --git a/alab_management/experiment_manager.py b/alab_management/experiment_manager.py index 9dcff3d5..96145b7c 100644 --- a/alab_management/experiment_manager.py +++ b/alab_management/experiment_manager.py @@ -6,9 +6,14 @@ done. """ +import logging import time +from contextlib import contextmanager from typing import Any +from alab_management.utils.logger import set_up_rich_handler +from alab_management.utils.module_ops import load_definition + from .config import AlabOSConfig from .experiment_view import CompletedExperimentView, ExperimentStatus, ExperimentView from .logger import DBLogger @@ -16,6 +21,9 @@ from .task_view import TaskStatus, TaskView from .utils.graph_ops import Graph +cli_logger = logging.getLogger(__name__) +set_up_rich_handler(cli_logger) + class ExperimentManager: """ @@ -35,6 +43,7 @@ def __init__(self): ) # if this is not defined in the config, assume it this feature is not being used. if self.__copy_to_completed_db: self.completed_experiment_view = CompletedExperimentView() + self._pause_handling_experiments = False def run(self): """Start the event loop.""" @@ -50,8 +59,9 @@ def run(self): time.sleep(1) def _loop(self): - self.handle_pending_experiments() - self.mark_completed_experiments() + if not self._pause_handling_experiments: + self.handle_pending_experiments() + self.mark_completed_experiments() def handle_pending_experiments(self): """ @@ -194,3 +204,21 @@ def mark_completed_experiments(self): "exp_id": experiment["_id"], }, ) + + @contextmanager + def pause_handling_experiments(self): + """This method will pause the handle pending experiments.""" + try: + self._pause_handling_experiments = True + cli_logger.info("Pausing handling experiments.") + yield + finally: + self._pause_handling_experiments = False + cli_logger.info("Resuming handling experiments.") + + def refresh_task_list(self): + """This method will refresh the task list by reloading definition of tasks.""" + print("Refreshing task view in ExperimentManager...") + load_definition(reload=True) + self.task_view = TaskView() + self.experiment_view = ExperimentView() diff --git a/alab_management/scripts/launch_lab.py b/alab_management/scripts/launch_lab.py index 74afffcd..a0ed3a76 100644 --- a/alab_management/scripts/launch_lab.py +++ b/alab_management/scripts/launch_lab.py @@ -117,6 +117,7 @@ def system_refresh(): task_manager.pause_new_task_launching(), resource_manager.pause_resource_assigning(), device_manager.pause_all_devices(), + experiment_manager.pause_handling_experiments(), ): os.environ["ALABOS_RELOAD"] = "1" while task_manager.check_number_of_running_tasks(): @@ -125,6 +126,7 @@ def system_refresh(): # important to refresh device first because device manager will compare current state with future state device_manager.refresh_devices() task_manager.refresh_tasks() + experiment_manager.refresh_task_list() time.sleep(10) os.environ.pop("ALABOS_RELOAD", None) diff --git a/alab_management/utils/module_ops.py b/alab_management/utils/module_ops.py index ad1e74b2..fdf24392 100644 --- a/alab_management/utils/module_ops.py +++ b/alab_management/utils/module_ops.py @@ -104,6 +104,7 @@ def import_module_from_path(path: str | Path, reload: bool = False) -> ModuleTyp # Determine module name by relative path from top-level package rel_path = path.relative_to(dir_path.parent) module_name = ".".join(rel_path.with_suffix("").parts) + # Import the module with import_lock: if module_name in sys.modules and reload: @@ -111,12 +112,93 @@ def import_module_from_path(path: str | Path, reload: bool = False) -> ModuleTyp # Set an environment variable to indicate reloading, used in add_device, add_task, add_sample_position os.environ["ALABOS_RELOAD"] = "1" try: + # First, scan for new modules that might not be imported yet + if reload: + _scan_and_import_new_modules(path) return deep_reload(sys.modules[module_name]) finally: os.environ.pop("ALABOS_RELOAD", None) return importlib.import_module(module_name, module_name) +def _scan_and_import_new_modules(root_path: Path) -> None: + """ + Scan for new Python modules in the given path and import them. + This ensures that new packages/definitions are discovered during reload. + """ + if not root_path.is_dir(): + return + + # Use the same logic as import_module_from_path to find the top-level package + dir_path = root_path + while (dir_path.parent / "__init__.py").exists(): + dir_path = dir_path.parent + + # Collect all Python files and sort them by depth (submodules first) + python_files = [] + for py_file in root_path.rglob("*.py"): + if py_file.name == "__init__.py": + continue + # Calculate depth (number of parts in relative path) + rel_path = py_file.relative_to(root_path) + depth = len(rel_path.parts) + python_files.append((depth, py_file)) + + # Sort by depth (deepest first) to ensure submodules are imported before parent modules + python_files.sort(key=lambda x: x[0], reverse=True) + + reloaded_packages = set() + root_path = root_path.resolve() + + for _depth, py_file in python_files: + try: + rel_path = py_file.relative_to(dir_path.parent) + module_name = ".".join(rel_path.with_suffix("").parts) + + # If this module is not already imported, import it + if module_name not in sys.modules: + print(f"Importing new module: {module_name}") + importlib.import_module(module_name) + + # After importing, reload each parent package up to the working directory + parent = py_file.parent + while True: + # Stop if we've reached above the working directory + if parent.resolve() < root_path: + break + # Only reload if __init__.py exists + init_file = parent / "__init__.py" + if not init_file.exists(): + parent = parent.parent + continue + # Calculate the package name + try: + rel_parent = parent.relative_to(dir_path.parent) + package_name = ".".join(rel_parent.parts) + if ( + package_name + and package_name not in reloaded_packages + and package_name in sys.modules + ): + print(f"Reloading parent package: {package_name}") + try: + deep_reload(sys.modules[package_name]) + reloaded_packages.add(package_name) + except Exception as e: + print( + f"Failed to reload parent package {package_name}: {e}" + ) + except ValueError: + # parent is not under dir_path.parent + break + if parent.resolve() == root_path: + break + parent = parent.parent + except (ValueError, ImportError) as e: + print(f"Skipping import of {py_file}: {e}") + continue + + def load_definition(reload: bool = False) -> None: """Load device and task definitions from file (specified in config file).""" from alab_management.config import AlabOSConfig diff --git a/docs/source/auto_refresh.md b/docs/source/auto_refresh.md index a47a7399..c9c701da 100644 --- a/docs/source/auto_refresh.md +++ b/docs/source/auto_refresh.md @@ -50,7 +50,7 @@ The `auto_refresh` feature has some limitations: This means that if you change the device or task definition, the running tasks will continue to use the old definitions. Keep in mind that the best practice is to ensure devices and tasks definitions are backward compatible to the older versions so that the running tasks can continue to run without any issues and not have a conflict with the new definitions. - No user input should be done during the refresh process to avoid unexpected behaviors. No blocking is currently implemented. -- No support for task removal has been tested. +- No support for task removal. It will require further implementation in checking running tasks and submitted experiments for the task being removed. But this is less important as removing a task is not a common operation. ## Tests The `auto_refresh` feature is tested by: @@ -60,6 +60,7 @@ The `auto_refresh` feature is tested by: 4. adding and removing a sample position prefix in a device 5. changing the numbers in a sample position in a device 6. changing the definition of a task. -7. adding a new task. +7. adding a new class of task. +8. adding a new class of device. diff --git a/tests/fake_lab/tasks/moving.py b/tests/fake_lab/tasks/moving.py index 40968c22..6c5926b6 100644 --- a/tests/fake_lab/tasks/moving.py +++ b/tests/fake_lab/tasks/moving.py @@ -26,17 +26,18 @@ def run(self): with self.lab_view.request_resources( {RobotArm: {}, None: {self.dest: 1, self.sample_position: 1}} ) as (inner_devices, sample_positions): + sample_destination = sample_positions[None][self.dest][0] robot_arm: RobotArm = inner_devices[RobotArm] robot_arm.run_program( f"{sample_positions[None][self.sample_position][0]}-{self.dest}.urp" ) - self.lab_view.move_sample(self.sample, self.dest) + self.lab_view.move_sample(self.sample, sample_destination) self.logger.log_device_signal( device_name=robot_arm.name, signal_name="MoveSample", signal_value={ "src": sample_positions[None][self.sample_position][0], - "dest": sample_positions[None][self.dest][0], + "dest": sample_destination, }, ) return self.task_id diff --git a/tests/test_auto_refresh_scenarios.py b/tests/test_auto_refresh_scenarios.py index 7a1443e5..0f443243 100644 --- a/tests/test_auto_refresh_scenarios.py +++ b/tests/test_auto_refresh_scenarios.py @@ -1,3 +1,5 @@ +import atexit +import signal import subprocess import time import unittest @@ -12,6 +14,44 @@ from alab_management.task_view import TaskView +class VerificationTimeout(Exception): + """Raised when a verification helper times out waiting for a condition.""" + + pass + + +def cleanup_background_processes(): + """Clean up background processes in case of test failure.""" + try: + subprocess.run( + "lsof -ti :8897 | xargs kill -9 2>/dev/null || true", + check=False, + shell=True, + ) + subprocess.run( + "ps aux | grep 'dramatiq' | grep 'alab_management.task_actor' | awk '{print $2}' | xargs -r kill", + check=False, + shell=True, + ) + subprocess.run("pkill -f 'alabos launch_worker'", check=False, shell=True) + subprocess.run("pkill -f 'alabos launch'", check=False, shell=True) + except Exception: + pass + + +def signal_handler(signum, frame): + """Handle interrupt signals to ensure cleanup.""" + print(f"\nReceived signal {signum}, cleaning up...") + cleanup_background_processes() + exit(1) + + +# Register signal handlers and cleanup function +signal.signal(signal.SIGINT, signal_handler) +signal.signal(signal.SIGTERM, signal_handler) +atexit.register(cleanup_background_processes) + + class TestAutoRefreshScenarios(unittest.TestCase): """Test auto refresh functionality for various scenarios.""" @@ -46,28 +86,48 @@ def setUp(self) -> None: def tearDown(self) -> None: """Clean up the test environment.""" - # Kill processes more robustly - self.main_process.terminate() - self.worker_process.terminate() - subprocess.run( - "lsof -ti :8897 | xargs kill -9 2>/dev/null || true", - check=False, - shell=True, - ) - subprocess.run( - "ps aux | grep 'dramatiq' | grep 'alab_management.task_actor' | awk '{print $2}' | xargs -r kill", - check=False, - shell=True, - ) - subprocess.run("pkill -f 'alabos launch_worker'", check=False, shell=True) - time.sleep(5) - cleanup_lab( - all_collections=True, - _force_i_know_its_dangerous=True, - sim_mode=True, - database_name="Alab_sim", - user_confirmation="y", - ) + try: + # Kill processes more robustly + if hasattr(self, "main_process") and self.main_process: + self.main_process.terminate() + try: + self.main_process.wait(timeout=5) + except subprocess.TimeoutExpired: + self.main_process.kill() + + if hasattr(self, "worker_process") and self.worker_process: + self.worker_process.terminate() + try: + self.worker_process.wait(timeout=5) + except subprocess.TimeoutExpired: + self.worker_process.kill() + + # Additional cleanup to ensure all processes are killed + subprocess.run( + "lsof -ti :8897 | xargs kill -9 2>/dev/null || true", + check=False, + shell=True, + ) + subprocess.run( + "ps aux | grep 'dramatiq' | grep 'alab_management.task_actor' | awk '{print $2}' | xargs -r kill", + check=False, + shell=True, + ) + subprocess.run("pkill -f 'alabos launch_worker'", check=False, shell=True) + subprocess.run("pkill -f 'alabos launch'", check=False, shell=True) + time.sleep(2) + + cleanup_lab( + all_collections=True, + _force_i_know_its_dangerous=True, + sim_mode=True, + database_name="Alab_sim", + user_confirmation="y", + ) + except Exception as e: + print(f"Error during tearDown: {e}") + # Force cleanup even if there's an error + cleanup_background_processes() def _submit_test_experiment(self) -> dict[str, Any]: """Submit a test experiment and return the response.""" @@ -107,7 +167,9 @@ def wait_for_refresh_cycle(self, timeout: int = 35) -> None: """Wait for the refresh cycle to complete (system checks every 20 seconds).""" time.sleep(timeout) - def _wait_for_experiment_completion(self, timeout: int = 120) -> None: + def _wait_for_experiment_completion( + self, timeout: int = 120, expected_tasks: int = 3 + ) -> None: """Wait for the current experiment to complete.""" start_time = time.time() while time.time() - start_time < timeout: @@ -116,10 +178,12 @@ def _wait_for_experiment_completion(self, timeout: int = 120) -> None: completed_tasks = [ task for task in all_tasks if task.get("status") == "COMPLETED" ] - if len(completed_tasks) == 3: + if len(completed_tasks) == expected_tasks: return time.sleep(5) - raise TimeoutError(f"Experiment did not complete within {timeout} seconds") + raise VerificationTimeout( + f"Experiment did not complete within {timeout} seconds" + ) def _wait_and_verify_sample_position_change( self, position_name: str, expected_number: int, timeout: int = 120 @@ -143,14 +207,14 @@ def _wait_and_verify_sample_position_change( print( f"Sample position {position_name} number change to {expected_number} detected" ) - return # Change detected + return True # Change detected time.sleep(5) except Exception: time.sleep(5) - self.fail( - f"Sample position {position_name} number change to {expected_number} not detected within {timeout} seconds" + raise VerificationTimeout( + f"Sample position {position_name} did not reach expected number {expected_number} within {timeout} seconds" ) def _wait_and_verify_device_addition( @@ -165,14 +229,14 @@ def _wait_and_verify_device_addition( device_view = DeviceView() if device_view.device_exists_in_db(device_name): - return # Device found + return True # Device found time.sleep(5) except Exception: time.sleep(5) - self.fail( - f"Device {device_name} addition not detected within {timeout} seconds" + raise VerificationTimeout( + f"Device {device_name} was not added within {timeout} seconds" ) def _wait_and_verify_device_removal( @@ -187,13 +251,15 @@ def _wait_and_verify_device_removal( device_view = DeviceView() if not device_view.device_exists_in_db(device_name): - return # Device removed + return True # Device removed time.sleep(5) except Exception: time.sleep(5) - self.fail(f"Device {device_name} removal not detected within {timeout} seconds") + raise VerificationTimeout( + f"Device {device_name} was not removed within {timeout} seconds" + ) def _wait_and_verify_sample_position_addition( self, position_name: str, expected_number: int, timeout: int = 60 @@ -211,14 +277,14 @@ def _wait_and_verify_sample_position_addition( ) if max_number == expected_number: - return # Position found + return True # Position found time.sleep(5) except Exception: time.sleep(5) - self.fail( - f"Sample position {position_name} addition not detected within {timeout} seconds" + raise VerificationTimeout( + f"Sample position {position_name} was not added with expected number {expected_number} within {timeout} seconds" ) def _wait_and_verify_sample_position_removal( @@ -241,14 +307,14 @@ def _wait_and_verify_sample_position_removal( break if not position_found: - return # Position removed + return True # Position removed time.sleep(5) except Exception: time.sleep(5) - self.fail( - f"Sample position {position_name} removal not detected within {timeout} seconds" + raise VerificationTimeout( + f"Sample position {position_name} was not removed within {timeout} seconds" ) # TODO: wrong way to search for device sample position. @@ -274,14 +340,14 @@ def _wait_and_verify_device_sample_position_addition( ) if max_number == expected_number: - return # Position found in device + return True # Position found in device time.sleep(5) except Exception: time.sleep(5) - self.fail( - f"Device {device_name} sample position {position_name} addition not detected within {timeout} seconds" + raise VerificationTimeout( + f"Device sample position {device_name}/{position_name} was not added with expected number {expected_number} within {timeout} seconds" ) # TODO: wrong way to search for device sample position. @@ -306,14 +372,14 @@ def _wait_and_verify_device_sample_position_removal( break if not position_found: - return # Position removed from device + return True # Position removed from device time.sleep(5) except Exception: time.sleep(5) - self.fail( - f"Device {device_name} sample position {position_name} removal not detected within {timeout} seconds" + raise VerificationTimeout( + f"Device sample position {device_name}/{position_name} was not removed within {timeout} seconds" ) # TODO: wrong way to search for device sample position. @@ -339,33 +405,51 @@ def _wait_and_verify_device_sample_position_change( ) if max_number == expected_number: - return # Change detected + return True # Change detected time.sleep(5) except Exception: time.sleep(5) - self.fail( - f"Device {device_name} sample position {position_name} number change to {expected_number} not detected within {timeout} seconds" + raise VerificationTimeout( + f"Device sample position {device_name}/{position_name} did not reach expected number {expected_number} within {timeout} seconds" ) def _wait_and_verify_task_addition(self, task_name: str, timeout: int = 60) -> None: """Wait for task addition and verify it's applied.""" + print(f"=== Starting _wait_and_verify_task_addition for {task_name} ===") start_time = time.time() + iteration = 0 while time.time() - start_time < timeout: + iteration += 1 + elapsed = time.time() - start_time + print(f"=== Iteration {iteration}, elapsed: {elapsed:.1f}s ===") try: # Check if the task exists from database from alab_management.task_view.task_view import TaskView task_view = TaskView() - if task_view.task_type_exists_in_db(task_name): - return # Task found + exists = task_view.task_type_exists_in_db(task_name) + print(f"=== Task {task_name} exists in DB: {exists} ===") + + # Debug: Let's see what tasks are actually in the database + all_tasks = task_view.get_all_tasks_from_db() + print(f"=== All tasks in DB: {list(all_tasks.keys())} ===") + + if exists: + print(f"=== Task {task_name} found successfully! ===") + return True # Task found + print(f"=== Task {task_name} not found yet, sleeping 5s ===") time.sleep(5) - except Exception: + except Exception as e: + print(f"=== Exception in _wait_and_verify_task_addition: {e} ===") time.sleep(5) - self.fail(f"Task {task_name} addition not detected within {timeout} seconds") + print(f"=== Timeout reached for task {task_name} ===") + raise VerificationTimeout( + f"Task {task_name} was not added within {timeout} seconds" + ) def _wait_and_verify_task_removal(self, task_name: str, timeout: int = 60) -> None: """Wait for task removal and verify it's applied.""" @@ -377,13 +461,15 @@ def _wait_and_verify_task_removal(self, task_name: str, timeout: int = 60) -> No task_view = TaskView() if not task_view.task_type_exists_in_db(task_name): - return # Task removed + return True # Task removed time.sleep(5) except Exception: time.sleep(5) - self.fail(f"Task {task_name} removal not detected within {timeout} seconds") + raise VerificationTimeout( + f"Task {task_name} was not removed within {timeout} seconds" + ) def test_1_adding_removing_slots_standalone_sample_position(self): """Test 1: Adding and removing slots on a standalone sample position/changing the numbers.""" @@ -428,7 +514,7 @@ def test_2_adding_removing_device_and_sample_position(self): time.sleep(5) # Wait for tasks to start # Wait for experiment to complete - self._wait_for_experiment_completion() + self._wait_for_experiment_completion(expected_tasks=3) # Add a new device (furnace_17) to the configuration add_device_line = 'add_device(Furnace(name="furnace_16"))' @@ -469,7 +555,7 @@ def test_3_adding_removing_whole_sample_position(self): time.sleep(5) # Wait for tasks to start # Wait for experiment to complete - self._wait_for_experiment_completion() + self._wait_for_experiment_completion(expected_tasks=3) # Add a new standalone sample position new_sample_position = """ @@ -536,7 +622,7 @@ def test_4_adding_removing_sample_position_prefix_in_device(self): time.sleep(5) # Wait for tasks to start # Wait for experiment to complete - self._wait_for_experiment_completion() + self._wait_for_experiment_completion(expected_tasks=3) # Add a new sample position to the furnace device new_position = """ @@ -602,7 +688,7 @@ def test_5_changing_numbers_in_sample_position_in_device(self): time.sleep(5) # Wait for tasks to start # Wait for experiment to complete - self._wait_for_experiment_completion() + self._wait_for_experiment_completion(expected_tasks=3) # Change the number of samples in the "inside" position from 8 to 4 modified_content = original_content.replace( @@ -636,8 +722,10 @@ def test_5_changing_numbers_in_sample_position_in_device(self): # Restore original content furnace_file.write_text(original_content) - def test_7_adding_new_task(self): - """Test 7: Adding a new task.""" + def test_6_adding_new_task(self): + """Test 6: Adding a new task.""" + print("=== Starting test_6_adding_new_task ===") + # Create a new task file new_task_file = Path("tests/fake_lab/tasks/test_task.py") new_task_content = '''import time @@ -673,44 +761,44 @@ def run(self): # Get the original fake_lab __init__.py content init_file = Path("tests/fake_lab/__init__.py") original_content = init_file.read_text() + print("=== Original content loaded ===") try: - # Submit an experiment to have tasks running + print("=== Submitting test experiment ===") resp = self._submit_test_experiment() + print(f"=== Response status: {resp.status_code} ===") self.assertEqual(resp.status_code, 200) + print("=== Test experiment submitted successfully ===") time.sleep(5) # Wait for tasks to start - # Wait for experiment to complete - self._wait_for_experiment_completion() + print("=== Waiting for experiment to complete ===") + self._wait_for_experiment_completion(expected_tasks=3) + print("=== Experiment completed ===") - # Create the new task file + print("=== Creating new task file ===") new_task_file.write_text(new_task_content) + print("=== New task file written ===") - # Add the task to the __init__.py file add_task_line = "add_task(TakePictureWithoutSpecifiedResult)" - - # Add import for the new task import_line = "from .tasks import (" new_import_line = "from .tasks import (\n TestTask," - # Also add the import to the tasks __init__.py file tasks_init_file = Path("tests/fake_lab/tasks/__init__.py") tasks_init_content = tasks_init_file.read_text() + print("=== Read tasks/__init__.py ===") - # Add the import if it doesn't already exist if "from .test_task import TestTask" not in tasks_init_content: tasks_init_content += "\nfrom .test_task import TestTask\n" tasks_init_file.write_text(tasks_init_content) + print("=== Added import to tasks/__init__.py ===") modified_content = original_content.replace( import_line, new_import_line ).replace(add_task_line, f"{add_task_line}\nadd_task(TestTask)") init_file.write_text(modified_content) - - # Wait for refresh cycle and verify task is added - self._wait_and_verify_task_addition("TestTask") - - # Test that the new task can be used in an experiment + print("=== Modified __init__.py with new task ===") + time.sleep(100) # wait for refresh cycle + print("=== Submitting experiment with new task ===") new_experiment = { "name": "Test new task experiment", "tags": [], @@ -737,22 +825,43 @@ def run(self): }, ], } + resp = requests.post( + "http://127.0.0.1:8897/api/experiment/submit", json=new_experiment + ) + print(f"=== Response status for new experiment: {resp.status_code} ===") + self.assertEqual(resp.status_code, 200) + print("=== New task experiment submitted successfully ===") + + print("=== Waiting for refresh cycle and verifying task addition ===") + self._wait_and_verify_task_addition("TestTask") + print("=== Task addition verified ===") resp = requests.post( "http://127.0.0.1:8897/api/experiment/submit", json=new_experiment ) + print(f"=== Response status for new experiment: {resp.status_code} ===") + self.assertEqual(resp.status_code, 200) + print("=== New task experiment submitted successfully ===") + + print( + "=== Submitting another test experiment to verify system is functional ===" + ) + resp = self._submit_test_experiment() + print( + f"=== Response status for final test experiment: {resp.status_code} ===" + ) self.assertEqual(resp.status_code, 200) + print("=== System functional after adding new task ===") - # Remove the task by restoring original content and deleting the file + finally: + print("=== Cleaning up and restoring original files ===") init_file.write_text(original_content) if new_task_file.exists(): new_task_file.unlink() - # Also remove the import from tasks __init__.py tasks_init_file = Path("tests/fake_lab/tasks/__init__.py") if tasks_init_file.exists(): tasks_init_content = tasks_init_file.read_text() - # Remove the TestTask import tasks_init_content = tasks_init_content.replace( "\nfrom .test_task import TestTask\n", "" ) @@ -760,32 +869,242 @@ def run(self): "from .test_task import TestTask\n", "" ) tasks_init_file.write_text(tasks_init_content) + print("=== Cleanup complete ===") - # Wait for refresh cycle and verify task is removed - self._wait_and_verify_task_removal("TestTask") + def test_7_adding_removing_new_device_class(self): + """Test 7: Adding and removing a new device class.""" + print("=== Starting test_7_adding_removing_new_device_class ===") - # Verify the system is still functional + # Create a new device file + new_device_file = Path("tests/fake_lab/devices/scale.py") + new_device_content = '''from typing import ClassVar + +from alab_management.device_view import BaseDevice +from alab_management.sample_view import SamplePosition + + +class Scale(BaseDevice): + """Fake scale device for auto refresh testing.""" + + description: ClassVar[str] = "Fake scale device" + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._is_running = False + + @property + def sample_positions(self): + return [ + SamplePosition( + "weighing_platform", + description="The platform where samples are placed for weighing", + number=4, # four samples can be weighed at the same time + ), + ] + + def emergent_stop(self): + pass + + def run_program(self, *args, **kwargs): + self._is_running = True + + def is_running(self): + return self._is_running + + def get_weight(self): + return 10.5 + + def connect(self): + pass + + def disconnect(self): + pass +''' + + # Get the original fake_lab __init__.py content + init_file = Path("tests/fake_lab/__init__.py") + original_content = init_file.read_text() + print("=== Original content loaded ===") + + try: + print("=== Submitting test experiment ===") resp = self._submit_test_experiment() + print(f"=== Response status: {resp.status_code} ===") self.assertEqual(resp.status_code, 200) + print("=== Test experiment submitted successfully ===") + time.sleep(5) # Wait for tasks to start - finally: - # Restore original content and clean up + print("=== Waiting for experiment to complete ===") + self._wait_for_experiment_completion(expected_tasks=3) + print("=== Experiment completed ===") + + print("=== Creating new device file ===") + new_device_file.write_text(new_device_content) + print("=== New device file written ===") + + # Update the devices/__init__.py to include the new Scale device + devices_init_file = Path("tests/fake_lab/devices/__init__.py") + devices_init_content = devices_init_file.read_text() + print("=== Read devices/__init__.py ===") + + if "from .scale import Scale" not in devices_init_content: + # Add import for Scale device + import_line = "from .robot_arm import RobotArm" + new_import_line = ( + "from .robot_arm import RobotArm\nfrom .scale import Scale" + ) + devices_init_content = devices_init_content.replace( + import_line, new_import_line + ) + + # Add Scale to the __all__ list if it exists + if "__all__" in devices_init_content: + all_line = "__all__ = [" + new_all_line = "__all__ = [\n Scale," + devices_init_content = devices_init_content.replace( + all_line, new_all_line + ) + + devices_init_file.write_text(devices_init_content) + print("=== Added Scale import to devices/__init__.py ===") + + # Add the Scale device to the main __init__.py + # First, add Scale to the import statement + import_line = "from .devices import (" + new_import_line = "from .devices import (\n Scale," + modified_content = original_content.replace(import_line, new_import_line) + + # Then add the device registration + add_device_line = ( + 'add_device(DeviceThatRunSlow(name="device_that_run_slow_2"))' + ) + new_device_line = 'add_device(Scale(name="scale_1"))' + + modified_content = modified_content.replace( + add_device_line, + f"{add_device_line}\n{new_device_line}", + ) + init_file.write_text(modified_content) + print("=== Modified __init__.py with new Scale device ===") + time.sleep(100) # wait for refresh cycle + # Wait for refresh cycle and verify device is added + print( + "=== Waiting for refresh cycle and verifying Scale device addition ===" + ) + self._wait_and_verify_device_addition("scale_1") + print("=== Scale device addition verified ===") + + # Verify the device sample position was also added + print("=== Verifying Scale device sample position addition ===") + self._wait_and_verify_device_sample_position_addition( + "scale_1", "weighing_platform", 4 + ) + print("=== Scale device sample position addition verified ===") + + # Submit an experiment that uses the new Scale device + print("=== Submitting experiment that uses the new Scale device ===") + scale_experiment = { + "name": "Scale device test experiment", + "tags": [], + "metadata": {}, + "samples": [{"name": "scale_test_sample", "tags": [], "metadata": {}}], + "tasks": [ + { + "type": "Starting", + "prev_tasks": [], + "parameters": {"dest": "furnace_temp"}, + "samples": ["scale_test_sample"], + }, + { + "type": "Moving", + "prev_tasks": [0], + "parameters": {"dest": "scale_1/weighing_platform"}, + "samples": ["scale_test_sample"], + }, + { + "type": "Ending", + "prev_tasks": [1], + "parameters": {}, + "samples": ["scale_test_sample"], + }, + ], + } + resp = requests.post( + "http://127.0.0.1:8897/api/experiment/submit", json=scale_experiment + ) + print(f"=== Response status for Scale experiment: {resp.status_code} ===") + self.assertEqual(resp.status_code, 200) + print("=== Scale experiment submitted successfully ===") + + # Wait for the Scale experiment to complete + print("=== Waiting for Scale experiment to complete ===") + self._wait_for_experiment_completion(expected_tasks=6) + print("=== Scale experiment completed ===") + + # Remove the device by restoring original content + print("=== Removing Scale device by restoring original content ===") init_file.write_text(original_content) - if new_task_file.exists(): - new_task_file.unlink() + print("=== Original content restored ===") - # Also remove the import from tasks __init__.py in cleanup - tasks_init_file = Path("tests/fake_lab/tasks/__init__.py") - if tasks_init_file.exists(): - tasks_init_content = tasks_init_file.read_text() - # Remove the TestTask import - tasks_init_content = tasks_init_content.replace( - "\nfrom .test_task import TestTask\n", "" + # Wait for refresh cycle and verify device is removed + print( + "=== Waiting for refresh cycle and verifying Scale device removal ===" + ) + self._wait_and_verify_device_removal("scale_1") + print("=== Scale device removal verified ===") + + # Verify the device sample position was also removed + print("=== Verifying Scale device sample position removal ===") + self._wait_and_verify_device_sample_position_removal( + "scale_1", "weighing_platform" + ) + print("=== Scale device sample position removal verified ===") + + # Verify the system is still functional + print( + "=== Submitting test experiment to verify system is still functional ===" + ) + resp = self._submit_test_experiment() + print( + f"=== Response status for final test experiment: {resp.status_code} ===" + ) + self.assertEqual(resp.status_code, 200) + print("=== System functional after adding/removing new device class ===") + + finally: + print("=== Cleaning up and restoring original files ===") + + # Delete the Scale device file + if new_device_file.exists(): + new_device_file.unlink() + print("=== Deleted scale.py file ===") + + # Restore devices/__init__.py + devices_init_file = Path("tests/fake_lab/devices/__init__.py") + if devices_init_file.exists(): + devices_init_content = devices_init_file.read_text() + devices_init_content = devices_init_content.replace( + "\nfrom .scale import Scale", "" ) - tasks_init_content = tasks_init_content.replace( - "from .test_task import TestTask\n", "" + devices_init_content = devices_init_content.replace( + "from .scale import Scale\n", "" ) - tasks_init_file.write_text(tasks_init_content) + devices_init_content = devices_init_content.replace("\n Scale,", "") + devices_init_file.write_text(devices_init_content) + print("=== Restored devices/__init__.py ===") + + # Restore main __init__.py - remove Scale from imports and device registration + init_content = init_file.read_text() + # Remove Scale from the import statement + init_content = init_content.replace("\n Scale,", "") + # Remove the Scale device registration + init_content = init_content.replace( + '\nadd_device(Scale(name="scale_1"))', "" + ) + init_file.write_text(init_content) + print("=== Restored main __init__.py ===") + + print("=== Cleanup complete ===") if __name__ == "__main__": From e0237b85fbb9bb2e8cc6b48e587296e392e9f9ea Mon Sep 17 00:00:00 2001 From: Bernardus Rendy <37468936+bernardusrendy@users.noreply.github.com> Date: Sun, 13 Jul 2025 17:23:26 -0700 Subject: [PATCH 06/11] Update the auto refresh documentation to reflect the new reimport scheme for new classes of tasks and devices --- docs/source/auto_refresh.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/source/auto_refresh.md b/docs/source/auto_refresh.md index c9c701da..409c8696 100644 --- a/docs/source/auto_refresh.md +++ b/docs/source/auto_refresh.md @@ -33,7 +33,7 @@ the device and task manager will be re-imported. It is a process with the follow the device and task definition will be re-imported. This means that any changes made to the task definition or device code will be reflected in the task execution. If `auto_refresh` is not enabled or missing in the configuration, the task actor will not be re-imported, and any changes made to the task definition or device -code will not be reflected in the task execution. +code will not be reflected in the task execution. The re-import is done through the lowest level first (modules .py files) up to the highest level (__init__.py in the working directory). Note that the change will be reflected in the newly launched tasks right away, at the importing time. But for the device and task managers, it will check the file modification time every 30 s. This usually will not be From 279f0fe68141e63811b32930c2d5ddc7a05e9d64 Mon Sep 17 00:00:00 2001 From: Bernardus Rendy <37468936+bernardusrendy@users.noreply.github.com> Date: Sun, 13 Jul 2025 17:25:53 -0700 Subject: [PATCH 07/11] Updated documentation to be precise that the definitions are reloaded in the managers --- docs/source/auto_refresh.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/source/auto_refresh.md b/docs/source/auto_refresh.md index 409c8696..db83d22c 100644 --- a/docs/source/auto_refresh.md +++ b/docs/source/auto_refresh.md @@ -30,7 +30,7 @@ the device and task manager will be re-imported. It is a process with the follow 10. Task manager will resume to launch new tasks. **Task Actor**: Task actor is the function that actually runs the task. At the beginning of each task process, -the device and task definition will be re-imported. This means that any changes made to the task definition +the device and task definition will be re-imported in all managers (task, device, and experiment managers). This means that any changes made to the task definition or device code will be reflected in the task execution. If `auto_refresh` is not enabled or missing in the configuration, the task actor will not be re-imported, and any changes made to the task definition or device code will not be reflected in the task execution. The re-import is done through the lowest level first (modules .py files) up to the highest level (__init__.py in the working directory). From d8494f48de620d6fe38e6a3b111de8ada2a436e8 Mon Sep 17 00:00:00 2001 From: Bernardus Rendy <37468936+bernardusrendy@users.noreply.github.com> Date: Sun, 13 Jul 2025 18:02:48 -0700 Subject: [PATCH 08/11] fix flake8 error --- alab_management/device_manager.py | 57 ++++++++++++++++++--------- alab_management/sample_view/sample.py | 3 +- alab_management/scripts/setup_lab.py | 15 ++++--- 3 files changed, 50 insertions(+), 25 deletions(-) diff --git a/alab_management/device_manager.py b/alab_management/device_manager.py index 68769525..6019e8d9 100644 --- a/alab_management/device_manager.py +++ b/alab_management/device_manager.py @@ -152,16 +152,20 @@ def __init__(self, _check_status: bool = True): # (e.g., "Filled_vial_rack"), not the full name (Filled_vial_rack/1) self.sample_positions_in_devices_to_be_updated = ( [] - ) # this will be pooled by a dict[str,SamplePosition] where the key is the device name and the value is the sample position object + ) # this will be pooled by a dict[str,SamplePosition] where the key is the device name + # and the value is a list of sample position objects self.sample_positions_in_devices_to_be_updated_that_is_paused = ( [] - ) # this will be pooled by a dict[str,SamplePosition] where the key is the device name and the value is the sample position object + ) # this will be pooled by a dict[str,SamplePosition] where the key is the device name + # and the value is the sample position object self.sample_positions_in_devices_to_be_removed = ( [] - ) # this will be pooled by a dict[str,SamplePosition] where the key is the device name and the value is the sample position object + ) # this will be pooled by a dict[str,SamplePosition] where the key is the device name + # and the value is the sample position object self.sample_positions_in_devices_to_be_removed_that_is_paused = ( [] - ) # this will be pooled by a dict[str,SamplePosition] where the key is the device name and the value is the sample position object + ) # this will be pooled by a dict[str,SamplePosition] where the key is the device name + # and the value is the sample position object self.sample_positions_to_be_updated_or_removed_thread = Thread( target=self._check_sample_positions_status_and_update_or_remove_if_not_occupied ) @@ -234,7 +238,8 @@ def _check_device_status_and_remove_if_not_occupied(self): device_name ): print( - f"Device {device_name} is not occupied and has no samples on it, pausing it and then removing it from the device view." + f"Device {device_name} is not occupied and has no samples on it, " + "pausing it and then removing it from the device view." ) self._device_view.pause_device(device_name) self.device_names_to_be_removed_that_is_paused.append(device_name) @@ -248,7 +253,8 @@ def _check_device_status_and_remove_if_not_occupied(self): ) else: print( - f"Device {device_name} is in status {device_entry['status']}, skipping and waiting for it to be unoccupied." + f"Device {device_name} is in status {device_entry['status']}, " + "skipping and waiting for it to be unoccupied." ) for device_name in self.device_names_to_be_removed_that_is_paused: device_entry: dict[str, Any] | None = self._device_view.get_device( @@ -260,7 +266,8 @@ def _check_device_status_and_remove_if_not_occupied(self): and not self._sample_view.get_samples_on_device(device_name) ): print( - f"Device {device_name} is not occupied, paused, and has no samples on it, removing it from the device view." + f"Device {device_name} is not occupied, paused, and has no samples on it, " + "removing it from the device view." ) self._device_view.remove_device(device_name) # also remove the sample positions that are related to the device @@ -276,7 +283,8 @@ def _check_device_status_and_remove_if_not_occupied(self): self._device_view.pause_device(device_name) else: print( - f"Device {device_name} is in status {device_entry['status']}, skipping and waiting for it to be unoccupied." + f"Device {device_name} is in status {device_entry['status']}, " + "skipping and waiting for it to be unoccupied." ) self._device_view.pause_device(device_name) @@ -374,10 +382,12 @@ def _check_sample_positions_status_and_update_or_remove_if_not_occupied(self): device_entry: dict[str, Any] | None = ( self._device_view.get_device(device_name) ) - # check if the device is not occupied, and sample positions that are related to the device are also not occupied + # check if the device is not occupied, + # and sample positions that are related to the device are also not occupied if device_entry["status"] == DeviceTaskStatus.IDLE.name: print( - f"Device {device_name} is not occupied and has no samples on it, pausing it to update the sample positions." + f"Device {device_name} is not occupied and has no samples on it, " + "pausing it to update the sample positions." ) self._device_view.pause_device(device_name) self.sample_positions_in_devices_to_be_updated_that_is_paused.append( @@ -394,7 +404,8 @@ def _check_sample_positions_status_and_update_or_remove_if_not_occupied(self): self._device_view.pause_device(device_name) else: print( - f"Device {device_name} is in status {device_entry['status']}, skipping and waiting for it to be unoccupied." + f"Device {device_name} is in status {device_entry['status']}, " + "skipping and waiting for it to be unoccupied." ) if processed: self.sample_positions_in_devices_to_be_updated.remove( @@ -428,7 +439,8 @@ def _check_sample_positions_status_and_update_or_remove_if_not_occupied(self): == DevicePauseStatus.PAUSED.name ): print( - f"Device {device_name} is not occupied, paused, and has no samples on it, updating the sample positions." + f"Device {device_name} is not occupied, paused, " + "and has no samples on it, updating the sample positions." ) for sample_position_object in device_sample_position_dict[ device_name @@ -450,12 +462,14 @@ def _check_sample_positions_status_and_update_or_remove_if_not_occupied(self): ) elif device_entry["status"] == DeviceTaskStatus.OCCUPIED.name: print( - f"Device {device_name} is occupied, skipping and waiting for it to be unoccupied." + f"Device {device_name} is occupied, skipping " + "and waiting for it to be unoccupied." ) self._device_view.pause_device(device_name) else: print( - f"Device {device_name} is in status {device_entry['status']}, skipping and waiting for it to be unoccupied." + f"Device {device_name} is in status {device_entry['status']}, " + "skipping and waiting for it to be unoccupied." ) self._device_view.pause_device(device_name) if processed: @@ -487,10 +501,12 @@ def _check_sample_positions_status_and_update_or_remove_if_not_occupied(self): device_entry: dict[str, Any] | None = ( self._device_view.get_device(device_name) ) - # check if the device is not occupied, and sample positions that are related to the device are also not occupied + # check if the device is not occupied, + # and sample positions that are related to the device are also not occupied if device_entry["status"] == DeviceTaskStatus.IDLE.name: print( - f"Device {device_name} is not occupied and has no samples on it, pausing it to update the sample positions." + f"Device {device_name} is not occupied and has no samples on it, " + "pausing it to update the sample positions." ) self._device_view.pause_device(device_name) self.sample_positions_in_devices_to_be_removed_that_is_paused.append( @@ -507,7 +523,8 @@ def _check_sample_positions_status_and_update_or_remove_if_not_occupied(self): self._device_view.pause_device(device_name) else: print( - f"Device {device_name} is in status {device_entry['status']}, skipping and waiting for it to be unoccupied." + f"Device {device_name} is in status {device_entry['status']}, " + "skipping and waiting for it to be unoccupied." ) if processed: self.sample_positions_in_devices_to_be_removed.remove( @@ -540,7 +557,8 @@ def _check_sample_positions_status_and_update_or_remove_if_not_occupied(self): == DevicePauseStatus.PAUSED.name ): print( - f"Device {device_name} is not occupied, paused, and has no samples on it, updating the sample positions." + f"Device {device_name} is not occupied, paused, " + "and has no samples on it, updating the sample positions." ) for sample_position_object in device_sample_position_dict[ device_name @@ -559,7 +577,8 @@ def _check_sample_positions_status_and_update_or_remove_if_not_occupied(self): self._device_view.pause_device(device_name) else: print( - f"Device {device_name} is in status {device_entry['status']}, skipping and waiting for it to be unoccupied." + f"Device {device_name} is in status {device_entry['status']}, " + "skipping and waiting for it to be unoccupied." ) self._device_view.pause_device(device_name) if processed: diff --git a/alab_management/sample_view/sample.py b/alab_management/sample_view/sample.py index d3a2ab2f..7414db24 100644 --- a/alab_management/sample_view/sample.py +++ b/alab_management/sample_view/sample.py @@ -60,7 +60,8 @@ def __post_init__(self): # it is only pooled with new sample positions that are added to the lab during reload. _standalone_sample_position_registry: dict[str, SamplePosition] = {} -# _current_standalone_sample_position_registry is used to store all the standalone sample positions that are defined in the __init__.py +# _current_standalone_sample_position_registry is used to store +# all the standalone sample positions that are defined in the __init__.py # when alabos setup is called, regardless of whether it is a reload or not. # this is used to check if a sample position is still in the lab during reload. # if not, it will be removed from the lab once unoccupied and devices that are related to it are also not occupied. diff --git a/alab_management/scripts/setup_lab.py b/alab_management/scripts/setup_lab.py index e93795af..16758650 100644 --- a/alab_management/scripts/setup_lab.py +++ b/alab_management/scripts/setup_lab.py @@ -88,7 +88,8 @@ def setup_lab( # because the device documents were not yet created within the Device collection. print(f"Devices added to the db and registry: {filtered_devices}") - # step 7: get all the standalone sample positions from the __init__.py and the ones that are currently active in the lab [current state] + # step 7: get all the standalone sample positions from the __init__.py + # and the ones that are currently active in the lab [current state] sample_view = SampleView() # get the standalone sample positions in the registry # note that a SamplePosition object consist of a name and a number @@ -116,7 +117,8 @@ def setup_lab( sample_positions=sample_positions, parent_device_name=None ) # TODO: calculate positive delta of sample positions to print out - # step 10: remove prefix of sample positions names that are not in the lab anymore [calculate negative delta of prefix of sample positions] + # step 10: remove prefix of sample positions names that are not in the lab anymore + # [calculate negative delta of prefix of sample positions] removed_sample_positions_prefixes = [] for sample_position_name in sample_positions_names: if sample_position_name not in current_sample_positions_names: @@ -130,7 +132,8 @@ def setup_lab( # than the ones in the sample_positions sample_positions_before_reload = list( sample_positions_dict_before_reload.values() - ) # all sample positions information not including __init__.py. This is used to check if there is any negative delta of sample positions numbers + ) # all sample positions information not including __init__.py. + # This is used to check if there is any negative delta of sample positions numbers sample_positions_names_before_reload = [ sample_position.name for sample_position in sample_positions_before_reload ] @@ -164,7 +167,8 @@ def setup_lab( sample_positions=device.sample_positions, parent_device_name=device.name ) - # step 13: find the current sample positions in devices that are not in the db [calculate negative delta of device sample positions] + # step 13: find the current sample positions in devices that are not in the db + # [calculate negative delta of device sample positions] # positive is already handled in step 12, so we only need to calculate negative delta to be handled by device manager # once the devices are not used anymore devices_sample_positions_before_reload = { @@ -193,7 +197,8 @@ def setup_lab( ) print(f"Removed sample positions in devices: {removed_sample_positions_in_devices}") - # step 14: check if there is any update of sample positions from the devices [calculate negative delta of sample positions numbers] + # step 14: check if there is any update of sample positions from the devices + # [calculate negative delta of sample positions numbers] # positive is already handled in step 12, so we only need to calculate negative delta to be handled by device manager # once the devices are not used anymore updated_sample_positions_in_devices = {} From 9e983eeba2be97ae9b5573f23b19549f798f176c Mon Sep 17 00:00:00 2001 From: Bernardus Rendy <37468936+bernardusrendy@users.noreply.github.com> Date: Sun, 13 Jul 2025 19:11:18 -0700 Subject: [PATCH 09/11] Fix pylint --- .pylintrc | 2 +- alab_management/device_manager.py | 548 +++++++++++---------- alab_management/device_view/device_view.py | 2 +- alab_management/sample_view/sample_view.py | 2 +- alab_management/scripts/setup_lab.py | 135 +++-- alab_management/utils/module_ops.py | 83 ++-- 6 files changed, 430 insertions(+), 342 deletions(-) diff --git a/.pylintrc b/.pylintrc index 9933ab76..3e0fda0e 100644 --- a/.pylintrc +++ b/.pylintrc @@ -141,7 +141,7 @@ disable=fixme, ; xreadlines-attribute, missing-timeout, broad-exception-caught, - too-many-positional-arguments, + too-many-arguments, broad-exception-raised, logging-fstring-interpolation, global-statement, diff --git a/alab_management/device_manager.py b/alab_management/device_manager.py index 6019e8d9..bb793e4a 100644 --- a/alab_management/device_manager.py +++ b/alab_management/device_manager.py @@ -288,303 +288,325 @@ def _check_device_status_and_remove_if_not_occupied(self): ) self._device_view.pause_device(device_name) - def _check_sample_positions_status_and_update_or_remove_if_not_occupied(self): - """Check the sample positions status and remove it if it is not occupied.""" - while True: - time.sleep(10) - for sample_position_object in self.sample_positions_objects_to_be_updated: - prefix = sample_position_object.name - sample_positions_full_names = ( - self._sample_view.get_sample_positions_names_by_prefix(prefix) + def _get_sample_positions_status(self, sample_positions_names): + """Get the status of sample positions.""" + sample_positions_status = [] + for sample_position_name in sample_positions_names: + sample_positions_status.append( + self._sample_view.get_sample_position_status(sample_position_name)[0] + ) + return sample_positions_status + + def _handle_sample_position_update(self, sample_position_object): + """Handle updating a sample position.""" + prefix = sample_position_object.name + sample_positions_full_names = ( + self._sample_view.get_sample_positions_names_by_prefix(prefix) + ) + sample_positions_status = self._get_sample_positions_status( + sample_positions_full_names + ) + + if all( + status == SamplePositionStatus.EMPTY for status in sample_positions_status + ): + print( + f"Sample position with prefix {prefix} is empty, removing it from the sample view and \ + adding it back with the new number of slots." + ) + self._sample_view.remove_sample_position_by_prefix(prefix) + self._sample_view.add_sample_positions_to_db( + sample_positions=[sample_position_object], + parent_device_name=None, + ) + self.sample_positions_objects_to_be_updated.remove(sample_position_object) + print( + f"Sample position with prefix {prefix} has been updated in the sample view." + ) + else: + print( + f"Sample position with prefix {prefix} is not empty, " + f"current status of some sample positions: {sample_positions_status}," + "skipping and waiting for it to be empty." + ) + + def _handle_sample_position_removal(self, prefix): + """Handle removing a sample position.""" + sample_positions_full_names = ( + self._sample_view.get_sample_positions_names_by_prefix(prefix) + ) + sample_positions_status = self._get_sample_positions_status( + sample_positions_full_names + ) + + if all( + status == SamplePositionStatus.EMPTY for status in sample_positions_status + ): + print( + f"Sample position with prefix {prefix} is empty, removing it from the sample view." + ) + self._sample_view.remove_sample_position_by_prefix(prefix) + self.sample_positions_prefixes_to_be_removed.remove(prefix) + print( + f"Sample position with prefix {prefix} has been removed from the sample view." + ) + else: + print( + f"Sample position with prefix {prefix} is not empty, " + f"current status of some sample positions: {sample_positions_status}, " + "skipping and waiting for it to be empty." + ) + + def _handle_device_sample_position_update(self, device_sample_position_dict): + """Handle updating sample positions in devices.""" + processed = False + for device_name in list(device_sample_position_dict.keys()): + sample_positions_names_in_device = ( + self._sample_view.get_sample_positions_names_by_device(device_name) + ) + sample_positions_status = self._get_sample_positions_status( + sample_positions_names_in_device + ) + + if all( + status == SamplePositionStatus.EMPTY + for status in sample_positions_status + ): + device_entry: dict[str, Any] | None = self._device_view.get_device( + device_name ) - sample_positions_status = [] - for sample_position_full_name in sample_positions_full_names: - sample_positions_status.append( - self._sample_view.get_sample_position_status( - sample_position_full_name - )[0] - ) - if all( - status == SamplePositionStatus.EMPTY - for status in sample_positions_status - ): + + if device_entry["status"] == DeviceTaskStatus.IDLE.name: print( - f"Sample position with prefix {prefix} is empty, removing it from the sample view and \ - adding it back with the new number of slots." + f"Device {device_name} is not occupied and has no samples on it, " + "pausing it to update the sample positions." ) - self._sample_view.remove_sample_position_by_prefix(prefix) - self._sample_view.add_sample_positions_to_db( - sample_positions=[sample_position_object], - parent_device_name=None, + self._device_view.pause_device(device_name) + self.sample_positions_in_devices_to_be_updated_that_is_paused.append( + device_sample_position_dict ) - self.sample_positions_objects_to_be_updated.remove( - sample_position_object + processed = True + print( + f"Device {device_name} has been paused to update the sample positions." ) + elif device_entry["status"] == DeviceTaskStatus.OCCUPIED.name: print( - f"Sample position with prefix {prefix} has been updated in the sample view." + f"Device {device_name} is occupied, skipping and waiting for it to be unoccupied." ) + self._device_view.pause_device(device_name) else: print( - f"Sample position with prefix {prefix} is not empty, current status of some sample positions: {sample_positions_status}, \ - skipping and waiting for it to be empty." - ) - for prefix in self.sample_positions_prefixes_to_be_removed: - sample_positions_full_names = ( - self._sample_view.get_sample_positions_names_by_prefix(prefix) - ) - sample_positions_status = [] - for sample_position_full_name in sample_positions_full_names: - sample_positions_status.append( - self._sample_view.get_sample_position_status( - sample_position_full_name - )[0] + f"Device {device_name} is in status {device_entry['status']}, " + "skipping and waiting for it to be unoccupied." ) - if all( - status == SamplePositionStatus.EMPTY - for status in sample_positions_status + + if processed: + self.sample_positions_in_devices_to_be_updated.remove( + device_sample_position_dict + ) + + def _handle_device_sample_position_update_paused(self, device_sample_position_dict): + """Handle updating sample positions in paused devices.""" + processed = False + for device_name in list(device_sample_position_dict.keys()): + sample_positions_names_in_device = ( + self._sample_view.get_sample_positions_names_by_device(device_name) + ) + sample_positions_status = self._get_sample_positions_status( + sample_positions_names_in_device + ) + + if all( + status == SamplePositionStatus.EMPTY + for status in sample_positions_status + ): + device_entry = self._device_view.get_device(device_name) + + if ( + device_entry["status"] == DeviceTaskStatus.IDLE.name + and device_entry["pause_status"] == DevicePauseStatus.PAUSED.name ): print( - f"Sample position with prefix {prefix} is empty, removing it from the sample view." + f"Device {device_name} is not occupied, paused, " + "and has no samples on it, updating the sample positions." + ) + for sample_position_object in device_sample_position_dict[ + device_name + ]: + self._sample_view.remove_sample_position_by_prefix( + f"{device_name}{sample_position_object.SEPARATOR}{sample_position_object.name}" + ) + # add the sample positions back to the device + for sample_position_object in device_sample_position_dict[ + device_name + ]: + self._sample_view.add_sample_positions_to_db( + sample_positions=[sample_position_object], + parent_device_name=device_name, + ) + processed = True + print( + f"Sample positions in device {device_name} have been updated." ) - self._sample_view.remove_sample_position_by_prefix(prefix) - self.sample_positions_prefixes_to_be_removed.remove(prefix) + elif device_entry["status"] == DeviceTaskStatus.OCCUPIED.name: print( - f"Sample position with prefix {prefix} has been removed from the sample view." + f"Device {device_name} is occupied, skipping " + "and waiting for it to be unoccupied." ) + self._device_view.pause_device(device_name) else: print( - f"Sample position with prefix {prefix} is not empty, current status of some sample positions: {sample_positions_status}, \ - skipping and waiting for it to be empty." + f"Device {device_name} is in status {device_entry['status']}, " + "skipping and waiting for it to be unoccupied." ) + self._device_view.pause_device(device_name) - for ( + if processed: + self.sample_positions_in_devices_to_be_updated_that_is_paused.remove( device_sample_position_dict - ) in self.sample_positions_in_devices_to_be_updated: - processed = False - for device_name in list(device_sample_position_dict.keys()): - sample_positions_names_in_device = ( - self._sample_view.get_sample_positions_names_by_device( - device_name - ) + ) + + def _handle_device_sample_position_removal(self, device_sample_position_dict): + """Handle removing sample positions from devices.""" + processed = False + for device_name in list(device_sample_position_dict.keys()): + sample_positions_names_in_device = ( + self._sample_view.get_sample_positions_names_by_device(device_name) + ) + sample_positions_status = self._get_sample_positions_status( + sample_positions_names_in_device + ) + + if all( + status == SamplePositionStatus.EMPTY + for status in sample_positions_status + ): + device_entry = self._device_view.get_device(device_name) + + if device_entry["status"] == DeviceTaskStatus.IDLE.name: + print( + f"Device {device_name} is not occupied and has no samples on it, " + "pausing it to remove the sample positions." ) - sample_positions_status = [] - for sample_position_name in sample_positions_names_in_device: - sample_positions_status.append( - self._sample_view.get_sample_position_status( - sample_position_name - )[0] - ) - if all( - status == SamplePositionStatus.EMPTY - for status in sample_positions_status - ): - # pause the device - device_entry: dict[str, Any] | None = ( - self._device_view.get_device(device_name) - ) - # check if the device is not occupied, - # and sample positions that are related to the device are also not occupied - if device_entry["status"] == DeviceTaskStatus.IDLE.name: - print( - f"Device {device_name} is not occupied and has no samples on it, " - "pausing it to update the sample positions." - ) - self._device_view.pause_device(device_name) - self.sample_positions_in_devices_to_be_updated_that_is_paused.append( - device_sample_position_dict - ) - processed = True - print( - f"Device {device_name} has been paused to update the sample positions." - ) - elif device_entry["status"] == DeviceTaskStatus.OCCUPIED.name: - print( - f"Device {device_name} is occupied, skipping and waiting for it to be unoccupied." - ) - self._device_view.pause_device(device_name) - else: - print( - f"Device {device_name} is in status {device_entry['status']}, " - "skipping and waiting for it to be unoccupied." - ) - if processed: - self.sample_positions_in_devices_to_be_updated.remove( + self._device_view.pause_device(device_name) + self.sample_positions_in_devices_to_be_removed_that_is_paused.append( device_sample_position_dict ) + processed = True + print( + f"Device {device_name} has been paused to remove the sample positions." + ) + elif device_entry["status"] == DeviceTaskStatus.OCCUPIED.name: + print( + f"Device {device_name} is occupied, skipping and waiting for it to be unoccupied." + ) + self._device_view.pause_device(device_name) + else: + print( + f"Device {device_name} is in status {device_entry['status']}, " + "skipping and waiting for it to be unoccupied." + ) - for ( + if processed: + self.sample_positions_in_devices_to_be_removed.remove( device_sample_position_dict - ) in self.sample_positions_in_devices_to_be_updated_that_is_paused: - processed = False - for device_name in list(device_sample_position_dict.keys()): - sample_positions_names_in_device = ( - self._sample_view.get_sample_positions_names_by_device( - device_name - ) + ) + + def _handle_device_sample_position_removal_paused( + self, device_sample_position_dict + ): + """Handle removing sample positions from paused devices.""" + processed = False + for device_name in list(device_sample_position_dict.keys()): + sample_positions_names_in_device = ( + self._sample_view.get_sample_positions_names_by_device(device_name) + ) + sample_positions_status = self._get_sample_positions_status( + sample_positions_names_in_device + ) + + if all( + status == SamplePositionStatus.EMPTY + for status in sample_positions_status + ): + device_entry = self._device_view.get_device(device_name) + + if ( + device_entry["status"] == DeviceTaskStatus.IDLE.name + and device_entry["pause_status"] == DevicePauseStatus.PAUSED.name + ): + print( + f"Device {device_name} is not occupied, paused, " + "and has no samples on it, removing the sample positions." ) - sample_positions_status = [] - for sample_position_name in sample_positions_names_in_device: - sample_positions_status.append( - self._sample_view.get_sample_position_status( - sample_position_name - )[0] + for sample_position_object in device_sample_position_dict[ + device_name + ]: + self._sample_view.remove_sample_position_by_prefix( + f"{device_name}{sample_position_object.SEPARATOR}{sample_position_object.name}" ) - if all( - status == SamplePositionStatus.EMPTY - for status in sample_positions_status - ): - if ( - device_entry["status"] == DeviceTaskStatus.IDLE.name - and device_entry["pause_status"] - == DevicePauseStatus.PAUSED.name - ): - print( - f"Device {device_name} is not occupied, paused, " - "and has no samples on it, updating the sample positions." - ) - for sample_position_object in device_sample_position_dict[ - device_name - ]: - self._sample_view.remove_sample_position_by_prefix( - f"{device_name}{sample_position_object.SEPARATOR}{sample_position_object.name}" - ) - # add the sample positions back to the device - for sample_position_object in device_sample_position_dict[ - device_name - ]: - self._sample_view.add_sample_positions_to_db( - sample_positions=[sample_position_object], - parent_device_name=device_name, - ) - processed = True - print( - f"Sample positions in device {device_name} have been updated." - ) - elif device_entry["status"] == DeviceTaskStatus.OCCUPIED.name: - print( - f"Device {device_name} is occupied, skipping " - "and waiting for it to be unoccupied." - ) - self._device_view.pause_device(device_name) - else: - print( - f"Device {device_name} is in status {device_entry['status']}, " - "skipping and waiting for it to be unoccupied." - ) - self._device_view.pause_device(device_name) - if processed: - self.sample_positions_in_devices_to_be_updated_that_is_paused.remove( - device_sample_position_dict + processed = True + print( + f"Sample positions in device {device_name} have been removed." + ) + elif device_entry["status"] == DeviceTaskStatus.OCCUPIED.name: + print( + f"Device {device_name} is occupied, skipping " + "and waiting for it to be unoccupied." + ) + self._device_view.pause_device(device_name) + else: + print( + f"Device {device_name} is in status {device_entry['status']}, " + "skipping and waiting for it to be unoccupied." ) + self._device_view.pause_device(device_name) + + if processed: + self.sample_positions_in_devices_to_be_removed_that_is_paused.remove( + device_sample_position_dict + ) + + def _check_sample_positions_status_and_update_or_remove_if_not_occupied(self): + """Check the sample positions status and remove it if it is not occupied.""" + while True: + time.sleep(10) + + # Handle standalone sample position updates + for sample_position_object in self.sample_positions_objects_to_be_updated: + self._handle_sample_position_update(sample_position_object) + + # Handle standalone sample position removals + for prefix in self.sample_positions_prefixes_to_be_removed: + self._handle_sample_position_removal(prefix) + + # Handle device sample position updates + for ( + device_sample_position_dict + ) in self.sample_positions_in_devices_to_be_updated: + self._handle_device_sample_position_update(device_sample_position_dict) + + # Handle paused device sample position updates + for ( + device_sample_position_dict + ) in self.sample_positions_in_devices_to_be_updated_that_is_paused: + self._handle_device_sample_position_update_paused( + device_sample_position_dict + ) + + # Handle device sample position removals for ( device_sample_position_dict ) in self.sample_positions_in_devices_to_be_removed: - processed = False - for device_name in list(device_sample_position_dict.keys()): - sample_positions_names_in_device = ( - self._sample_view.get_sample_positions_names_by_device( - device_name - ) - ) - sample_positions_status = [] - for sample_position_name in sample_positions_names_in_device: - sample_positions_status.append( - self._sample_view.get_sample_position_status( - sample_position_name - )[0] - ) - if all( - status == SamplePositionStatus.EMPTY - for status in sample_positions_status - ): - # pause the device - device_entry: dict[str, Any] | None = ( - self._device_view.get_device(device_name) - ) - # check if the device is not occupied, - # and sample positions that are related to the device are also not occupied - if device_entry["status"] == DeviceTaskStatus.IDLE.name: - print( - f"Device {device_name} is not occupied and has no samples on it, " - "pausing it to update the sample positions." - ) - self._device_view.pause_device(device_name) - self.sample_positions_in_devices_to_be_removed_that_is_paused.append( - device_sample_position_dict - ) - processed = True - print( - f"Device {device_name} has been paused to update the sample positions." - ) - elif device_entry["status"] == DeviceTaskStatus.OCCUPIED.name: - print( - f"Device {device_name} is occupied, skipping and waiting for it to be unoccupied." - ) - self._device_view.pause_device(device_name) - else: - print( - f"Device {device_name} is in status {device_entry['status']}, " - "skipping and waiting for it to be unoccupied." - ) - if processed: - self.sample_positions_in_devices_to_be_removed.remove( - device_sample_position_dict - ) + self._handle_device_sample_position_removal(device_sample_position_dict) + + # Handle paused device sample position removals for ( device_sample_position_dict ) in self.sample_positions_in_devices_to_be_removed_that_is_paused: - processed = False - for device_name in list(device_sample_position_dict.keys()): - sample_positions_names_in_device = ( - self._sample_view.get_sample_positions_names_by_device( - device_name - ) - ) - sample_positions_status = [] - for sample_position_name in sample_positions_names_in_device: - sample_positions_status.append( - self._sample_view.get_sample_position_status( - sample_position_name - )[0] - ) - if all( - status == SamplePositionStatus.EMPTY - for status in sample_positions_status - ): - if ( - device_entry["status"] == DeviceTaskStatus.IDLE.name - and device_entry["pause_status"] - == DevicePauseStatus.PAUSED.name - ): - print( - f"Device {device_name} is not occupied, paused, " - "and has no samples on it, updating the sample positions." - ) - for sample_position_object in device_sample_position_dict[ - device_name - ]: - self._sample_view.remove_sample_position_by_prefix( - f"{device_name}{sample_position_object.SEPARATOR}{sample_position_object.name}" - ) - processed = True - print( - f"Sample positions in device {device_name} have been updated." - ) - elif device_entry["status"] == DeviceTaskStatus.OCCUPIED.name: - print( - f"Device {device_name} is occupied, skipping and waiting for it to be unoccupied." - ) - self._device_view.pause_device(device_name) - else: - print( - f"Device {device_name} is in status {device_entry['status']}, " - "skipping and waiting for it to be unoccupied." - ) - self._device_view.pause_device(device_name) - if processed: - self.sample_positions_in_devices_to_be_removed_that_is_paused.remove( - device_sample_position_dict - ) + self._handle_device_sample_position_removal_paused( + device_sample_position_dict + ) @contextmanager def pause_all_devices(self): diff --git a/alab_management/device_view/device_view.py b/alab_management/device_view/device_view.py index 00d2b79e..7f50c318 100644 --- a/alab_management/device_view/device_view.py +++ b/alab_management/device_view/device_view.py @@ -611,7 +611,7 @@ def remove_device(self, device_name: str): """Remove a device from the device view and the registry.""" # disconnect the device print(f"Disconnecting from {device_name}...") - with self._lock(): + with self._lock(): # pylint: disable=not-callable device = self._device_list[device_name] device._disconnect_wrapper() self._device_list.pop(device_name, None) diff --git a/alab_management/sample_view/sample_view.py b/alab_management/sample_view/sample_view.py index 0f0f5017..e4574a7e 100644 --- a/alab_management/sample_view/sample_view.py +++ b/alab_management/sample_view/sample_view.py @@ -523,7 +523,7 @@ def exists(self, sample_id: ObjectId | str) -> bool: def remove_sample_position_by_prefix(self, prefix: str): """Remove a sample position from the database.""" - with self._lock(): + with self._lock(): # pylint: disable=not-callable remove_standalone_sample_position(prefix) self._sample_positions_collection.delete_many( {"name": {"$regex": f"^{re.escape(prefix)}"}} diff --git a/alab_management/scripts/setup_lab.py b/alab_management/scripts/setup_lab.py index 16758650..6074c65f 100644 --- a/alab_management/scripts/setup_lab.py +++ b/alab_management/scripts/setup_lab.py @@ -12,12 +12,92 @@ ) +def _process_device_sample_positions( + device_name, + devices_sample_positions_before_reload, + current_devices_sample_positions, +): + """Process sample positions for a specific device.""" + removed_positions = [] + current_device_sample_positions = current_devices_sample_positions[device_name] + current_sample_position_names = [ + pos.name for pos in current_device_sample_positions + ] + + for sample_position in devices_sample_positions_before_reload[device_name]: + if sample_position.name not in current_sample_position_names: + removed_positions.append(sample_position) + + return removed_positions + + +def _find_removed_sample_positions_in_devices( + devices_sample_positions_before_reload, current_devices_sample_positions +): + """Find sample positions in devices that have been removed.""" + removed_sample_positions_in_devices = {} + for device_name in devices_sample_positions_before_reload: + for current_device_name in current_devices_sample_positions: + if current_device_name == device_name: + removed_positions = _process_device_sample_positions( + device_name, + devices_sample_positions_before_reload, + current_devices_sample_positions, + ) + if removed_positions: + removed_sample_positions_in_devices[device_name] = removed_positions + return removed_sample_positions_in_devices + + +def _process_device_sample_position_updates( + device_name, + devices_sample_positions_before_reload, + current_devices_sample_positions, +): + """Process sample position updates for a specific device.""" + updated_positions = [] + for sample_position in devices_sample_positions_before_reload[device_name]: + for current_sample_position in current_devices_sample_positions[device_name]: + if ( + sample_position.name == current_sample_position.name + and sample_position.number != current_sample_position.number + ): + updated_positions.append(current_sample_position) + return updated_positions + + +def _find_updated_sample_positions_in_devices( + devices_sample_positions_before_reload, current_devices_sample_positions +): + """Find sample positions in devices that have been updated.""" + updated_sample_positions_in_devices = {} + for device_name in devices_sample_positions_before_reload: + for current_device_name in current_devices_sample_positions: + if current_device_name == device_name: + updated_positions = _process_device_sample_position_updates( + device_name, + devices_sample_positions_before_reload, + current_devices_sample_positions, + ) + if updated_positions: + updated_sample_positions_in_devices[device_name] = updated_positions + return updated_sample_positions_in_devices + + def setup_lab( reload: bool = False, ) -> ( dict[str, list[str] | list[SamplePosition] | dict[str, list[SamplePosition]]] | bool ): - """Cleanup the db and then import all the definitions and set up the db.""" + """Setup the lab by adding devices and sample positions to the database. + + Args: + reload (bool): If True, reload the definitions from the config file. + + Returns + ------- + dict or bool: If reload is True, returns a dict with the changes made. Otherwise returns True. + """ from alab_management.device_view import ( DeviceView, get_all_devices, @@ -64,11 +144,11 @@ def setup_lab( # step 4: filter out the devices that are not in the db [positive delta of devices] filtered_devices = {} # all device from the __init__.py that are not in the db - for device_name in device_dict: + for device_name, device in device_dict.items(): if device_name not in available_devices_names: - filtered_devices[device_name] = device_dict[ - device_name - ] # all device from the __init__.py that are not in the db + filtered_devices[device_name] = ( + device # all device from the __init__.py that are not in the db + ) # step 5: filter out the devices that are not in the __init__.py # [negative delta of devices, will be applied by device manager once the devices are not used anymore] @@ -76,7 +156,7 @@ def setup_lab( [] ) # all device names from the db that are not in the __init__.py for device_name in available_devices_names: - if device_name not in list(current_devices_dict.keys()): + if device_name not in current_devices_dict: removed_devices_names.append(device_name) print(f"Devices to be removed from the db and registry: {removed_devices_names}") @@ -177,49 +257,18 @@ def setup_lab( current_devices_sample_positions = { device.name: device.sample_positions for device in current_devices } - removed_sample_positions_in_devices = {} - for device_name in devices_sample_positions_before_reload: - for current_device_name in current_devices_sample_positions: - if current_device_name == device_name: - for sample_position in devices_sample_positions_before_reload[ - device_name - ]: - if sample_position.name not in [ - current_device_sample_position.name - for current_device_sample_position in current_devices_sample_positions[ - current_device_name - ] - ]: - if device_name not in removed_sample_positions_in_devices: - removed_sample_positions_in_devices[device_name] = [] - removed_sample_positions_in_devices[device_name].append( - sample_position - ) + removed_sample_positions_in_devices = _find_removed_sample_positions_in_devices( + devices_sample_positions_before_reload, current_devices_sample_positions + ) print(f"Removed sample positions in devices: {removed_sample_positions_in_devices}") # step 14: check if there is any update of sample positions from the devices # [calculate negative delta of sample positions numbers] # positive is already handled in step 12, so we only need to calculate negative delta to be handled by device manager # once the devices are not used anymore - updated_sample_positions_in_devices = {} - for device_name in devices_sample_positions_before_reload: - for current_device_name in current_devices_sample_positions: - if current_device_name == device_name: - for sample_position in devices_sample_positions_before_reload[ - device_name - ]: - for current_sample_position in current_devices_sample_positions[ - current_device_name - ]: - if ( - sample_position.name == current_sample_position.name - and sample_position.number != current_sample_position.number - ): - if device_name not in updated_sample_positions_in_devices: - updated_sample_positions_in_devices[device_name] = [] - updated_sample_positions_in_devices[device_name].append( - current_sample_position - ) + updated_sample_positions_in_devices = _find_updated_sample_positions_in_devices( + devices_sample_positions_before_reload, current_devices_sample_positions + ) print(f"Updated sample positions in devices: {updated_sample_positions_in_devices}") # print the alarm configuration diff --git a/alab_management/utils/module_ops.py b/alab_management/utils/module_ops.py index fdf24392..e0b3e1b4 100644 --- a/alab_management/utils/module_ops.py +++ b/alab_management/utils/module_ops.py @@ -121,6 +121,53 @@ def import_module_from_path(path: str | Path, reload: bool = False) -> ModuleTyp return importlib.import_module(module_name, module_name) +def _should_reload_package(package_name: str, reloaded_packages: set) -> bool: + """Check if a package should be reloaded.""" + return ( + package_name + and package_name not in reloaded_packages + and package_name in sys.modules + ) + + +def _reload_parent_package(package_name: str, reloaded_packages: set) -> None: + """Reload a parent package.""" + print(f"Reloading parent package: {package_name}") + try: + deep_reload(sys.modules[package_name]) + reloaded_packages.add(package_name) + except Exception as e: + print(f"Failed to reload parent package {package_name}: {e}") + + +def _process_parent_packages( + py_file: Path, dir_path: Path, root_path: Path, reloaded_packages: set +) -> None: + """Process parent packages for reloading.""" + parent = py_file.parent + while True: + # Stop if we've reached above the working directory + if parent.resolve() < root_path: + break + # Only reload if __init__.py exists + init_file = parent / "__init__.py" + if not init_file.exists(): + parent = parent.parent + continue + # Calculate the package name + try: + rel_parent = parent.relative_to(dir_path.parent) + package_name = ".".join(rel_parent.parts) + if _should_reload_package(package_name, reloaded_packages): + _reload_parent_package(package_name, reloaded_packages) + except ValueError: + # parent is not under dir_path.parent + break + if parent.resolve() == root_path: + break + parent = parent.parent + + def _scan_and_import_new_modules(root_path: Path) -> None: """ Scan for new Python modules in the given path and import them. @@ -161,39 +208,9 @@ def _scan_and_import_new_modules(root_path: Path) -> None: importlib.import_module(module_name) # After importing, reload each parent package up to the working directory - parent = py_file.parent - while True: - # Stop if we've reached above the working directory - if parent.resolve() < root_path: - break - # Only reload if __init__.py exists - init_file = parent / "__init__.py" - if not init_file.exists(): - parent = parent.parent - continue - # Calculate the package name - try: - rel_parent = parent.relative_to(dir_path.parent) - package_name = ".".join(rel_parent.parts) - if ( - package_name - and package_name not in reloaded_packages - and package_name in sys.modules - ): - print(f"Reloading parent package: {package_name}") - try: - deep_reload(sys.modules[package_name]) - reloaded_packages.add(package_name) - except Exception as e: - print( - f"Failed to reload parent package {package_name}: {e}" - ) - except ValueError: - # parent is not under dir_path.parent - break - if parent.resolve() == root_path: - break - parent = parent.parent + _process_parent_packages( + py_file, dir_path, root_path, reloaded_packages + ) except (ValueError, ImportError) as e: print(f"Skipping import of {py_file}: {e}") continue From 548f706e8e337cd60a255986d34f0ca06ebc447a Mon Sep 17 00:00:00 2001 From: Bernardus Rendy <37468936+bernardusrendy@users.noreply.github.com> Date: Sun, 13 Jul 2025 21:18:31 -0700 Subject: [PATCH 10/11] reverse to too-many-positional-arguments disabling --- .pylintrc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pylintrc b/.pylintrc index 3e0fda0e..0a5c1b6a 100644 --- a/.pylintrc +++ b/.pylintrc @@ -64,7 +64,7 @@ confidence= disable=fixme, no-name-in-module, missing-function-docstring, - too-many-arguments, + too-many-positional-arguments, import-error, missing-module-docstring, too-few-public-methods, From d45855d0c8abdb89f44ea65393c20bc1240900b2 Mon Sep 17 00:00:00 2001 From: Bernardus Rendy <37468936+bernardusrendy@users.noreply.github.com> Date: Thu, 31 Jul 2025 22:53:26 -0700 Subject: [PATCH 11/11] Added extra feature to help get sample name by position or sample by position --- alab_management/sample_view/sample_view.py | 38 ++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/alab_management/sample_view/sample_view.py b/alab_management/sample_view/sample_view.py index e4574a7e..2fb32e96 100644 --- a/alab_management/sample_view/sample_view.py +++ b/alab_management/sample_view/sample_view.py @@ -578,3 +578,41 @@ def get_sample_position_max_number_by_prefix(self, prefix: str) -> int: max_number = max(max_number, 1) return max_number + + def get_sample_name_by_position(self, position: str) -> str | None: + """ + Get the sample name at a given position. + + Args: + position: The position to search for + + Returns + ------- + The sample name if a sample exists at the position, None otherwise + """ + sample = self._sample_collection.find_one({"position": position}) + return sample["name"] if sample else None + + def get_sample_by_position(self, position: str) -> Sample | None: + """ + Get the sample object at a given position. + + Args: + position: The position to search for + + Returns + ------- + The Sample object if a sample exists at the position, None otherwise + """ + sample = self._sample_collection.find_one({"position": position}) + if sample is None: + return None + + return Sample( + sample_id=sample["_id"], + name=sample["name"], + position=sample["position"], + task_id=sample["task_id"], + metadata=sample.get("metadata", {}), + tags=sample.get("tags", []), + )