diff --git a/alab_management/__init__.py b/alab_management/__init__.py index b71dd57c..e7b72b5b 100644 --- a/alab_management/__init__.py +++ b/alab_management/__init__.py @@ -2,7 +2,7 @@ import os -__version__ = "0.4.1" +__version__ = "1.0.1" from .builders import ExperimentBuilder from .device_view.dbattributes import value_in_database from .device_view.device import BaseDevice, add_device diff --git a/alab_management/_default/__init__.py b/alab_management/_default/__init__.py index 2fb5739f..fa13030d 100644 --- a/alab_management/_default/__init__.py +++ b/alab_management/_default/__init__.py @@ -1,2 +1,41 @@ -from .devices import * # noqa -from .tasks import * # noqa +""" +This is the entry point for the alab_management package. This package is used to manage the ALab project. + +You will need to import all the device and task definitions in the project and register them using the `add_device` +and `add_task` functions. +""" + +# import helper functions from alabos package +from alab_management.device_view import add_device +from alab_management.sample_view import SamplePosition, add_standalone_sample_position +from alab_management.task_view import add_task + +# import all the device and task definitions here +# relative imports are recommended (starts with a dot) +from .devices.default_device import DefaultDevice +from .tasks.default_task import DefaultTask + +# you can add the devices here. If they are the same type, +# you can use the same class and just change the name. +# +# For example, if you have 3 devices under different IP addresses, +# you can use the same class and just change the IP address and name. +# AlabOS will autoamtically decide which device to run an experiment +# based on their availability. +add_device(DefaultDevice(name="device_1", ip_address="192.168.1.11")) +add_device(DefaultDevice(name="device_2", ip_address="192.168.1.12")) +add_device(DefaultDevice(name="device_3", ip_address="192.168.1.13")) + +# you can add all the tasks here +add_task(DefaultTask) + +# When defining a device, you can define the sample positions related to that device, +# where the sample positions are bound to the device. +# AlabOS also provides a way to define standalone sample positions that are not bound to any device. +add_standalone_sample_position( + SamplePosition( + "default_standalone_sample_position", + description="Default sample position", + number=8, + ) +) diff --git a/alab_management/_default/config.toml b/alab_management/_default/config.toml index 5d778e45..31f90d70 100644 --- a/alab_management/_default/config.toml +++ b/alab_management/_default/config.toml @@ -1,19 +1,35 @@ [general] -name = 'default_lab' -working_dir = "." +name = 'default_lab' # Put the name of the lab here, it will be used as the DB name +working_dir = "." # the working directory of the lab, where the device and task definitions are stored -[mongodb] +[mongodb] # the MongoDB configuration host = 'localhost' password = '' port = 27017 username = '' +# all the completed experiments are stored in this database +# the db name will be the lab name + '_completed' [mongodb_completed] host = "localhost" password = "" port = 27017 username = "" -[rabbitmq] +[rabbitmq] # the RabbitMQ configuration host = "localhost" port = 5672 + +# the user notification configuration, currently only email and slack are supported +# if you don't want to use them, just leave them empty +[alarm] +# the email configuration. All the user notification will be sent to all the email_receivers in the list +# the email_sender is the email address of the sender, e.g. alabos@xxx.com +email_receivers = [] +email_sender = " " +email_password = " " + +# the slack configuration. All the user notification will be sent to the slack_channel_id +# the slack_bot_token is the token of the slack bot, you can get it from https://api.slack.com/apps +slack_bot_token = " " +slack_channel_id = " " diff --git a/alab_management/_default/devices/__init__.py b/alab_management/_default/devices/__init__.py index bf260537..ce94388e 100644 --- a/alab_management/_default/devices/__init__.py +++ b/alab_management/_default/devices/__init__.py @@ -1,3 +1 @@ -# from alab_management import import_device_definitions - -# import_device_definitions(__file__, __name__) +"""You don't have to modify this file. It is used to make Python treat the directory as containing packages.""" diff --git a/alab_management/_default/devices/default_device.py b/alab_management/_default/devices/default_device.py index 614cccf4..02c973bb 100644 --- a/alab_management/_default/devices/default_device.py +++ b/alab_management/_default/devices/default_device.py @@ -5,11 +5,22 @@ class DefaultDevice(BaseDevice): - """Default device definition, refer to https://idocx.github.io/alab_management/device_definition.html.""" + """Default device definition, refer to https://idocx.github.io/alab_management/device_definition.html. # TODO""" + # You can add a description to the device. description: ClassVar[str] = "Default device" - def __init__(self, *args, **kwargs): + def __init__(self, ip_address: str, *args, **kwargs): + """ + You can customize this method to store more information about the device. For example, + if the device is communicated through a serial port, you can store the serial port information here. + + Args: + ip_address: IP address of the device. This is just an example, you can change it to any other information. + *args: + **kwargs: + """ + self.ip_address = ip_address super().__init__(*args, **kwargs) @property @@ -24,16 +35,29 @@ def sample_positions(self): ] def connect(self): + """ + Connect to the device. + + In this method, you can define the connection to the device with various protocols. After calling + this method, the instance should be able to communicate with the device. + """ pass def disconnect(self): - pass + """ + Disconnect from the device. - def emergent_stop(self): + In this method, you can define the disconnection from the device. Although the instance + may still exist, it should not be able to communicate with the device after this method is called. + """ pass def is_running(self): - return False + """ + Check if the device is running. - -default_device_1 = DefaultDevice(name="default_device_1") + Returns: + ------- + bool: True if the device is running, False otherwise. + """ + return False diff --git a/alab_management/_default/tasks/__init__.py b/alab_management/_default/tasks/__init__.py index 23a26b0b..ce94388e 100644 --- a/alab_management/_default/tasks/__init__.py +++ b/alab_management/_default/tasks/__init__.py @@ -1,3 +1 @@ -# from alab_management import import_task_definitions - -# import_task_definitions(__file__, __name__) +"""You don't have to modify this file. It is used to make Python treat the directory as containing packages.""" diff --git a/alab_management/_default/tasks/default_task.py b/alab_management/_default/tasks/default_task.py index 9e0a8ffd..166b3234 100644 --- a/alab_management/_default/tasks/default_task.py +++ b/alab_management/_default/tasks/default_task.py @@ -1,10 +1,11 @@ from bson import ObjectId # type: ignore +from pydantic import BaseModel from alab_management.task_view.task import BaseTask class DefaultTask(BaseTask): - """The default task, refer to https://idocx.github.io/alab_management/task_definition.html for more details.""" + """The default task, refer to https://idocx.github.io/alab_management/task_definition.html for more details. #TODO""" def __init__(self, sample: ObjectId, *args, **kwargs): super().__init__(*args, **kwargs) @@ -21,3 +22,7 @@ def run(self): def validate(self): return True + + @property + def result_specification(self) -> BaseModel: + pass diff --git a/alab_management/builders/samplebuilder.py b/alab_management/builders/samplebuilder.py index 93dd605c..929cf887 100644 --- a/alab_management/builders/samplebuilder.py +++ b/alab_management/builders/samplebuilder.py @@ -83,72 +83,3 @@ def __eq__(self, other): def __repr__(self): """Return a string representation of the sample.""" return f"" - - -# ## Format checking for API inputs - - -# class TaskInputFormat(BaseModel): -# id: Optional[Any] = Field(None, alias="id") -# task_type: str -# parameters: Dict[str, Any] -# capacity: int -# prev_tasks: List[str] -# samples: List[Union[str, Any]] - -# @validator("id") -# def must_be_valid_objectid(cls, v): -# try: -# ObjectId(v) -# except: -# raise ValueError(f"Received invalid _id: {v} is not a valid ObjectId!") -# return v - -# @validator("capacity") -# def must_be_positive(cls, v): -# if v < 0: -# raise ValueError(f"Capacity must be positive, received {v}") -# return v - -# @validator("samples") -# def samples_must_be_valid_objectid(cls, v): -# for sample_id in v: -# try: -# ObjectId(sample_id) -# except: -# raise ValueError( -# f"Received invalid sample_id: {sample_id} is not a valid ObjectId!" -# ) -# return v - -# @validator("prev_tasks") -# def prevtasks_must_be_valid_objectid(cls, v): -# for task_id in v: -# try: -# ObjectId(task_id) -# except: -# raise ValueError( -# f"Received invalid sample_id: {task_id} is not a valid ObjectId!" -# ) -# return v - - -# class SampleInputFormat(BaseModel): -# """ -# Format check for API for Sample submission. -# Sample's must follow this format to be accepted into the batching queue. -# """ - -# id: Optional[Any] = Field(None, alias="id") -# name: constr(regex=r"^[^$.]+$") # type: ignore -# tags: List[str] -# tasks: List[TaskInputFormat] -# metadata: Dict[str, Any] - -# @validator("id") -# def must_be_valid_objectid(cls, v): -# try: -# ObjectId(v) -# except: -# raise ValueError(f"Received invalid _id: {v} is not a valid ObjectId!") -# return v diff --git a/alab_management/device_view/device.py b/alab_management/device_view/device.py index 4bdc7bdf..2e0150b4 100644 --- a/alab_management/device_view/device.py +++ b/alab_management/device_view/device.py @@ -287,11 +287,6 @@ def sample_positions(self): """ raise NotImplementedError() - @abstractmethod - def emergent_stop(self): # TODO rename this to emergency stop - """Specify how the device should stop when emergency.""" - raise NotImplementedError() - @abstractmethod def is_running(self) -> bool: """Check whether this device is running.""" diff --git a/alab_management/scripts/cli.py b/alab_management/scripts/cli.py index 2399a718..efd48e05 100644 --- a/alab_management/scripts/cli.py +++ b/alab_management/scripts/cli.py @@ -3,7 +3,6 @@ import click from alab_management.__init__ import __version__ -from alab_management.config import AlabOSConfig from .cleanup_lab import cleanup_lab from .init_project import init_project @@ -25,7 +24,6 @@ def cli(): /_/ \_\_|\__,_|_.__/ \___/|____/ ---- Alab OS v{__version__} -- Alab Project Team ---- - Simulation mode: {"ON" if AlabOSConfig().is_sim_mode() else "OFF"} """ ) @@ -57,7 +55,11 @@ def setup_lab_cli(): @click.option("--debug", default=False, is_flag=True) def launch_lab_cli(host, port, debug): """Start to run the lab.""" + from alab_management.config import AlabOSConfig + + click.echo(f'Simulation mode: {"ON" if AlabOSConfig().is_sim_mode() else "OFF"}') click.echo(f"The dashboard will be served on http://{host}:{port}") + launch_lab(host, port, debug) diff --git a/alab_management/task_view/task.py b/alab_management/task_view/task.py index 004e73a7..c87e769c 100644 --- a/alab_management/task_view/task.py +++ b/alab_management/task_view/task.py @@ -13,7 +13,6 @@ from alab_management.task_view.task_enums import TaskPriority if TYPE_CHECKING: - from alab_management.device_view.device import BaseDevice from alab_management.lab_view import LabView @@ -53,7 +52,7 @@ def __init__( task_id: ObjectId | None = None, lab_view: Optional["LabView"] = None, priority: TaskPriority | int | None = TaskPriority.NORMAL, - simulation: bool = True, + offline_mode: bool = True, *args, **kwargs, ): @@ -62,6 +61,8 @@ def __init__( task_id: the identifier of task lab_view: a lab_view corresponding to the task_id samples: a list of sample_id's corresponding to samples involvend in the task. + offline_mode: whether the task is run in offline mode or not. It is in offline mode when you + are trying to build an experiment out of it. Here is an example about how to define a custom task @@ -74,11 +75,16 @@ def __init__(self, sample_1: ObjectId, sample_2: Optional[ObjectId], self.setpoints = setpoints self.samples = [sample_1, sample_2, sample_3, sample_4] """ - self.__simulation = simulation + self.__offline = offline_mode + self._is_taskid_generated = ( + False # whether the task_id is generated using ObjectId() here or not + ) self.__samples = samples or [] - if self.is_simulation: - task_id = task_id or ObjectId() # if None, generate an ID now + if self.is_offline: + if task_id is None: # if task_id is not provided, generate one + self._is_taskid_generated = True + task_id = ObjectId() self.task_id = task_id current_frame = inspect.currentframe() outer_frames = inspect.getouterframes(current_frame) @@ -88,11 +94,12 @@ def __init__(self, sample_1: ObjectId, sample_2: Optional[ObjectId], for key, val in inspect.getargvalues(subclass_init_frame).locals.items() if key not in ["self", "args", "kwargs", "__class__"] } - + if not self.validate(): + raise ValueError("Task validation failed!") else: if (task_id is None) or (lab_view is None) or (samples is None): raise ValueError( - "BaseTask was instantiated with simulation mode off -- task_id, " + "BaseTask was instantiated with offline mode off -- task_id, " "lab_view, and samples must all be provided!" ) self.task_id = task_id @@ -100,13 +107,11 @@ def __init__(self, sample_1: ObjectId, sample_2: Optional[ObjectId], self.logger = self.lab_view.logger self.priority = priority self.lab_view.priority = priority - # if not self.validate(): #TODO: implement this - # raise ValueError("Task validation failed!") @property - def is_simulation(self) -> bool: - """Returns True if this task is a simulation, False if it is a live task.""" - return self.__simulation + def is_offline(self) -> bool: + """Returns True if this task is in offline, False if it is a live task.""" + return self.__offline @property def samples(self) -> list[str]: @@ -116,13 +121,13 @@ def samples(self) -> list[str]: @property def priority(self) -> int: """Returns the priority of this task.""" - if self.is_simulation: + if self.is_offline: return 0 return self.lab_view._resource_requester.priority @property @abstractmethod - def result_specification(self) -> BaseModel: + def result_specification(self) -> type[BaseModel]: """Returns a dictionary describing the results to be generated by this task. Raises @@ -153,7 +158,7 @@ def update_result(self, key: str, value: Any): # TODO type checking? - if not self.__simulation: + if not self.__offline: self.lab_view.update_result(name=key, value=value) def export_result(self, key: str) -> dict: @@ -171,6 +176,10 @@ def export_result(self, key: str) -> dict: ------- Any: The value of the result. """ + if self._is_taskid_generated: + raise ValueError( + "Cannot export a result from a task with an automatically generated task_id!" + ) if key not in self.result_specification: raise ValueError( f"Result key {key} is not included in the result specification for this task!" @@ -224,13 +233,13 @@ def import_result( def priority(self, value: int | TaskPriority): if value < 0: raise ValueError("Priority should be a positive integer") - if not self.__simulation: + if not self.__offline: self.lab_view._resource_requester.priority = int(value) def set_message(self, message: str): """Sets the task message to be displayed on the dashboard.""" self._message = message - if not self.__simulation: + if not self.__offline: self.lab_view._task_view.set_message(task_id=self.task_id, message=message) def get_message(self): @@ -245,10 +254,9 @@ def validate(self) -> bool: Should return False if the task has values that make it impossible to execute. For example, a ``Heating`` subclass of `BaseTask` might return False if the set temperature is too high for the furnace. + + By default, this function returns True unless it is overridden by a subclass. """ - # raise NotImplementedError( - # "The .validate method must be implemented by a subclass of BaseTask" - # ) return True @abstractmethod @@ -324,10 +332,10 @@ def add_to( Args: samples (Union[SampleBuilder, List[SampleBuilder]]): One or more SampleBuilder's which will have this task appended to their tasklists. """ - if not self.__simulation: + if not self.__offline: raise RuntimeError( "Cannot add a live BaseTask instance to a SampleBuilder. BaseTask must be instantiated with " - "`simulation=True` to enable this method." + "`offline_mode=True` to enable this method." ) if isinstance(samples, SampleBuilder): samples = [samples] diff --git a/docs/source/bestpractice.md b/docs/source/bestpractice.md new file mode 100644 index 00000000..091b436e --- /dev/null +++ b/docs/source/bestpractice.md @@ -0,0 +1 @@ +# Best Practice \ No newline at end of file diff --git a/docs/source/closed_loop.md b/docs/source/closed_loop.md new file mode 100644 index 00000000..19e4c58c --- /dev/null +++ b/docs/source/closed_loop.md @@ -0,0 +1 @@ +# Closed-loop synthesis with a decision-making agent \ No newline at end of file diff --git a/docs/source/conf.py b/docs/source/conf.py index 1d4fa4cb..c76d0395 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -26,7 +26,7 @@ # -- Project information ----------------------------------------------------- project = "Alab Management System" -copyright = f"{date.today().year}, Alab Project Team" +copyright = f"{date.today().year}, Ceder group" author = "Alab Project Team" # The full version, including alpha/beta/rc tags @@ -50,7 +50,7 @@ "sphinx.ext.napoleon", "sphinx.ext.viewcode", "sphinx.ext.githubpages", - "recommonmark", + "myst_nb", "sphinx_autodoc_typehints", ] @@ -79,14 +79,14 @@ "show_navbar_depth": 0, } -html_logo = (Path(__file__).parent / "_static" / "logo.png").as_posix() -html_title = "Alab Management" +# html_logo = (Path(__file__).parent / "_static" / "logo.png").as_posix() +html_title = f"Alab Management {__version__}" # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, # so a file named "default.css" will overwrite the builtin "default.css". html_static_path = ["_static"] -html_css_files = ["custom.css"] +html_css_files = [] StandaloneHTMLBuilder.supported_image_types = [ "image/svg+xml", diff --git a/docs/source/development.rst b/docs/source/development.rst index 569f2942..a2c55fd4 100644 --- a/docs/source/development.rst +++ b/docs/source/development.rst @@ -1,9 +1,7 @@ -.. _development: - Development ------------ +-------------- + -============================= Development Environment Setup ============================= diff --git a/docs/source/device_definition.md b/docs/source/device_definition.md new file mode 100644 index 00000000..30748617 --- /dev/null +++ b/docs/source/device_definition.md @@ -0,0 +1,27 @@ +# Defining new device + +To define a device, you should inherit from [`BaseDevice`](alab_management.device_view.device.BaseDevice). `BaseDevice` +provides a basic interface for a device, with a few abstract methods that should be implemented in the derived class. + +Other than the required abstract methods, you can also define additional methods that are specific to the device. For +example, you can define a `.do_powder_dosing` function for a powder dosing station, or a `.move_to` function for a robot arm. +The defined methods will be accessible to the task that uses the device. + +In this tutorial, we will take the box furnace as an example. + +```{note} +Considering the various communication protocol and the complexity of the device, we will not implement the actual +communication with the device in this tutorial. But the code should be good to run on simulation mode. + +If you are interested in how the communication can be implemented, you can check [alab_control](https://github.com/CederGroupHub/alab_control, +where all the communication with the device is implemented for A-Lab. +``` + +## Implementing `BoxFurnace` +### Connection and basic metadata +### Defining the device interface +#### Setting up the message +#### Error handling +### Mocking the device + +## Registering the device diff --git a/docs/source/device_definition.rst b/docs/source/device_definition.rst deleted file mode 100644 index 5c9162d4..00000000 --- a/docs/source/device_definition.rst +++ /dev/null @@ -1,15 +0,0 @@ -.. _defining_device: - -===================== -Defining a new device -===================== - -To define a device, you should inherit from :py:class:`BaseDevice `. -Here is the code sample. For more, please refer to `example `_ in the repo . - - -.. autoclass:: alab_management.device_view.device.BaseDevice - - .. automethod:: __init__ - .. automethod:: emergent_stop - .. autoproperty:: sample_positions diff --git a/docs/source/index.rst b/docs/source/index.rst index accc1c3c..70b2a622 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -2,8 +2,6 @@ :width: 430 :class: no-scaled-link -.. note:: - *Currently, this package is still under construction.* This Alab Workflow Management is aimed at providing a configurable and sharable platform for autonomous synthesis, just like what `ROS `_ has done for robotics. @@ -94,29 +92,23 @@ Task actor is a function that start a task process (managed by `Dramatiq `_ as our database. We communicate with database via `pymongo `_ package. -.. toctree:: - :maxdepth: -1 - :caption: Quickstart - :hidden: - - Installation - Setup definition folder - Defining new devices - Defining new tasks .. toctree:: - :maxdepth: -1 - :hidden: - - Development - API Docs - + :maxdepth: -1 + :hidden: + + installation + tutorial + bestpractice + Development + API Docs Indices and tables diff --git a/docs/source/installation.rst b/docs/source/installation.rst index bff2d8f7..4d2ba7cc 100644 --- a/docs/source/installation.rst +++ b/docs/source/installation.rst @@ -1,16 +1,20 @@ -.. _installation: - -============ Installation ============ Prerequisites ------------- +MongoDB +~~~~~~~ You must have access to at least one `MongoDB database `_ (locally or remotely). To install MongoDB locally, refer to `this `_. +RabbitMQ +~~~~~~~~ + +You must have RabbitMQ installed on your machine. To install RabbitMQ, refer to `this `_. + Install via pip ---------------- @@ -24,23 +28,12 @@ Install from source code .. code-block:: sh - git clone https://github.com/CederGroupHub/alab_management + git clone https://github.com/CederGroupHub/alabos cd alab_management pip install -e . - brew install rabbitmq - brew services start rabbitmq - -(Only for Mac OS) Additional installation for RabbitMQ ------------------------- - -.. code-block:: sh - - brew install rabbitmq - brew services start rabbitmq What's next ------------------ -Next, we will discuss how to set up a definition folder for custom devices and tasks. - +Next, we will show you a tutorial on how to setup the package for an autonomous lab. diff --git a/docs/source/quickstart.rst b/docs/source/quickstart.rst deleted file mode 100644 index e69de29b..00000000 diff --git a/docs/source/setup.rst b/docs/source/setup.rst index b97d495d..d19ec5b9 100644 --- a/docs/source/setup.rst +++ b/docs/source/setup.rst @@ -1,82 +1,92 @@ -Set up definition folder +Set up configuration folder ======================== -Initiate a definition project via command line +Initiate a project via command line ---------------------------------------------- -To initiate a project, you only need to run following command in the target folder. +To initiate a project, you only need to run following command in the target folder. You can also create +all the files and folders manually, but it is recommended to use the command line tool to do this. .. code-block:: sh - alabos init + mkdir tutorial + cd tutorial + alabos init # create all the necessary files and folders .. note:: - The folder you run this command must be empty + The folder you run this command must be empty. Or there will be an error raised. -Definition folder structure ---------------------------- -Alab Management serves as a Python package, which will handle the resource assignment in the lab automatically for you. -Before you run the system, you need to define devices and tasks according to your need. -The definition itself is a Python module, which has the structure: +Folder structure +------------------ + +After the command is run, you will see multiple files and folders generated in the target folder: .. code-block:: none ├── devices - │ ├── device_1.py - │ ├── device_2.py - │ ├── device_3.py + │ ├── default_device.py │ └── __init__.py │ ├── tasks - │ ├── task_1.py - │ ├── task_2.py - │ ├── task_3.py + │ ├── default_task.py │ └── __init__.py │ ├── config.toml └── __init__.py -For more examples, please refer to -`example `_ in the repo . +There will be a ``config.toml`` file, which contains the configurations of the lab, including the database connection information, +the working directory of the lab, etc. In this tutorial, we will create a mini A-Lab named ``Mini-Alab``. All the DB +configuration will be used as default. The configuration file will look like this: -Configuration File ------------------- +.. code-block:: toml -Besides the definition files, we also introduce a config file (``.toml``) for db -connection and some general configurations. + [general] + name = 'Mini-Alab' # Put the name of the lab here, it will be used as the DB name + working_dir = "." # the working directory of the lab, where the device and task definitions are stored -A default configuration may look like this. Usually, it is just okay to + [mongodb] # the MongoDB configuration + host = 'localhost' + password = '' + port = 27017 + username = '' -.. code-block:: toml + # all the completed experiments are stored in this database + # the db name will be the lab name + '_completed' + [mongodb_completed] + host = "localhost" + password = "" + port = 27017 + username = "" + + [rabbitmq] # the RabbitMQ configuration + host = "localhost" + port = 5672 + + # the user notification configuration, currently only email and slack are supported + # if you don't want to use them, just leave them empty + [alarm] + # the email configuration. All the user notification will be sent to all the email_receivers in the list + # the email_sender is the email address of the sender, e.g. alabos@xxx.com + email_receivers = [] + email_sender = " " + email_password = " " + + # the slack configuration. All the user notification will be sent to the slack_channel_id + # the slack_bot_token is the token of the slack bot, you can get it from https://api.slack.com/apps + slack_bot_token = " " + slack_channel_id = " " + + +The ``devices`` and ``tasks`` folders are for storing the definition files of devices and tasks, respectively, where +you can define the devices and tasks you want to use in the lab. + +The whole project is a Python package, so you will need to create a ``__init__.py`` file in the root folder to make it a package. - [general] - # the working dir specifies where the device and task definitions can be loaded - # by default, the working directory should just be the directory where the config - # file stores - name = "ALab" #the name of the lab. This will also set the name of the MongoDB database - working_dir = "." #the working directory of the lab code - # parent_package = "" #optional. This is used if the config/devices/tasks files are not in the root folder of the python package. This should point to the parent package of the config file (ie if devices/tasks are in a submodule of the "my_alab" package like "my_alab/system", this should be "my_alab.system") - - [mongodb] - # this section specify how to connect to the "working" database, you can specify the host - # and port of database. If your database - # needs authentication, you will also need to provide username and password - host = 'localhost' - port = 27017 - username = '' - password = '' - - [mongodb_completed] - # this section specify how to connect to the "completed" database, which is a parallel database that stores the results for completed experiments. This is formatted the same as the previous section - host = 'localhost' - port = 27017 - username = '' - password = '' What's next ------------------ -Next, we will introduce how to define custom devices and tasks and register them. \ No newline at end of file +Next, we will introduce how to define custom devices and tasks and register them to the system. \ No newline at end of file diff --git a/docs/source/start_lab.md b/docs/source/start_lab.md new file mode 100644 index 00000000..be9a75e2 --- /dev/null +++ b/docs/source/start_lab.md @@ -0,0 +1 @@ +# Launching lab in simulation mode \ No newline at end of file diff --git a/docs/source/submit_experiments.md b/docs/source/submit_experiments.md new file mode 100644 index 00000000..2a7782aa --- /dev/null +++ b/docs/source/submit_experiments.md @@ -0,0 +1 @@ +# Submitting the synthesis experiments \ No newline at end of file diff --git a/docs/source/tutorial.md b/docs/source/tutorial.md new file mode 100644 index 00000000..3b0c91e8 --- /dev/null +++ b/docs/source/tutorial.md @@ -0,0 +1,56 @@ +# Tutorial +In this section, we will guide you how to implement a simple autonomous lab for solid-state synthesis +using `alabos` package. + +## Automated solid-state synthesis +The solid-state synthesis is one of the most important methods to synthesize inorganic powder materials. Usually, the precursors +are provided in powder form and are mixed together in a specific ratio. The mixture is then heated to a high temperature +for a certain period of time, where the new phase will form. The new phase is then ground and characterized +using various techniques. The most common ones are X-ray diffraction (XRD) for the crystal structure, scanning electron +microscopy (SEM) for the morphology, and energy-dispersive X-ray spectroscopy (EDS) for the elemental composition. + +In this tutorial, we will implement a basic autonomous lab for solid-state synthesis. The lab will include +a powder dosing and mixing station, four box furnaces, four tube furnaces, a powder grinding station, an XRD +machine, an SEM/EDS machine. Apart from the devices, we will also provide a set of analysis tasks that will be +executed after the synthesis is finished to interpret the results. + +We will implement the interface for various device and tasks that will be used in the lab. The device includes +``` +- Powder Dosing Station +- Robot Arm +- Box Furnace +- Tube Furnace +- Powder Grinding Station +- XRD Preparation Station +- SEM Preataration Station +- XRD machine +- SEM machine +``` +The tasks include +``` +- PowderDosing +- Moving +- Heating +- HeatingWithAtmosphere +- PowderRecovery +- XRD +- SEM +``` + +## The structure of the tutorial +We will start the tutorial from setting up the definition folder, where we will define the devices and tasks, where +the demo of the functionalities of the `alabos` package will be shown. The next step will be to start the simulated lab +and submit the synthesis task. We will also show how to implement the close-loop synthesis, where the synthesis task +new experiment will be submitted after the old one is finished. + +```{toctree} +:maxdepth: 1 +:hidden: + +setup +device_definition +task_definition +start_lab +submit_experiments +closed_loop +``` \ No newline at end of file diff --git a/examples/fake_lab/__init__.py b/examples/fake_lab/__init__.py deleted file mode 100644 index 15431605..00000000 --- a/examples/fake_lab/__init__.py +++ /dev/null @@ -1,35 +0,0 @@ -from alab_management.device_view import add_device -from alab_management.sample_view import SamplePosition, add_standalone_sample_position -from alab_management.task_view import add_task - -from .devices.furnace import Furnace -from .devices.robot_arm import RobotArm -from .tasks.ending import Ending -from .tasks.heating import Heating -from .tasks.moving import Moving -from .tasks.starting import Starting - -add_device(Furnace(name="furnace_1")) -add_device(Furnace(name="furnace_2")) -add_device(Furnace(name="furnace_3")) -add_device(Furnace(name="furnace_4")) -add_device(RobotArm(name="dummy")) - -add_standalone_sample_position( - SamplePosition( - "furnace_table", description="Temporary position to transfer samples" - ) -) - -add_standalone_sample_position( - SamplePosition( - "furnace_temp", - number=4, - description="Test positions", - ) -) - -add_task(Starting) -add_task(Moving) -add_task(Heating) -add_task(Ending) diff --git a/examples/fake_lab/config.toml b/examples/fake_lab/config.toml deleted file mode 100644 index ba5e1e51..00000000 --- a/examples/fake_lab/config.toml +++ /dev/null @@ -1,19 +0,0 @@ -[general] -working_dir = "." -name = '__fake_alab__' - -[mongodb] -host = 'localhost' -port = 27017 -username = '' -password = '' - -[mongodb_completed] -host = 'localhost' -port = 27017 -username = '' -password = '' - -[rabbitmq] -host = "localhost" -port = 5672 diff --git a/examples/fake_lab/devices/__init__.py b/examples/fake_lab/devices/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/examples/fake_lab/devices/furnace.py b/examples/fake_lab/devices/furnace.py deleted file mode 100644 index 16e2f8cf..00000000 --- a/examples/fake_lab/devices/furnace.py +++ /dev/null @@ -1,46 +0,0 @@ -from threading import Timer -from typing import ClassVar - -from alab_management.device_view import BaseDevice -from alab_management.sample_view import SamplePosition - - -class Furnace(BaseDevice): - description: ClassVar[str] = "Fake furnace" - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self._is_running = False - - @property - def sample_positions(self): - return [ - SamplePosition( - "inside", - description="The position inside the furnace, where the samples are heated", - ), - ] - - def emergent_stop(self): - pass - - def run_program(self, *segments): - self._is_running = True - - def finish(): - self._is_running = False - - t = Timer(2, finish) - t.start() - - def is_running(self): - return self._is_running - - def get_temperature(self): - return 300 - - def connect(self): - pass - - def disconnect(self): - pass diff --git a/examples/fake_lab/devices/robot_arm.py b/examples/fake_lab/devices/robot_arm.py deleted file mode 100644 index 48e5cf03..00000000 --- a/examples/fake_lab/devices/robot_arm.py +++ /dev/null @@ -1,32 +0,0 @@ -from typing import ClassVar - -from alab_management.device_view import BaseDevice -from alab_management.sample_view import SamplePosition - - -class RobotArm(BaseDevice): - description: ClassVar[str] = "Fake robot arm" - - @property - def sample_positions(self): - return [ - SamplePosition( - "sample_holder", - description="The position that can hold the sample", - ), - ] - - def emergent_stop(self): - pass - - def run_program(self, program): - pass - - def is_running(self) -> bool: - return False - - def connect(self): - pass - - def disconnect(self): - pass diff --git a/examples/fake_lab/tasks/__init__.py b/examples/fake_lab/tasks/__init__.py deleted file mode 100644 index 8768b295..00000000 --- a/examples/fake_lab/tasks/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -from .ending import Ending -from .heating import Heating -from .moving import Moving -from .starting import Starting diff --git a/examples/fake_lab/tasks/ending.py b/examples/fake_lab/tasks/ending.py deleted file mode 100644 index 7c77599b..00000000 --- a/examples/fake_lab/tasks/ending.py +++ /dev/null @@ -1,13 +0,0 @@ -from bson import ObjectId - -from alab_management.task_view.task import BaseTask - - -class Ending(BaseTask): - def __init__(self, samples: list[ObjectId], *args, **kwargs): - super().__init__(samples=samples, *args, **kwargs) - self.sample = samples[0] - - def run(self): - self.lab_view.move_sample(self.sample, None) - return self.task_id diff --git a/examples/fake_lab/tasks/heating.py b/examples/fake_lab/tasks/heating.py deleted file mode 100644 index c953b668..00000000 --- a/examples/fake_lab/tasks/heating.py +++ /dev/null @@ -1,47 +0,0 @@ -import time - -from bson import ObjectId - -from alab_management.task_view import BaseTask -from fake_lab.devices.furnace import Furnace - -from .moving import Moving - - -class Heating(BaseTask): - def __init__( - self, - samples: list[ObjectId], - setpoints: list[tuple[float, float]], - *args, - **kwargs, - ): - super().__init__(samples=samples, *args, **kwargs) - self.setpoints = setpoints - self.sample = samples[0] - - def run(self): - with self.lab_view.request_resources({Furnace: {"inside": 1}}) as ( - devices, - sample_positions, - ): - furnace = devices[Furnace] - inside_furnace = sample_positions[Furnace]["inside"][0] - - self.lab_view.run_subtask( - Moving, - sample=self.sample, - samples=[self.sample], - dest=inside_furnace, - ) - - furnace.run_program(self.setpoints) - - while furnace.is_running(): - self.logger.log_device_signal( - device_name=furnace.name, - signal_name="Temperature", - signal_value=furnace.get_temperature(), - ) - time.sleep(1) - return self.task_id diff --git a/examples/fake_lab/tasks/moving.py b/examples/fake_lab/tasks/moving.py deleted file mode 100644 index 25d3ed28..00000000 --- a/examples/fake_lab/tasks/moving.py +++ /dev/null @@ -1,33 +0,0 @@ -from typing import cast - -from bson import ObjectId - -from alab_management.task_view.task import BaseTask -from fake_lab.devices.robot_arm import RobotArm - - -class Moving(BaseTask): - def __init__(self, samples: list[ObjectId], dest: str, *args, **kwargs): - super().__init__(samples=samples, *args, **kwargs) - self.sample = samples[0] - self.dest = dest - self.sample_position = self.lab_view.get_sample(sample=self.sample).position - - def run(self): - with self.lab_view.request_resources( - {RobotArm: {}, None: {self.dest: 1, self.sample_position: 1}} - ) as (devices, sample_positions): - robot_arm = cast(RobotArm, 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.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], - }, - ) - return self.task_id diff --git a/examples/fake_lab/tasks/starting.py b/examples/fake_lab/tasks/starting.py deleted file mode 100644 index 545fefc9..00000000 --- a/examples/fake_lab/tasks/starting.py +++ /dev/null @@ -1,21 +0,0 @@ -import time - -from bson import ObjectId - -from alab_management.task_view.task import BaseTask - - -class Starting(BaseTask): - def __init__(self, samples: list[ObjectId], dest: str, *args, **kwargs): - super().__init__(samples=samples, *args, **kwargs) - self.sample = samples[0] - self.dest = dest - - def run(self): - with self.lab_view.request_resources({None: {self.dest: 1}}) as ( - devices, - sample_positions, - ): - self.lab_view.move_sample(self.sample, sample_positions[None][self.dest][0]) - time.sleep(1) - return self.task_id diff --git a/pyproject.toml b/pyproject.toml index 814a5893..eed8784f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -62,8 +62,8 @@ docs = [ "sphinx-copybutton==0.5.2", "sphinx==7.2.5", "sphinx-autodoc-typehints >= 1.12.0", - "sphinx_book_theme", - "recommonmark ~= 0.7.1", + "sphinx_book_theme==1.3.0", + "myst-nb==1.1.1", ] dev = [ "pre-commit>=2.12.1",