4 Commits

Author SHA1 Message Date
h840473807
5805f94e9a 扣电驱动中增加多个组装参数,更新驱动与注册表 (#120)
扣电驱动中增加多个组装参数,elec_vol:int=50, assembly_type:int=7, assembly_pressure:int=4200,更新驱动与注册表
2025-10-21 16:27:02 +08:00
Calvin Cao
3adcc41ce8 Merge pull request #118 from h840473807/workstation_dev_YB2
Workstation dev yb2
2025-10-21 10:32:41 +08:00
h840473807
243922caf4 宜宾配液+扣电工站注册表文件
宜宾配液+扣电工站注册表文件
2025-10-20 15:43:21 +08:00
h840473807
079ec9d1b4 workstation_by_hhm
宜宾扣电工站与奔曜配液工站,更新截止10月20日
2025-10-20 15:36:53 +08:00
221 changed files with 40371 additions and 48575 deletions

View File

@@ -1,6 +1,6 @@
package:
name: unilabos
version: 0.10.7
version: 0.10.6
source:
path: ../unilabos
@@ -31,14 +31,11 @@ requirements:
- python ==3.11.11
- pip
- setuptools
- zstd
- zstandard
run:
- conda-forge::python ==3.11.11
- compilers
- cmake
- zstd
- zstandard
- ninja
- if: unix
then:

View File

@@ -1,340 +0,0 @@
name: Build Conda-Pack Environment
on:
workflow_dispatch:
inputs:
branch:
description: '选择要构建的分支'
required: true
default: 'dev'
type: string
platforms:
description: '选择构建平台 (逗号分隔): linux-64, osx-64, osx-arm64, win-64'
required: false
default: 'win-64'
type: string
jobs:
build-conda-pack:
strategy:
fail-fast: false
matrix:
include:
- os: ubuntu-latest
platform: linux-64
env_file: unilabos-linux-64.yaml
script_ext: sh
- os: macos-13 # Intel
platform: osx-64
env_file: unilabos-osx-64.yaml
script_ext: sh
- os: macos-latest # ARM64
platform: osx-arm64
env_file: unilabos-osx-arm64.yaml
script_ext: sh
- os: windows-latest
platform: win-64
env_file: unilabos-win64.yaml
script_ext: bat
runs-on: ${{ matrix.os }}
defaults:
run:
# Windows uses cmd for better conda/mamba compatibility, Unix uses bash
shell: ${{ matrix.platform == 'win-64' && 'cmd' || 'bash' }}
steps:
- name: Check if platform should be built
id: should_build
shell: bash
run: |
if [[ -z "${{ github.event.inputs.platforms }}" ]]; then
echo "should_build=true" >> $GITHUB_OUTPUT
elif [[ "${{ github.event.inputs.platforms }}" == *"${{ matrix.platform }}"* ]]; then
echo "should_build=true" >> $GITHUB_OUTPUT
else
echo "should_build=false" >> $GITHUB_OUTPUT
fi
- uses: actions/checkout@v4
if: steps.should_build.outputs.should_build == 'true'
with:
ref: ${{ github.event.inputs.branch }}
fetch-depth: 0
- name: Setup Miniforge (with mamba)
if: steps.should_build.outputs.should_build == 'true'
uses: conda-incubator/setup-miniconda@v3
with:
miniforge-version: latest
use-mamba: true
python-version: '3.11.11'
channels: conda-forge,robostack-staging,uni-lab,defaults
channel-priority: flexible
activate-environment: unilab
auto-update-conda: false
show-channel-urls: true
- name: Install conda-pack, unilabos and dependencies (Windows)
if: steps.should_build.outputs.should_build == 'true' && matrix.platform == 'win-64'
run: |
echo Installing unilabos and dependencies to unilab environment...
echo Using mamba for faster and more reliable dependency resolution...
mamba install -n unilab uni-lab::unilabos conda-pack -c uni-lab -c robostack-staging -c conda-forge -y
- name: Install conda-pack, unilabos and dependencies (Unix)
if: steps.should_build.outputs.should_build == 'true' && matrix.platform != 'win-64'
shell: bash
run: |
echo "Installing unilabos and dependencies to unilab environment..."
echo "Using mamba for faster and more reliable dependency resolution..."
mamba install -n unilab uni-lab::unilabos conda-pack -c uni-lab -c robostack-staging -c conda-forge -y
- name: Get latest ros-humble-unilabos-msgs version (Windows)
if: steps.should_build.outputs.should_build == 'true' && matrix.platform == 'win-64'
id: msgs_version_win
run: |
echo Checking installed ros-humble-unilabos-msgs version...
conda list -n unilab ros-humble-unilabos-msgs
for /f "tokens=2" %%i in ('conda list -n unilab ros-humble-unilabos-msgs --json ^| python -c "import sys, json; pkgs=json.load(sys.stdin); print(pkgs[0]['version'] if pkgs else 'not-found')"') do set VERSION=%%i
echo installed_version=%VERSION% >> %GITHUB_OUTPUT%
echo Installed ros-humble-unilabos-msgs version: %VERSION%
- name: Get latest ros-humble-unilabos-msgs version (Unix)
if: steps.should_build.outputs.should_build == 'true' && matrix.platform != 'win-64'
id: msgs_version_unix
shell: bash
run: |
echo "Checking installed ros-humble-unilabos-msgs version..."
VERSION=$(conda list -n unilab ros-humble-unilabos-msgs --json | python -c "import sys, json; pkgs=json.load(sys.stdin); print(pkgs[0]['version'] if pkgs else 'not-found')")
echo "installed_version=$VERSION" >> $GITHUB_OUTPUT
echo "Installed ros-humble-unilabos-msgs version: $VERSION"
- name: Check for newer ros-humble-unilabos-msgs (Windows)
if: steps.should_build.outputs.should_build == 'true' && matrix.platform == 'win-64'
run: |
echo Checking for available ros-humble-unilabos-msgs versions...
mamba search ros-humble-unilabos-msgs -c uni-lab -c robostack-staging -c conda-forge || echo Search completed
echo.
echo Updating ros-humble-unilabos-msgs to latest version...
mamba update -n unilab ros-humble-unilabos-msgs -c uni-lab -c robostack-staging -c conda-forge -y || echo Already at latest version
- name: Check for newer ros-humble-unilabos-msgs (Unix)
if: steps.should_build.outputs.should_build == 'true' && matrix.platform != 'win-64'
shell: bash
run: |
echo "Checking for available ros-humble-unilabos-msgs versions..."
mamba search ros-humble-unilabos-msgs -c uni-lab -c robostack-staging -c conda-forge || echo "Search completed"
echo ""
echo "Updating ros-humble-unilabos-msgs to latest version..."
mamba update -n unilab ros-humble-unilabos-msgs -c uni-lab -c robostack-staging -c conda-forge -y || echo "Already at latest version"
- name: Install latest unilabos from source (Windows)
if: steps.should_build.outputs.should_build == 'true' && matrix.platform == 'win-64'
run: |
echo Uninstalling existing unilabos...
mamba run -n unilab pip uninstall unilabos -y || echo unilabos not installed via pip
echo Installing unilabos from source (branch: ${{ github.event.inputs.branch }})...
mamba run -n unilab pip install .
echo Verifying installation...
mamba run -n unilab pip show unilabos
- name: Install latest unilabos from source (Unix)
if: steps.should_build.outputs.should_build == 'true' && matrix.platform != 'win-64'
shell: bash
run: |
echo "Uninstalling existing unilabos..."
mamba run -n unilab pip uninstall unilabos -y || echo "unilabos not installed via pip"
echo "Installing unilabos from source (branch: ${{ github.event.inputs.branch }})..."
mamba run -n unilab pip install .
echo "Verifying installation..."
mamba run -n unilab pip show unilabos
- name: Display environment info (Windows)
if: steps.should_build.outputs.should_build == 'true' && matrix.platform == 'win-64'
run: |
echo === Environment Information ===
mamba env list
echo.
echo === Installed Packages ===
mamba list -n unilab | findstr /C:"unilabos" /C:"ros-humble-unilabos-msgs" || mamba list -n unilab
echo.
echo === Python Packages ===
mamba run -n unilab pip list | findstr unilabos || mamba run -n unilab pip list
- name: Display environment info (Unix)
if: steps.should_build.outputs.should_build == 'true' && matrix.platform != 'win-64'
shell: bash
run: |
echo "=== Environment Information ==="
mamba env list
echo ""
echo "=== Installed Packages ==="
mamba list -n unilab | grep -E "(unilabos|ros-humble-unilabos-msgs)" || mamba list -n unilab
echo ""
echo "=== Python Packages ==="
mamba run -n unilab pip list | grep unilabos || mamba run -n unilab pip list
- name: Verify environment integrity (Windows)
if: steps.should_build.outputs.should_build == 'true' && matrix.platform == 'win-64'
run: |
echo Verifying Python version...
mamba run -n unilab python -c "import sys; print(f'Python version: {sys.version}')"
echo Verifying unilabos import...
mamba run -n unilab python -c "import unilabos; print(f'UniLabOS version: {unilabos.__version__}')" || echo Warning: Could not import unilabos
echo Checking critical packages...
mamba run -n unilab python -c "import rclpy; print('ROS2 rclpy: OK')"
echo Running comprehensive verification script...
mamba run -n unilab python scripts\verify_installation.py --auto-install || echo Warning: Verification script reported issues
echo Environment verification complete!
- name: Verify environment integrity (Unix)
if: steps.should_build.outputs.should_build == 'true' && matrix.platform != 'win-64'
shell: bash
run: |
echo "Verifying Python version..."
mamba run -n unilab python -c "import sys; print(f'Python version: {sys.version}')"
echo "Verifying unilabos import..."
mamba run -n unilab python -c "import unilabos; print(f'UniLabOS version: {unilabos.__version__}')" || echo "Warning: Could not import unilabos"
echo "Checking critical packages..."
mamba run -n unilab python -c "import rclpy; print('ROS2 rclpy: OK')"
echo "Running comprehensive verification script..."
mamba run -n unilab python scripts/verify_installation.py --auto-install || echo "Warning: Verification script reported issues"
echo "Environment verification complete!"
- name: Pack conda environment (Windows)
if: steps.should_build.outputs.should_build == 'true' && matrix.platform == 'win-64'
run: |
echo Packing unilab environment with conda-pack...
mamba activate unilab && conda pack -n unilab -o unilab-env-${{ matrix.platform }}.tar.gz --ignore-missing-files
echo Pack file created:
dir unilab-env-${{ matrix.platform }}.tar.gz
- name: Pack conda environment (Unix)
if: steps.should_build.outputs.should_build == 'true' && matrix.platform != 'win-64'
shell: bash
run: |
echo "Packing unilab environment with conda-pack..."
mamba install conda-pack -c conda-forge -y
conda pack -n unilab -o unilab-env-${{ matrix.platform }}.tar.gz --ignore-missing-files
echo "Pack file created:"
ls -lh unilab-env-${{ matrix.platform }}.tar.gz
- name: Prepare Windows distribution package
if: steps.should_build.outputs.should_build == 'true' && matrix.platform == 'win-64'
run: |
echo ==========================================
echo Creating distribution package...
echo Platform: ${{ matrix.platform }}
echo ==========================================
mkdir dist-package 2>nul || cd .
rem Copy packed environment
echo Adding: unilab-env-${{ matrix.platform }}.tar.gz
copy unilab-env-${{ matrix.platform }}.tar.gz dist-package\
rem Copy installation script
echo Adding: install_unilab.bat
copy scripts\install_unilab.bat dist-package\
rem Copy verification script
echo Adding: verify_installation.py
copy scripts\verify_installation.py dist-package\
rem Copy source code repository (including .git)
echo Adding: Uni-Lab-OS source repository
robocopy . dist-package\Uni-Lab-OS /E /XD dist-package /NFL /NDL /NJH /NJS /NC /NS || if %ERRORLEVEL% LSS 8 exit /b 0
rem Create README using Python script
echo Creating: README.txt
python scripts\create_readme.py ${{ matrix.platform }} ${{ github.event.inputs.branch }} dist-package\README.txt
echo.
echo Distribution package contents:
dir /b dist-package
echo.
- name: Prepare Unix/Linux distribution package
if: steps.should_build.outputs.should_build == 'true' && matrix.platform != 'win-64'
shell: bash
run: |
echo "=========================================="
echo "Creating distribution package..."
echo "Platform: ${{ matrix.platform }}"
echo "=========================================="
mkdir -p dist-package
# Copy packed environment
echo "Adding: unilab-env-${{ matrix.platform }}.tar.gz"
cp unilab-env-${{ matrix.platform }}.tar.gz dist-package/
# Copy installation script
echo "Adding: install_unilab.sh"
cp scripts/install_unilab.sh dist-package/
chmod +x dist-package/install_unilab.sh
# Copy verification script
echo "Adding: verify_installation.py"
cp scripts/verify_installation.py dist-package/
# Copy source code repository (including .git)
echo "Adding: Uni-Lab-OS source repository"
rsync -a --exclude='dist-package' . dist-package/Uni-Lab-OS
# Create README using Python script
echo "Creating: README.txt"
python scripts/create_readme.py ${{ matrix.platform }} ${{ github.event.inputs.branch }} dist-package/README.txt
echo ""
echo "Distribution package contents:"
ls -lh dist-package/
echo ""
- name: Upload distribution package
if: steps.should_build.outputs.should_build == 'true'
uses: actions/upload-artifact@v4
with:
name: unilab-pack-${{ matrix.platform }}-${{ github.event.inputs.branch }}
path: dist-package/
retention-days: 90
if-no-files-found: error
- name: Display package info (Windows)
if: steps.should_build.outputs.should_build == 'true' && matrix.platform == 'win-64'
run: |
echo ==========================================
echo Build Summary
echo ==========================================
echo Platform: ${{ matrix.platform }}
echo Branch: ${{ github.event.inputs.branch }}
echo Python version: 3.11.11
echo.
echo Distribution package contents:
dir dist-package
echo.
echo Artifact name: unilab-pack-${{ matrix.platform }}-${{ github.event.inputs.branch }}
echo.
echo After download, extract the ZIP and run:
echo install_unilab.bat
echo ==========================================
- name: Display package info (Unix)
if: steps.should_build.outputs.should_build == 'true' && matrix.platform != 'win-64'
shell: bash
run: |
echo "=========================================="
echo "Build Summary"
echo "=========================================="
echo "Platform: ${{ matrix.platform }}"
echo "Branch: ${{ github.event.inputs.branch }}"
echo "Python version: 3.11.11"
echo ""
echo "Distribution package contents:"
ls -lh dist-package/
echo ""
echo "Artifact name: unilab-pack-${{ matrix.platform }}-${{ github.event.inputs.branch }}"
echo ""
echo "After download:"
echo " install_unilab.sh"
echo "=========================================="

View File

@@ -1,113 +0,0 @@
name: Deploy Docs
on:
push:
branches: [main]
pull_request:
branches: [main]
workflow_dispatch:
inputs:
branch:
description: '要部署文档的分支'
required: false
default: 'main'
type: string
deploy_to_pages:
description: '是否部署到 GitHub Pages'
required: false
default: true
type: boolean
# 设置 GITHUB_TOKEN 权限以部署到 GitHub Pages
permissions:
contents: read
pages: write
id-token: write
# 只允许一个并发部署,跳过正在进行和最新排队之间的运行
# 但是不取消正在进行的运行,因为我们希望允许这些生产部署完成
concurrency:
group: 'pages'
cancel-in-progress: false
jobs:
# Build documentation
build:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
ref: ${{ github.event.inputs.branch || github.ref }}
fetch-depth: 0
- name: Setup Miniforge (with mamba)
uses: conda-incubator/setup-miniconda@v3
with:
miniforge-version: latest
use-mamba: true
python-version: '3.11.11'
channels: conda-forge,robostack-staging,uni-lab,defaults
channel-priority: flexible
activate-environment: unilab
auto-update-conda: false
show-channel-urls: true
- name: Install unilabos and dependencies
run: |
echo "Installing unilabos and dependencies to unilab environment..."
echo "Using mamba for faster and more reliable dependency resolution..."
mamba install -n unilab uni-lab::unilabos -c uni-lab -c robostack-staging -c conda-forge -y
- name: Install latest unilabos from source
run: |
echo "Uninstalling existing unilabos..."
mamba run -n unilab pip uninstall unilabos -y || echo "unilabos not installed via pip"
echo "Installing unilabos from source..."
mamba run -n unilab pip install .
echo "Verifying installation..."
mamba run -n unilab pip show unilabos
- name: Install documentation dependencies
run: |
echo "Installing documentation build dependencies..."
mamba run -n unilab pip install -r docs/requirements.txt
- name: Setup Pages
id: pages
uses: actions/configure-pages@v4
if: github.ref == 'refs/heads/main' || (github.event_name == 'workflow_dispatch' && github.event.inputs.deploy_to_pages == 'true')
- name: Build Sphinx documentation
run: |
cd docs
# Clean previous builds
rm -rf _build
# Build HTML documentation in conda environment
mamba run -n unilab python -m sphinx -b html . _build/html -v
- name: Check build results
run: |
echo "Documentation build completed, checking output directory:"
ls -la docs/_build/html/
echo "Checking for index.html:"
test -f docs/_build/html/index.html && echo "✓ index.html exists" || echo "✗ index.html missing"
- name: Upload build artifacts
uses: actions/upload-pages-artifact@v3
if: github.ref == 'refs/heads/main' || (github.event_name == 'workflow_dispatch' && github.event.inputs.deploy_to_pages == 'true')
with:
path: docs/_build/html
# Deploy to GitHub Pages
deploy:
if: github.ref == 'refs/heads/main' || (github.event_name == 'workflow_dispatch' && github.event.inputs.deploy_to_pages == 'true')
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
runs-on: ubuntu-latest
needs: build
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4

1
.gitignore vendored
View File

@@ -2,7 +2,6 @@ configs/
temp/
output/
unilabos_data/
pyrightconfig.json
## Python
# Byte-compiled / optimized / DLL files

View File

@@ -1,15 +0,0 @@
156 Xuwznln <18435084+Xuwznln@users.noreply.github.com>
39 Junhan Chang <changjh@dp.tech>
9 wznln <18435084+Xuwznln@users.noreply.github.com>
8 Guangxin Zhang <guangxin.zhang.bio@gmail.com>
5 ZiWei <131428629+ZiWei09@users.noreply.github.com>
2 Junhan Chang <changjh@pku.edu.cn>
2 Xie Qiming <97236197+Andy6M@users.noreply.github.com>
1 Harvey Que <103566763+Mile-Away@users.noreply.github.com>
1 Junhan Chang <1700011741@pku.edu.cn>
1 LccLink <1951855008@qq.com>
1 h840473807 <47357934+h840473807@users.noreply.github.com>
1 lixinyu1011 <61094742+lixinyu1011@users.noreply.github.com>
1 shiyubo0410 <shiyubo@dp.tech>
1 王俊杰 <1800011822@pku.edu.cn>
1 王俊杰 <43375851+wjjxxx@users.noreply.github.com>

View File

@@ -31,7 +31,7 @@ Join the [Intelligent Organic Chemistry Synthesis Competition](https://bohrium.d
Detailed documentation can be found at:
- [Online Documentation](https://xuwznln.github.io/Uni-Lab-OS-Doc/)
- [Online Documentation](https://readthedocs.dp.tech/Uni-Lab/v0.8.0/)
## Quick Start
@@ -55,7 +55,7 @@ pip install .
3. Start Uni-Lab System:
Please refer to [Documentation - Boot Examples](https://xuwznln.github.io/Uni-Lab-OS-Doc/boot_examples/index.html)
Please refer to [Documentation - Boot Examples](https://readthedocs.dp.tech/Uni-Lab/v0.8.0/boot_examples/index.html)
## Message Format

View File

@@ -31,7 +31,7 @@ Uni-Lab-OS 是一个用于实验室自动化的综合平台,旨在连接和控
详细文档可在以下位置找到:
- [在线文档](https://xuwznln.github.io/Uni-Lab-OS-Doc/)
- [在线文档](https://readthedocs.dp.tech/Uni-Lab/v0.8.0/)
## 快速开始
@@ -57,7 +57,7 @@ pip install .
3. 启动 Uni-Lab 系统:
请见[文档-启动样例](https://xuwznln.github.io/Uni-Lab-OS-Doc/boot_examples/index.html)
请见[文档-启动样例](https://readthedocs.dp.tech/Uni-Lab/v0.8.0/boot_examples/index.html)
## 消息格式

File diff suppressed because it is too large Load Diff

View File

@@ -91,7 +91,7 @@
使用以下命令启动模拟反应器:
```bash
unilab -g test/experiments/mock_reactor.json
unilab -g test/experiments/mock_reactor.json --app_bridges ""
```
### 2. 执行抽真空和充气操作

View File

@@ -23,7 +23,7 @@ extensions = [
"myst_parser",
"sphinx.ext.autodoc",
"sphinx.ext.napoleon", # 如果您使用 Google 或 NumPy 风格的 docstrings
"sphinx_rtd_theme",
"sphinx_rtd_theme"
]
source_suffix = {

View File

@@ -1,147 +0,0 @@
# 电池装配工站接入PLC
本指南将引导你完成电池装配工站(以 PLC 控制为例)的接入流程,包括新建工站文件、编写驱动与寄存器读写、生成注册表、上传及注意事项。
## 1. 新建工站文件
### 1.1 创建工站文件
`unilabos/devices/workstation/coin_cell_assembly` 目录下新建工站文件,如 `coin_cell_assembly.py`。工站类需继承 `WorkstationBase`,并在构造函数中初始化通信客户端与寄存器映射。
```python
from typing import Optional
# 工站基类
from unilabos.devices.workstation.workstation_base import WorkstationBase
# Modbus 通讯与寄存器 CSV 支持
from unilabos.device_comms.modbus_plc.client import TCPClient, BaseClient
class CoinCellAssemblyWorkstation(WorkstationBase):
def __init__(
self,
station_resource,
address: str = "192.168.1.20",
port: str = "502",
*args,
**kwargs,
):
super().__init__(station_resource=station_resource, *args, **kwargs)
self.station_resource = station_resource # 物料台面Deck
self.success: bool = False
self.allow_data_read: bool = False
self.csv_export_thread = None
self.csv_export_running = False
self.csv_export_file: Optional[str] = None
# 连接 PLC并注册寄存器节点
tcp = TCPClient(addr=address, port=port)
tcp.client.connect()
self.nodes = BaseClient.load_csv(".../PLC_register.csv")
self.client = tcp.register_node_list(self.nodes)
```
## 2. 编写驱动与寄存器读写
### 2.1 寄存器示例
- `COIL_SYS_START_CMD`BOOL地址 8010启动命令脉冲式
- `COIL_SYS_START_STATUS`BOOL地址 8210启动状态
- `REG_DATA_OPEN_CIRCUIT_VOLTAGE`FLOAT32地址 10002开路电压
- `REG_DATA_ASSEMBLY_PRESSURE`INT16地址 10014压制扣电压力
### 2.2 最小驱动示例
```python
from unilabos.device_comms.modbus_plc.modbus import WorderOrder
def start_and_read_metrics(self):
# 1) 下发启动(置 True 再复位 False
self.client.use_node('COIL_SYS_START_CMD').write(True)
self.client.use_node('COIL_SYS_START_CMD').write(False)
# 2) 等待进入启动状态
while True:
status, _ = self.client.use_node('COIL_SYS_START_STATUS').read(1)
if bool(status[0]):
break
# 3) 读取关键数据FLOAT32 需读 2 个寄存器并指定字节序)
voltage, _ = self.client.use_node('REG_DATA_OPEN_CIRCUIT_VOLTAGE').read(
2, word_order=WorderOrder.LITTLE
)
pressure, _ = self.client.use_node('REG_DATA_ASSEMBLY_PRESSURE').read(1)
return {
'open_circuit_voltage': voltage,
'assembly_pressure': pressure,
}
```
> 提示:若需参数下发,可在 PLC 端设置标志寄存器并完成握手复位,避免粘连与竞争。
## 3. 本地生成注册表并校验
完成工站类与驱动后,需要生成(或更新)工站注册表供系统识别。
### 3.1 新增工站设备(或资源)首次生成注册表
首先通过以下命令启动unilab。进入unilab系统状态检查页面
```bash
python unilabos\app\main.py -g celljson.json --ak <user的AK> --sk <user的SK>
```
点击注册表编辑,进入注册表编辑页面
![Layers](image_add_batteryPLC/unilab_sys_status.png)
按照图示步骤填写自动生成注册表信息:
![Layers](image_add_batteryPLC/unilab_registry_process.png)
步骤说明:
1. 选择新增的工站`coin_cell_assembly.py`文件
2. 点击分析按钮,分析`coin_cell_assembly.py`文件
3. 选择`coin_cell_assembly.py`文件中继承`WorkstationBase`
4. 填写新增的工站.py文件与`unilabos`目录的距离。例如,新增的工站文件`coin_cell_assembly.py`路径为`unilabos\devices\workstation\coin_cell_assembly\coin_cell_assembly.py`,则此处填写`unilabos.devices.workstation.coin_cell_assembly`
5. 此处填写新定义工站的类的名字(名称可以自拟)
6. 填写新的工站注册表备注信息
7. 生成注册表
以上操作步骤完成则会生成的新的注册表ymal文件如下图
![Layers](image_add_batteryPLC/unilab_new_yaml.png)
### 3.2 添加新生成注册表
`unilabos\registry\devices`目录下新建一个yaml文件此处新建文件命名为`coincellassemblyworkstation_device.yaml`,将上面生成的新的注册表信息粘贴到`coincellassemblyworkstation_device.yaml`文件中。
在终端输入以下命令进行注册表补全操作。
```bash
python unilabos\app\register.py --complete_registry
```
### 3.3 启动并上传注册表
新增设备之后启动unilab需要增加`--upload_registry`参数,来上传注册表信息。
```bash
python unilabos\app\main.py -g celljson.json --ak <user的AK> --sk <user的SK> --upload_registry
```
## 4. 注意事项
- 在新生成的 YAML 中,确认 `module` 指向新工站类,本例中需检查`coincellassemblyworkstation_device.yaml`文件中是否指向了`coin_cell_assembly.py`文件中定义的`CoinCellAssemblyWorkstation`类文件:
```
module: unilabos.devices.workstation.coin_cell_assembly.coin_cell_assembly:CoinCellAssemblyWorkstation
```
- 首次新增设备(或资源)需要在网页端新增注册表信息,`--complete_registry`补全注册表,`--upload_registry`上传注册表信息。
- 如果不是新增设备(或资源),仅对工站驱动的.py文件进行了修改则不需要在网页端新增注册表信息。只需要运行补全注册表信息之后上传注册表即可。

View File

@@ -111,8 +111,8 @@ new_device: # 设备名,要唯一
1.`auto-` 开头的动作:从你 Python 类的方法自动生成
2. 通用的驱动动作:
- `_execute_driver_command`:同步执行驱动命令(仅本地可用)
- `_execute_driver_command_async`:异步执行驱动命令(仅本地可用)
- `_execute_driver_command`:同步执行驱动命令
- `_execute_driver_command_async`:异步执行驱动命令
### 如果要手动定义动作

Binary file not shown.

Before

Width:  |  Height:  |  Size: 428 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 310 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

View File

@@ -1,409 +0,0 @@
# 物料教程Resource
本教程面向 Uni-Lab-OS 的开发者讲解“物料”的核心概念、3种物料格式UniLab、PyLabRobot、奔耀Bioyond及其相互转换方法并说明4种 children 结构表现形式及使用场景。
---
## 1. 物料是什么
- **物料Resource**指实验工作站中的实体对象包括设备device、操作甲板 deck、试剂、实验耗材也包括设备上承载的具体物料或者包含的容器如container/plate/well/瓶/孔/片等)。
- **物料基本信息**(以 UniLab list格式为例
```jsonc
{
"id": "plate", // 某一类物料的唯一名称
"name": "50ml瓶装试剂托盘", // 在云端显示的名称
"sample_id": null, // 同类物料的不同样品
"children": [
"50ml试剂瓶" // 表示托盘上有一个 50ml 试剂瓶
],
"parent": "deck", // 此物料放置在 deck 上
"type": "plate", // 物料类型
"class": "plate", // 物料对应的注册/类名
"position": {
"x": 0, // 初始放置位置
"y": 0,
"z": 0
},
"config": { // 固有配置(尺寸、旋转等)
"size_x": 400.0,
"size_y": 400.0,
"size_z": 400.0,
"rotation": {
"x": 0,
"y": 0,
"z": 0,
"type": "Rotation"
}
},
"data": {
"bottle_number": 1 // 动态数据(可变化)
}
}
```
## 2. 3种物料格式概览(UniLab、PyLabRobot、奔耀Bioyond)
### 2.1 UniLab 物料格式(云端/项目内通用)
- 结构特征:顶层通常是 `nodes` 列表;每个节点是扁平字典,`children` 是子节点 `id` 列表;`parent` 为父节点 `id``null`
- 用途:
- 云端数据存储、前端可视化、与图结构算法互操作
- 在上传/下载/部署配置时作为标准交换格式
示例片段UniLab 物料格式):
```jsonc
{
"nodes": [
{
"id": "a",
"name": "name_a",
"sample_id": 1,
"type": "deck",
"class": "deck",
"parent": null,
"children": ["b1"],
"position": {"x": 0, "y": 0, "z": 0},
"config": {},
"data": {}
},
{
"id": "b1",
"name": "name_b1",
"sample_id": 1,
"type": "plate",
"class": "plate",
"parent": "a1",
"children": [],
"position": {"x": 0, "y": 0, "z": 0},
"config": {},
"data": {}
}
]
}
```
### 2.2 PyLabRobotPLR物料格式实验流程运行时
- 结构特征:严格的层级树,`children` 为“子资源字典列表”(每个子节点本身是完整对象)。
- 用途:
- 实验流程执行与调度PLR 运行时期望的资源对象格式
- 通过 `Resource.deserialize/serialize``load_all_state/serialize_all_state` 与对象交互
示例片段PRL 物料格式)::
```json
{
"name": "deck",
"type": "Deck",
"category": "deck",
"location": {"x": 0, "y": 0, "z": 0, "type": "Coordinate"},
"rotation": {"x": 0, "y": 0, "z": 0, "type": "Rotation"},
"parent_name": null,
"children": [
{
"name": "plate_1",
"type": "Plate",
"category": "plate_96",
"location": {"x": 100, "y": 0, "z": 0, "type": "Coordinate"},
"rotation": {"x": 0, "y": 0, "z": 0, "type": "Rotation"},
"parent_name": "deck",
"children": [
{
"name": "A1",
"type": "Well",
"category": "well",
"location": {"x": 0, "y": 0, "z": 0, "type": "Coordinate"},
"rotation": {"x": 0, "y": 0, "z": 0, "type": "Rotation"},
"parent_name": "plate_1",
"children": []
}
]
}
]
}
```
### 2.3 奔耀 Bioyond 物料格式(第三方来源)
一般是厂商自己定义的json格式和字段信息需要提取和对应。以下为示例说明。
- 结构特征:顶层 `data` 列表,每项包含 `typeName``code``barCode``name``quantity``unit``locations`(仓位 `whName``x/y/z`)、`detail`(细粒度内容,如瓶内液体或孔位物料)。
- 用途:
- 第三方 WMS/设备的物料清单输入
- 需要自定义映射表将 `typeName` → PLR 类名,对 `locations`/`detail` 进行落位/赋值
示例片段奔耀Bioyond 物料格式):
```json
{
"data": [
{
"id": "3a1b5c10-d4f3-01ac-1e64-5b4be2add4b1",
"typeName": "液",
"code": "0006-00014",
"barCode": "",
"name": "EMC",
"quantity": 50,
"lockQuantity": 2.057,
"unit": "瓶",
"status": 1,
"isUse": false,
"locations": [
{
"id": "3a19da43-57b5-5e75-552f-8dbd0ad1075f",
"whid": "3a19da43-57b4-a2a8-3f52-91dbbeb836db",
"whName": "配液站内试剂仓库",
"code": "0003-0003",
"x": 1,
"y": 3,
"z": 1,
"quantity": 0
}
],
"detail": [
{
"code": "0006-00014-01",
"name": "EMC-瓶-1",
"x": 1,
"y": 3,
"z": 1,
"quantity": 500.0
}
]
}
],
"code": 1,
"message": "",
"timestamp": 0
}
```
### 2.4 3种物料格式关键字段对应(UniLab、PyLabRobot、奔耀Bioyond)
| 含义 | UniLab | PyLabRobot (PLR) | 奔耀 Bioyond |
| - | - | - | - |
| 节点唯一名 | `id` | `name` | `name` |
| 父节点引用 | `parent` | `parent_name` | `locations` 坐标(无直接父名,需映射坐标下的物料) |
| 子节点集合 | `children`id 列表或对象列表,视结构而定) | `children`(对象列表) | `detail`(明细,非严格树结构,需要自定义映射) |
| 类型(抽象类别) | `type`device/container/plate/deck/…) | `category`plate/well/…),以及类名 `type` | `typeName`(厂商自定义,如“液”、“加样头(大)”) |
| 运行/业务数据 | `data` | 通过 `serialize_all_state()`/`load_all_state()` 管理的状态 | `quantity``lockQuantity` 等业务数值 |
| 固有配置 | `config`size_x/size_y/size_z/model/ordering… | 资源字典中的同名键(反序列化时按构造签名取用) | 厂商自定义字段(需映射入 PLR/UniLab 的 `config``data` |
| 空间位置 | `position`x/y/z | `location`Coordinate + `rotation`Rotation | `locations`whName、x/y/z不含旋转 |
| 条码/标识 | `config.barcode`(可选) | 常放在配置键中(如 `barcode` | `barCode` |
| 数量单位 | 无固定键,通常在 `data` | 无固定键,通常在配置或状态中 | `unit` |
| 物料编码 | 通常在 `config``data` 自定义 | 通常在配置中自定义 | `code` |
说明:
- Bioyond 不提供显式的树形父子关系,通常通过 `locations` 将物料落位到某仓位/坐标。用 `detail` 表示子级明细。
---
## 3. children 的四种结构表示
- **list扁平列表**:每个节点是扁平字典,`children` 为子节点 `id` 数组。示例UniLab `nodes` 中的单个节点。
```json
{
"nodes": [
{ "id": "root", "parent": null, "children": ["child1"] },
{ "id": "child1", "parent": "root", "children": [] }
]
}
```
- **dict嵌套字典**:节点的 `children``{ child_id: child_node_dict }` 字典。
```json
{
"id": "root",
"parent": null,
"children": {
"child1": { "id": "child1", "parent": "root", "children": {} }
}
}
```
- **tree树形列表**:顶层是 `[root_node, ...]`,每个 `node.children` 是“子节点对象列表”(而非 id 列表)。
```json
[
{
"id": "root",
"parent": null,
"children": [
{ "id": "child1", "parent": "root", "children": [] }
]
}
]
```
- **nestdict顶层嵌套字典**:顶层是 `{root_id: root_node, ...}`,或者根节点自身带 `children: {id: node}` 形态。
```json
{
"root": {
"id": "root",
"parent": null,
"children": {
"child1": { "id": "child1", "parent": "root", "children": {} }
}
}
}
```
这些结构之间可使用 `graphio.py` 中的工具函数互转(见下一节)。
---
## 4. 转换函数及调用
核心代码文件:`unilabos/resources/graphio.py`
### 4.1 结构互转list/dict/tree/nestdict
代码引用:
```217:239:unilabos/resources/graphio.py
def dict_to_tree(nodes: dict, devices_only: bool = False) -> list[dict]:
# ... 由扁平 dictid->node生成树children 为对象列表)
```
```241:267:unilabos/resources/graphio.py
def dict_to_nested_dict(nodes: dict, devices_only: bool = False) -> dict:
# ... 由扁平 dict 生成嵌套字典children 为 {id:node}
```
```270:273:unilabos/resources/graphio.py
def list_to_nested_dict(nodes: list[dict]) -> dict:
# ... 由扁平列表children 为 id 列表)转嵌套字典
```
```275:286:unilabos/resources/graphio.py
def tree_to_list(tree: list[dict]) -> list[dict]:
# ... 由树形列表转回扁平列表children 还原为 id 列表)
```
```289:337:unilabos/resources/graphio.py
def nested_dict_to_list(nested_dict: dict) -> list[dict]:
# ... 由嵌套字典转回扁平列表
```
常见路径:
- UniLab 扁平列表 → 树:`dict_to_tree({r["id"]: r for r in resources})`
- 树 → UniLab 扁平列表:`tree_to_list(resources_tree)`
- 扁平列表 ↔ 嵌套字典:`list_to_nested_dict` / `nested_dict_to_list`
### 4.2 UniLab ↔ PyLabRobotPLR
高层封装:
```339:368:unilabos/resources/graphio.py
def convert_resources_to_type(resources_list: list[dict], resource_type: Union[type, list[type]], *, plr_model: bool = False):
# UniLab -> (NestedDict or PLR)
```
```371:395:unilabos/resources/graphio.py
def convert_resources_from_type(resources_list, resource_type: Union[type, list[type]], *, is_plr: bool = False):
# (NestedDict or PLR) -> UniLab 扁平列表
```
底层转换:
```398:441:unilabos/resources/graphio.py
def resource_ulab_to_plr(resource: dict, plr_model=False) -> "ResourcePLR":
# UniLab 单节点(树根) -> PLR Resource 对象
```
```443:481:unilabos/resources/graphio.py
def resource_plr_to_ulab(resource_plr: "ResourcePLR", parent_name: str = None, with_children=True):
# PLR Resource -> UniLab 单节点(dict)
```
示例:
```python
from unilabos.resources.graphio import convert_resources_to_type, convert_resources_from_type
from pylabrobot.resources.resource import Resource as ResourcePLR
# UniLab 扁平列表 -> PLR 根资源对象
plr_root = convert_resources_to_type(resources_list=ulab_list, resource_type=ResourcePLR)
# PLR 资源对象 -> UniLab 扁平列表(用于保存/上传)
ulab_flat = convert_resources_from_type(resources_list=plr_root, resource_type=ResourcePLR)
```
可选项:
- `plr_model=True`:保留 `model` 字段(默认会移除)。
- `with_children=False``resource_plr_to_ulab` 仅转换当前节点。
### 4.3 奔耀Bioyond→ PLR及进一步到 UniLab
转换入口:
```483:527:unilabos/resources/graphio.py
def resource_bioyond_to_plr(bioyond_materials: list[dict], type_mapping: dict = {}, deck: Any = None) -> list[dict]:
# Bioyond 列表 -> PLR 资源列表,并可根据 deck.warehouses 将资源落位
```
使用示例:
```python
import json
from unilabos.resources.graphio import resource_bioyond_to_plr, convert_resources_from_type
from pylabrobot.resources.resource import Resource as ResourcePLR
resp = json.load(open("unilabos/devices/workstation/bioyond_cell/bioyond_test_yibin.json", encoding="utf-8"))
materials = resp["data"]
# 将第三方类型name映射到 PLR 资源类名(需根据现场定义)
type_mapping = {
"液": "RegularContainer",
"加样头(大)": "RegularContainer"
}
plr_list = resource_bioyond_to_plr(materials, type_mapping=type_mapping, deck=None)
# 如需上传云端UniLab 扁平格式):
ulab_flat = convert_resources_from_type(plr_list, [ResourcePLR])
```
说明:
- `type_mapping` 必须由开发者根据设备/物料种类人工维护。
- 如传入 `deck`,且 `deck.warehouses` 命名与 `whName` 对应可将物料安放到仓库坐标x/y/z
---
## 5. 何时使用哪种格式
- **云端/持久化**:使用 UniLab 物料格式(扁平 `nodes` 列表children 为 id 列表)。便于版本化、可视化与网络传输。
- **实验工作流执行**:使用 PyLabRobotPLR格式。PLR 运行时依赖严格的树形资源结构与对象 API。
- **第三方设备/系统Bioyond输入**:保持来源格式不变,使用 `resource_bioyond_to_plr` + 人工 `type_mapping` 将其转换为 PLR必要时再转 UniLab
---
## 6. 常见问题与注意事项
- **children 形态不一致**:不同函数期望不同 children 形态,注意在进入转换前先用“结构互转”工具函数标准化形态。
- **devices_only**`dict_to_tree/dict_to_nested_dict` 支持仅保留 `type == device` 的节点。
- **模型/类型字段**PLR 对象序列化参数有所差异,`resource_ulab_to_plr` 内部会根据构造签名移除不兼容字段(如 `category`)。
- **驱动初始化**`initialize_resource(s)` 支持从注册表/类路径创建 PLR/UniLab 资源或列表。
参考代码:
```530:577:unilabos/resources/graphio.py
def initialize_resource(resource_config: dict, resource_type: Any = None) -> Union[list[dict], ResourcePLR]:
# 从注册类/模块反射创建资源,或将 UniLab 字典包装为列表
```
```580:597:unilabos/resources/graphio.py
def initialize_resources(resources_config) -> list[dict]:
# 批量初始化
```

View File

@@ -33,8 +33,6 @@ developer_guide/add_device
developer_guide/add_action
developer_guide/actions
developer_guide/add_protocol
developer_guide/add_batteryPLC
developer_guide/materials_tutorial.md
```
## 接口文档

View File

@@ -1,13 +0,0 @@
# Sphinx文档构建依赖
sphinx>=7.0.0
sphinx-rtd-theme>=2.0.0
myst-parser>=2.0.0
# 用于支持Jupyter notebook文档
myst-nb>=1.0.0
# 用于代码复制按钮
sphinx-copybutton>=0.5.0
# 用于自动摘要生成
sphinx-autobuild>=2024.2.4

View File

@@ -24,8 +24,6 @@ class WSConfig:
max_reconnect_attempts = 999 # 最大重连次数
ping_interval = 30 # ping间隔
```
您可以进入实验室点击左下角的头像在实验室详情中获取所在实验室的ak sk
![copy_aksk.gif](image/copy_aksk.gif)
### 完整配置示例

Binary file not shown.

Before

Width:  |  Height:  |  Size: 526 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 327 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 275 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 186 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 581 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 120 KiB

View File

@@ -245,78 +245,3 @@ unilab --ak your_ak --sk your_sk --port 8080 --disable_browser
- 检查图谱文件格式是否正确
- 验证设备连接和端点配置
- 确保注册表路径正确
## 页面操作
### 1. 启动成功
当您启动成功后,可以看到物料列表,节点模版和组态图如图展示
![material.png](image/material.png)
### 2. 根据需求创建设备和物料
我们可以做一个简单的案例
* 在容器1中加入水
* 通过传输泵将容器1中的水转移到容器2中
#### 2.1 添加所需的设备和物料
仪器设备work_station中的workstation 数量x1
仪器设备virtual_device中的virtual_transfer_pump 数量x1
物料耗材container中的container 数量x2
#### 2.2 将设备和物料根据父子关系进行关联
当我们添加设备时,仪器耗材模块的物料列表也会实时更新
我们需要将设备和物料拖拽到workstation中并在画布上将它们连接起来就像真实的设备操作一样
![links.png](image/links.png)
### 3. 创建工作流
进入工作流模块 → 点击"我创建的" → 新建工作流
![new.png](image/new.png)
#### 3.1 新增工作流节点
我们可以进入指定工作流,在空白处右键
* 选择Laboratory→host_node中的creat_resource
* 选择Laboratory→workstation中的PumpTransferProtocol
![creatworkfollow.gif](image/creatworkfollow.gif)
#### 3.2 配置节点参数
根据案例,工作流包含两个步骤:
1. 使用creat_resource在容器中创建水
2. 通过泵传输协议将水传输到另一个容器
我们点击creat_resource卡片上的编辑按钮来配置参数⭐
class_name container
device_id workstation
liquid_input_slot 0或-1均可
liquid_type : water
liquid_volume 根据需求填写即可默认单位ml这里举例50
parent workstation
res_id containe
关联设备名称(原unilabos_device_id) 这里就填写host_node
**配置完成后点击底部保存按钮**
我们点击PumpTransferProtocol卡片上的编辑按钮来配置参数⭐
event transfer_liquid
from_vessel water
to_vessel container1
volume 根据需求填写即可默认单位ml这里举例50
关联设备名称(原unilabos_device_id) 这里就填写workstation
**配置完成后点击底部保存按钮**
#### 3.3 运行工作流
1. 连接两个节点卡片
2. 点击底部保存按钮
3. 点击运行按钮执行工作流
![linksandrun.png](image/linksandrun.png)
### 运行监控
* 运行状态和消息实时显示在底部控制台
* 如有报错,可点击查看详细信息
### 结果验证
工作流完成后,返回仪器耗材模块:
* 点击 container1卡片查看详情
* 确认其中包含参数指定的水和容量

View File

@@ -1,197 +0,0 @@
# Uni-Lab-OS 一键安装快速指南
## 概述
本指南提供最快速的 Uni-Lab-OS 安装方法,使用预打包的 conda 环境,无需手动配置依赖。
## 前置要求
- 已安装 Conda/Miniconda/Miniforge/Mamba
- 至少 10GB 可用磁盘空间
- Windows 10+, macOS 10.14+, 或 Linux (Ubuntu 20.04+)
## 安装步骤
### 第一步:下载预打包环境
1. 访问 [GitHub Actions - Conda Pack Build](https://github.com/dptech-corp/Uni-Lab-OS/actions/workflows/conda-pack-build.yml)
2. 选择最新的成功构建记录(绿色勾号 ✓)
3. 在页面底部的 "Artifacts" 部分,下载对应你操作系统的压缩包:
- Windows: `unilab-pack-win-64-{branch}.zip`
- macOS (Intel): `unilab-pack-osx-64-{branch}.tar.gz`
- macOS (Apple Silicon): `unilab-pack-osx-arm64-{branch}.tar.gz`
- Linux: `unilab-pack-linux-64-{branch}.tar.gz`
### 第二步:解压并运行安装脚本
#### Windows
```batch
REM 使用 Windows 资源管理器解压下载的 zip 文件
REM 或使用命令行:
tar -xzf unilab-pack-win-64-dev.zip
REM 进入解压后的目录
cd unilab-pack-win-64-dev
REM 双击运行 install_unilab.bat
REM 或在命令行中执行:
install_unilab.bat
```
#### macOS
```bash
# 解压下载的压缩包
tar -xzf unilab-pack-osx-arm64-dev.tar.gz
# 进入解压后的目录
cd unilab-pack-osx-arm64-dev
# 运行安装脚本
bash install_unilab.sh
```
#### Linux
```bash
# 解压下载的压缩包
tar -xzf unilab-pack-linux-64-dev.tar.gz
# 进入解压后的目录
cd unilab-pack-linux-64-dev
# 添加执行权限(如果需要)
chmod +x install_unilab.sh
# 运行安装脚本
./install_unilab.sh
```
### 第三步:激活环境
```bash
conda activate unilab
```
### 第四步:验证安装(推荐)
```bash
# 确保已激活环境
conda activate unilab
# 运行验证脚本
python verify_installation.py
```
如果看到 "✓ All checks passed!",说明安装成功!
## 常见问题
### Q: 安装脚本找不到 conda
**A:** 确保你已经安装了 conda/miniconda/miniforge并且安装在标准位置
- **Windows**:
- `%USERPROFILE%\miniforge3`
- `%USERPROFILE%\miniconda3`
- `%USERPROFILE%\anaconda3`
- `C:\ProgramData\miniforge3`
- **macOS/Linux**:
- `~/miniforge3`
- `~/miniconda3`
- `~/anaconda3`
- `/opt/conda`
如果安装在其他位置,可以先激活 conda base 环境,然后手动运行安装脚本。
### Q: 安装后激活环境提示找不到?
**A:** 尝试以下方法:
```bash
# 方法 1: 使用 conda activate
conda activate unilab
# 方法 2: 使用完整路径激活Windows
call C:\Users\{YourUsername}\miniforge3\envs\unilab\Scripts\activate.bat
# 方法 2: 使用完整路径激活Unix
source ~/miniforge3/envs/unilab/bin/activate
```
### Q: conda-unpack 失败怎么办?
**A:** 尝试手动运行:
```bash
# Windows
cd %CONDA_PREFIX%\envs\unilab
.\Scripts\conda-unpack.exe
# macOS/Linux
cd $CONDA_PREFIX/envs/unilab
./bin/conda-unpack
```
### Q: 验证脚本报错?
**A:** 首先确认环境已激活:
```bash
# 检查当前环境
conda env list
# 应该看到 unilab 前面有 * 标记
```
如果仍有问题,查看具体报错信息,可能需要:
- 重新运行安装脚本
- 检查磁盘空间
- 查看详细文档
### Q: 环境很大,有办法减小吗?
**A:** 预打包的环境包含所有依赖,通常较大(压缩后 2-5GB。这是为了确保离线安装和完整功能。如果空间有限考虑使用手动安装方式只安装需要的组件。
### Q: 如何更新到最新版本?
**A:** 重新下载最新的预打包环境,运行安装脚本时选择覆盖现有环境。
或者在现有环境中更新:
```bash
conda activate unilab
# 更新 unilabos
cd /path/to/Uni-Lab-OS
git pull
pip install -e . --upgrade
# 更新 ros-humble-unilabos-msgs
mamba update ros-humble-unilabos-msgs -c uni-lab -c robostack-staging -c conda-forge
```
## 下一步
安装完成后,你可以:
1. **查看启动指南**: {doc}`launch`
2. **运行示例**: {doc}`../boot_examples/index`
3. **配置设备**: 编辑 `unilabos_data/startup_config.json`
4. **阅读开发文档**: {doc}`../developer_guide/workstation_architecture`
## 需要帮助?
- **文档**: [docs/user_guide/installation.md](installation.md)
- **问题反馈**: [GitHub Issues](https://github.com/dptech-corp/Uni-Lab-OS/issues)
- **开发版安装**: 参考 {doc}`installation` 的方式二
---
**提示**: 这个预打包环境包含了从指定分支(通常是 `dev`)构建的最新代码。如果需要稳定版本,请使用方式二手动安装 release 版本。

View File

@@ -1,54 +0,0 @@
{
"nodes": [
{
"id": "BatteryStation",
"name": "扣电工作站",
"parent": null,
"children": [
"coin_cell_deck"
],
"type": "device",
"class":"coincellassemblyworkstation_device",
"position": {
"x": 0,
"y": 0,
"z": 0
},
"config": {
"deck": {
"data": {
"_resource_child_name": "YB_YH_Deck",
"_resource_type": "unilabos.devices.workstation.coin_cell_assembly.YB_YH_materials:CoincellDeck"
}
},
"debug_mode": true,
"protocol_type": []
}
},
{
"id": "YB_YH_Deck",
"name": "YB_YH_Deck",
"children": [],
"parent": "BatteryStation",
"type": "deck",
"class": "CoincellDeck",
"position": {
"x": 0,
"y": 0,
"z": 0
},
"config": {
"type": "CoincellDeck",
"setup": true,
"rotation": {
"x": 0,
"y": 0,
"z": 0,
"type": "Rotation"
}
},
"data": {}
}
],
"links": []
}

View File

@@ -1,98 +0,0 @@
{
"nodes": [
{
"id": "bioyond_cell_workstation",
"name": "配液分液工站",
"parent": null,
"children": [
"YB_Bioyond_Deck"
],
"type": "device",
"class": "bioyond_cell",
"config": {
"deck": {
"data": {
"_resource_child_name": "YB_Bioyond_Deck",
"_resource_type": "unilabos.resources.bioyond.decks:BIOYOND_YB_Deck"
}
},
"protocol_type": []
},
"data": {}
},
{
"id": "YB_Bioyond_Deck",
"name": "YB_Bioyond_Deck",
"children": [],
"parent": "bioyond_cell_workstation",
"type": "deck",
"class": "BIOYOND_YB_Deck",
"position": {
"x": 0,
"y": 0,
"z": 0
},
"config": {
"type": "BIOYOND_YB_Deck",
"setup": true,
"rotation": {
"x": 0,
"y": 0,
"z": 0,
"type": "Rotation"
}
},
"data": {}
},
{
"id": "BatteryStation",
"name": "扣电工作站",
"parent": null,
"children": [
"coin_cell_deck"
],
"type": "device",
"class":"coincellassemblyworkstation_device",
"config": {
"deck": {
"data": {
"_resource_child_name": "YB_YH_Deck",
"_resource_type": "unilabos.devices.workstation.coin_cell_assembly.YB_YH_materials:CoincellDeck"
}
},
"protocol_type": []
},
"position": {
"size": {"height": 1450, "width": 1450, "depth": 2100},
"position": {
"x": -1500,
"y": 0,
"z": 0
}
}
},
{
"id": "YB_YH_Deck",
"name": "YB_YH_Deck",
"children": [],
"parent": "BatteryStation",
"type": "deck",
"class": "CoincellDeck",
"config": {
"type": "CoincellDeck",
"setup": true,
"rotation": {
"x": 0,
"y": 0,
"z": 0,
"type": "Rotation"
}
},
"data": {}
}
],
"links": []
}

22
package.xml Normal file
View File

@@ -0,0 +1,22 @@
<?xml version="1.0"?>
<?xml-model href="http://download.ros.org/schema/package_format3.xsd" schematypens="http://www.w3.org/2001/XMLSchema"?>
<package format="3">
<name>unilabos</name>
<version>0.0.0</version>
<description>ROS2 package for unilabos server</description>
<maintainer email="changjh@pku.edu.cn">changjh</maintainer>
<license>TODO: License declaration</license>
<build_depend>action_msgs</build_depend>
<exec_depend>action_msgs</exec_depend>
<member_of_group>rosidl_interface_packages</member_of_group>
<test_depend>ament_copyright</test_depend>
<test_depend>ament_flake8</test_depend>
<test_depend>ament_pep257</test_depend>
<test_depend>python3-pytest</test_depend>
<export>
<build_type>ament_python</build_type>
</export>
</package>

View File

@@ -1,6 +1,6 @@
package:
name: ros-humble-unilabos-msgs
version: 0.10.7
version: 0.10.6
source:
path: ../../unilabos_msgs
target_directory: src

View File

@@ -1,6 +1,6 @@
package:
name: unilabos
version: "0.10.7"
version: "0.10.6"
source:
path: ../..

View File

@@ -1,190 +0,0 @@
#!/usr/bin/env python3
"""
Create Distribution Package README
===================================
Generate README.txt for conda-pack distribution packages.
Usage:
python create_readme.py <platform> <branch> <output_file>
Arguments:
platform: Platform identifier (win-64, linux-64, osx-64, osx-arm64)
branch: Git branch name
output_file: Output file path (e.g., dist-package/README.txt)
Example:
python create_readme.py win-64 dev dist-package/README.txt
"""
import argparse
import sys
from datetime import datetime, timezone
from pathlib import Path
def get_readme_content(platform: str, branch: str) -> str:
"""
Generate README content for the specified platform.
Args:
platform: Platform identifier
branch: Git branch name
Returns:
str: README content
"""
# Get current UTC time
build_date = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
# Determine platform-specific content
is_windows = platform == "win-64"
if is_windows:
archive_ext = "zip"
install_script = "install_unilab.bat"
platform_instructions = """Windows:
1. Extract the downloaded ZIP file
2. Double-click install_unilab.bat (or run in cmd)
3. Follow the prompts"""
else:
archive_ext = "tar.gz"
install_script = "install_unilab.sh"
platform_name = {"linux-64": "linux-64", "osx-64": "osx-64", "osx-arm64": "osx-arm64"}.get(platform, platform)
platform_instructions = f"""macOS/Linux:
1. Download and extract unilab-pack-{platform_name}.tar.gz
2. Run: bash install_unilab.sh
3. Follow the prompts
Alternative (if downloaded from GitHub Actions):
1. Extract the artifact ZIP file
2. Extract unilab-pack-{platform_name}.tar.gz inside
3. Run: bash install_unilab.sh"""
# Generate README content
readme = f"""UniLabOS Conda-Pack Environment
================================
This package contains a pre-built UniLabOS environment.
Installation Instructions:
--------------------------
{platform_instructions}
The installation script will:
- Automatically find your conda installation
- Extract the environment to conda's envs/unilab directory
- Run conda-unpack to finalize setup
After installation:
conda activate unilab
python verify_installation.py
Verification:
-------------
The verify_installation.py script will check:
- Python version (3.11.11)
- ROS2 rclpy installation
- UniLabOS installation and dependencies
If all checks pass, you're ready to use UniLabOS!
Package Contents:
-----------------
- {install_script} (automatic installation script)
- unilab-env-{platform}.tar.gz (packed conda environment)
- verify_installation.py (environment verification tool)
- README.txt (this file)
Build Information:
------------------
Branch: {branch}
Platform: {platform}
Python: 3.11.11
Date: {build_date}
Troubleshooting:
----------------
If installation fails:
1. Ensure conda or mamba is installed
Check: conda --version
2. Verify you have sufficient disk space
Required: ~5-10 GB after extraction
3. Check installation permissions
You need write access to conda's envs directory
4. For detailed logs, run the install script from terminal
For more help:
- Documentation: docs/user_guide/installation.md
- Quick Start: QUICK_START_CONDA_PACK.md
- Issues: https://github.com/dptech-corp/Uni-Lab-OS/issues
License:
--------
UniLabOS is licensed under GPL-3.0-only.
See LICENSE file for details.
Repository: https://github.com/dptech-corp/Uni-Lab-OS
"""
return readme
def main():
"""Main entry point."""
parser = argparse.ArgumentParser(
description="Generate README.txt for conda-pack distribution",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
python create_readme.py win-64 dev dist-package/README.txt
python create_readme.py linux-64 main dist-package/README.txt
""",
)
parser.add_argument("platform", choices=["win-64", "linux-64", "osx-64", "osx-arm64"], help="Platform identifier")
parser.add_argument("branch", help="Git branch name")
parser.add_argument("output_file", help="Output file path")
args = parser.parse_args()
try:
# Generate README content
readme_content = get_readme_content(args.platform, args.branch)
# Create output directory if needed
output_path = Path(args.output_file)
output_path.parent.mkdir(parents=True, exist_ok=True)
# Write README file
with open(output_path, "w", encoding="utf-8") as f:
f.write(readme_content)
print(f" README.txt created: {output_path}")
print(f" Platform: {args.platform}")
print(f" Branch: {args.branch}")
return 0
except Exception as e:
print(f"Error creating README: {e}", file=sys.stderr)
import traceback
traceback.print_exc()
return 1
if __name__ == "__main__":
sys.exit(main())

View File

@@ -1,148 +0,0 @@
#!/usr/bin/env python3
"""
Create ZIP Archive with ZIP64 Support
======================================
This script creates a ZIP archive with ZIP64 support for large files (>2GB).
It's used in the conda-pack build workflow to package the distribution.
PowerShell's Compress-Archive has a 2GB limitation, so we use Python's zipfile
module with allowZip64=True to handle large conda-packed environments.
Usage:
python create_zip_archive.py <source_dir> <output_zip> [--compression-level LEVEL]
Arguments:
source_dir: Directory to compress
output_zip: Output ZIP file path
--compression-level: Compression level (0-9, default: 6)
Example:
python create_zip_archive.py dist-package unilab-pack-win-64.zip
"""
import argparse
import os
import sys
import zipfile
from pathlib import Path
def create_zip_archive(source_dir: str, output_zip: str, compression_level: int = 6) -> bool:
"""
Create a ZIP archive with ZIP64 support.
Args:
source_dir: Directory to compress
output_zip: Output ZIP file path
compression_level: Compression level (0-9)
Returns:
bool: True if successful
"""
try:
source_path = Path(source_dir)
output_path = Path(output_zip)
# Validate source directory
if not source_path.exists():
print(f"Error: Source directory does not exist: {source_dir}", file=sys.stderr)
return False
if not source_path.is_dir():
print(f"Error: Source path is not a directory: {source_dir}", file=sys.stderr)
return False
# Remove existing output file if present
if output_path.exists():
print(f"Removing existing archive: {output_path}")
output_path.unlink()
# Create ZIP archive
print("=" * 70)
print(f"Creating ZIP archive with ZIP64 support")
print(f" Source: {source_path.absolute()}")
print(f" Output: {output_path.absolute()}")
print(f" Compression: Level {compression_level}")
print("=" * 70)
total_size = 0
file_count = 0
with zipfile.ZipFile(
output_path, "w", zipfile.ZIP_DEFLATED, allowZip64=True, compresslevel=compression_level
) as zipf:
# Walk through source directory
for root, dirs, files in os.walk(source_dir):
for file in files:
file_path = os.path.join(root, file)
arcname = os.path.relpath(file_path, source_dir)
file_size = os.path.getsize(file_path)
# Add file to archive
zipf.write(file_path, arcname)
# Display progress
total_size += file_size
file_count += 1
print(f" [{file_count:3d}] Adding: {arcname:50s} {file_size:>15,} bytes")
# Get final archive size
archive_size = output_path.stat().st_size
compression_ratio = (1 - archive_size / total_size) * 100 if total_size > 0 else 0
# Display summary
print("=" * 70)
print("Archive created successfully!")
print(f" Files added: {file_count}")
print(f" Total size (uncompressed): {total_size:>15,} bytes ({total_size / (1024**3):.2f} GB)")
print(f" Archive size (compressed): {archive_size:>15,} bytes ({archive_size / (1024**3):.2f} GB)")
print(f" Compression ratio: {compression_ratio:.1f}%")
print("=" * 70)
return True
except Exception as e:
print(f"Error creating ZIP archive: {e}", file=sys.stderr)
import traceback
traceback.print_exc()
return False
def main():
"""Main entry point."""
parser = argparse.ArgumentParser(
description="Create ZIP archive with ZIP64 support for large files",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
python create_zip_archive.py dist-package unilab-pack-win-64.zip
python create_zip_archive.py dist-package unilab-pack-win-64.zip --compression-level 9
""",
)
parser.add_argument("source_dir", help="Directory to compress")
parser.add_argument("output_zip", help="Output ZIP file path")
parser.add_argument(
"--compression-level",
type=int,
default=6,
choices=range(0, 10),
metavar="LEVEL",
help="Compression level (0=no compression, 9=maximum compression, default=6)",
)
args = parser.parse_args()
# Create archive
success = create_zip_archive(args.source_dir, args.output_zip, args.compression_level)
# Exit with appropriate code
sys.exit(0 if success else 1)
if __name__ == "__main__":
main()

View File

@@ -1,203 +0,0 @@
@echo off
setlocal enabledelayedexpansion
echo ================================================
echo UniLabOS Environment Installation Script
echo ================================================
echo.
REM Get the directory where this script is located
set "SCRIPT_DIR=%~dp0"
cd /d "%SCRIPT_DIR%"
REM Find conda installation
echo Searching for conda installation...
REM Method 1: Try to get conda base using 'conda info --base'
set "CONDA_BASE="
for /f "tokens=*" %%i in ('conda info --base 2^>nul') do (
set "CONDA_BASE=%%i"
)
if not "%CONDA_BASE%"=="" (
echo Found conda at: %CONDA_BASE% (via conda info)
goto :conda_found
)
REM Method 2: Use 'where conda' and parse the path
echo Trying alternative method...
for /f "tokens=*" %%i in ('where conda 2^>nul') do (
set "CONDA_PATH=%%i"
goto :parse_conda_path
)
echo ERROR: Could not find conda installation!
echo Please make sure conda/mamba is installed and in your PATH.
echo.
pause
exit /b 1
:parse_conda_path
REM Parse conda path to find base directory
REM Common paths:
REM C:\Users\hp\miniforge3\Library\bin\conda.bat
REM C:\Users\hp\miniforge3\Scripts\conda.exe
REM C:\Users\hp\miniforge3\condabin\conda.bat
echo Found conda executable at: %CONDA_PATH%
REM Check if path contains \Library\bin\ (typical for conda.bat)
echo %CONDA_PATH% | findstr /C:"\Library\bin\" >nul
if not errorlevel 1 (
REM Path like: C:\Users\hp\miniforge3\Library\bin\conda.bat
REM Need to go up 3 levels: bin -> Library -> miniforge3
for %%i in ("%CONDA_PATH%") do set "CONDA_BASE=%%~dpi"
for %%i in ("%CONDA_BASE%..\..\..") do set "CONDA_BASE=%%~fi"
goto :conda_found
)
REM Check if path contains \Scripts\ (typical for conda.exe)
echo %CONDA_PATH% | findstr /C:"\Scripts\" >nul
if not errorlevel 1 (
REM Path like: C:\Users\hp\miniforge3\Scripts\conda.exe
REM Need to go up 2 levels: Scripts -> miniforge3
for %%i in ("%CONDA_PATH%") do set "CONDA_BASE=%%~dpi"
for %%i in ("%CONDA_BASE%..\.") do set "CONDA_BASE=%%~fi"
goto :conda_found
)
REM Check if path contains \condabin\ (typical for conda.bat)
echo %CONDA_PATH% | findstr /C:"\condabin\" >nul
if not errorlevel 1 (
REM Path like: C:\Users\hp\miniforge3\condabin\conda.bat
REM Need to go up 2 levels: condabin -> miniforge3
for %%i in ("%CONDA_PATH%") do set "CONDA_BASE=%%~dpi"
for %%i in ("%CONDA_BASE%..\.") do set "CONDA_BASE=%%~fi"
goto :conda_found
)
REM Default: assume it's 2 levels up
for %%i in ("%CONDA_PATH%") do set "CONDA_BASE=%%~dpi"
for %%i in ("%CONDA_BASE%..\.") do set "CONDA_BASE=%%~fi"
:conda_found
echo Found conda base directory: %CONDA_BASE%
echo.
REM Set target environment path
set "ENV_NAME=unilab"
set "ENV_PATH=%CONDA_BASE%\envs\%ENV_NAME%"
REM Check if environment already exists
if exist "%ENV_PATH%" (
echo WARNING: Environment '%ENV_NAME%' already exists at %ENV_PATH%
echo.
set /p "OVERWRITE=Do you want to overwrite it? (y/n): "
if /i not "!OVERWRITE!"=="y" (
echo Installation cancelled.
pause
exit /b 0
)
echo Removing existing environment...
rmdir /s /q "%ENV_PATH%"
)
REM Find the packed environment file
set "PACK_FILE="
for %%f in (unilab-env*.tar.gz) do (
set "PACK_FILE=%%f"
goto :found_pack
)
:found_pack
if "%PACK_FILE%"=="" (
echo ERROR: Could not find unilab-env*.tar.gz file!
echo Please make sure the packed environment file is in the same directory as this script.
echo.
pause
exit /b 1
)
echo Found packed environment: %PACK_FILE%
echo.
REM Extract the packed environment
echo Extracting environment to %ENV_PATH%...
mkdir "%ENV_PATH%"
REM Extract using tar (available in Windows 10+)
tar -xzf "%PACK_FILE%" -C "%ENV_PATH%"
if errorlevel 1 (
echo ERROR: Failed to extract environment!
echo Make sure you have Windows 10 or later with tar support.
pause
exit /b 1
)
echo.
echo Unpacking conda environment...
echo Changing to environment directory: %ENV_PATH%
cd /d "%ENV_PATH%"
REM Run conda-unpack from the environment directory
if exist "Scripts\conda-unpack.exe" (
echo Running: .\Scripts\conda-unpack.exe
.\Scripts\conda-unpack.exe
) else if exist "Scripts\activate.bat" (
echo Running: .\Scripts\activate.bat followed by conda-unpack
call .\Scripts\activate.bat
conda-unpack
) else (
echo ERROR: Could not find Scripts\conda-unpack.exe or Scripts\activate.bat!
echo Current directory: %CD%
echo Expected location: %ENV_PATH%\Scripts\
pause
exit /b 1
)
if errorlevel 1 (
echo ERROR: conda-unpack failed!
pause
exit /b 1
)
echo.
echo Checking UniLabOS entry point...
REM Check if unilab-script.py exists
set "UNILAB_SCRIPT=%ENV_PATH%\Scripts\unilab-script.py"
if not exist "%UNILAB_SCRIPT%" (
echo WARNING: unilab-script.py not found, creating it...
(
echo # -*- coding: utf-8 -*-
echo import re
echo import sys
echo.
echo from unilabos.app.main import main
echo.
echo if __name__ == '__main__':
echo sys.argv[0] = re.sub^(r'(-script\.pyw?^|\.exe^)?$', '', sys.argv[0]^)
echo sys.exit^(main^(^)^)
) > "%UNILAB_SCRIPT%"
echo Created: %UNILAB_SCRIPT%
) else (
echo Found: %UNILAB_SCRIPT%
)
echo.
echo ================================================
echo Installation completed successfully!
echo ================================================
echo.
echo To activate the environment, run:
echo conda activate %ENV_NAME%
echo.
echo or
echo.
echo call %ENV_PATH%\Scripts\activate.bat
echo.
echo You can verify the installation by running:
echo cd /d "%SCRIPT_DIR%"
echo python verify_installation.py
echo.
pause

View File

@@ -1,139 +0,0 @@
#!/bin/bash
set -e
echo "================================================"
echo "UniLabOS Environment Installation Script"
echo "================================================"
echo ""
# Get the directory where this script is located
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
cd "$SCRIPT_DIR"
# Find conda installation
echo "Searching for conda installation..."
CONDA_BASE=""
# Try to find conda in PATH
if command -v conda &> /dev/null; then
CONDA_BASE=$(conda info --base)
echo "Found conda at: $CONDA_BASE"
elif [ -d "$HOME/miniforge3" ]; then
CONDA_BASE="$HOME/miniforge3"
echo "Found conda at: $CONDA_BASE"
elif [ -d "$HOME/miniconda3" ]; then
CONDA_BASE="$HOME/miniconda3"
echo "Found conda at: $CONDA_BASE"
elif [ -d "$HOME/anaconda3" ]; then
CONDA_BASE="$HOME/anaconda3"
echo "Found conda at: $CONDA_BASE"
elif [ -d "/opt/conda" ]; then
CONDA_BASE="/opt/conda"
echo "Found conda at: $CONDA_BASE"
else
echo "ERROR: Could not find conda installation!"
echo "Please make sure conda/mamba is installed."
exit 1
fi
echo ""
# Initialize conda for this shell
if [ -f "$CONDA_BASE/etc/profile.d/conda.sh" ]; then
source "$CONDA_BASE/etc/profile.d/conda.sh"
fi
# Set target environment path
ENV_NAME="unilab"
ENV_PATH="$CONDA_BASE/envs/$ENV_NAME"
# Check if environment already exists
if [ -d "$ENV_PATH" ]; then
echo "WARNING: Environment '$ENV_NAME' already exists at $ENV_PATH"
read -p "Do you want to overwrite it? (y/n): " OVERWRITE
if [ "$OVERWRITE" != "y" ] && [ "$OVERWRITE" != "Y" ]; then
echo "Installation cancelled."
exit 0
fi
echo "Removing existing environment..."
rm -rf "$ENV_PATH"
fi
# Find the packed environment file
PACK_FILE=$(ls unilab-env*.tar.gz 2>/dev/null | head -n 1)
if [ -z "$PACK_FILE" ]; then
echo "ERROR: Could not find unilab-env*.tar.gz file!"
echo "Please make sure the packed environment file is in the same directory as this script."
exit 1
fi
echo "Found packed environment: $PACK_FILE"
echo ""
# Extract the packed environment
echo "Extracting environment to $ENV_PATH..."
mkdir -p "$ENV_PATH"
tar -xzf "$PACK_FILE" -C "$ENV_PATH"
echo ""
echo "Unpacking conda environment..."
echo "Changing to environment directory: $ENV_PATH"
cd "$ENV_PATH"
# Run conda-unpack from the environment directory
if [ -f "bin/conda-unpack" ]; then
echo "Running: ./bin/conda-unpack"
./bin/conda-unpack
elif [ -f "bin/activate" ]; then
echo "Running: source bin/activate followed by conda-unpack"
source bin/activate
conda-unpack
else
echo "ERROR: Could not find bin/conda-unpack or bin/activate!"
echo "Current directory: $(pwd)"
echo "Expected location: $ENV_PATH/bin/"
exit 1
fi
echo ""
echo "Checking UniLabOS entry point..."
# Check if unilab script exists in bin directory
UNILAB_SCRIPT="$ENV_PATH/bin/unilab"
if [ ! -f "$UNILAB_SCRIPT" ]; then
echo "WARNING: unilab script not found, creating it..."
cat > "$UNILAB_SCRIPT" << 'EOF'
#!/usr/bin/env python
# -*- coding: utf-8 -*-
import re
import sys
from unilabos.app.main import main
if __name__ == '__main__':
sys.argv[0] = re.sub(r'(-script\.pyw?|\.exe)?$', '', sys.argv[0])
sys.exit(main())
EOF
chmod +x "$UNILAB_SCRIPT"
echo "Created: $UNILAB_SCRIPT"
else
echo "Found: $UNILAB_SCRIPT"
fi
echo ""
echo "================================================"
echo "Installation completed successfully!"
echo "================================================"
echo ""
echo "To activate the environment, run:"
echo " conda activate $ENV_NAME"
echo ""
echo "or"
echo ""
echo " source $ENV_PATH/bin/activate"
echo ""
echo "You can verify the installation by running:"
echo " cd $SCRIPT_DIR"
echo " python verify_installation.py"
echo ""

View File

@@ -1,175 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
UniLabOS Installation Verification Script
=========================================
This script verifies that UniLabOS and its dependencies are correctly installed.
Run this script after installing the conda-pack environment to ensure everything works.
Usage:
python verify_installation.py [--auto-install]
Options:
--auto-install Automatically install missing packages
Or in the conda environment:
conda activate unilab
python verify_installation.py
"""
import sys
import os
import argparse
# IMPORTANT: Set UTF-8 encoding BEFORE any other imports
# This ensures all subsequent imports (including unilabos) can output UTF-8 characters
if sys.platform == "win32":
# Method 1: Reconfigure stdout/stderr to use UTF-8 with error handling
try:
sys.stdout.reconfigure(encoding="utf-8", errors="replace") # type: ignore
sys.stderr.reconfigure(encoding="utf-8", errors="replace") # type: ignore
except (AttributeError, OSError):
pass
# Method 2: Set environment variable for subprocess and console
os.environ["PYTHONIOENCODING"] = "utf-8"
# Method 3: Try to change Windows console code page to UTF-8
try:
import ctypes
# Set console code page to UTF-8 (CP 65001)
ctypes.windll.kernel32.SetConsoleCP(65001)
ctypes.windll.kernel32.SetConsoleOutputCP(65001)
except (ImportError, AttributeError, OSError):
pass
# Now import other modules
import importlib
# Use ASCII-safe symbols that work across all platforms
CHECK_MARK = "[OK]"
CROSS_MARK = "[FAIL]"
def check_package(package_name: str, display_name: str | None = None) -> bool:
"""
Check if a package can be imported.
Args:
package_name: Name of the package to import
display_name: Display name (defaults to package_name)
Returns:
bool: True if package is available
"""
if display_name is None:
display_name = package_name
try:
importlib.import_module(package_name)
print(f" {CHECK_MARK} {display_name}")
return True
except ImportError:
print(f" {CROSS_MARK} {display_name}")
return False
def check_python_version() -> bool:
"""Check Python version."""
version = sys.version_info
version_str = f"{version.major}.{version.minor}.{version.micro}"
if version.major == 3 and version.minor >= 11:
print(f" {CHECK_MARK} Python {version_str}")
return True
else:
print(f" {CROSS_MARK} Python {version_str} (requires Python 3.11+)")
return False
def main():
"""Run all verification checks."""
# Parse command line arguments
parser = argparse.ArgumentParser(
description="Verify UniLabOS installation",
formatter_class=argparse.RawDescriptionHelpFormatter,
)
parser.add_argument(
"--auto-install",
action="store_true",
help="Automatically install missing packages",
)
args = parser.parse_args()
print("=" * 60)
print("UniLabOS Installation Verification")
print("=" * 60)
if args.auto_install:
print("Mode: Auto-install missing packages")
else:
print("Mode: Verification only")
print()
all_passed = True
# Check Python version
print("Checking Python version...")
if not check_python_version():
all_passed = False
print()
# Check ROS2 rclpy
print("Checking ROS2 rclpy...")
if not check_package("rclpy", "ROS2 rclpy"):
all_passed = False
print()
# Run environment checker from unilabos
print("Checking UniLabOS and dependencies...")
try:
from unilabos.utils.environment_check import check_environment
print(f" {CHECK_MARK} UniLabOS installed")
# Check environment with optional auto-install
# Set show_details=False to suppress detailed Chinese output that may cause encoding issues
env_check_passed = check_environment(auto_install=args.auto_install, show_details=False)
if env_check_passed:
print(f" {CHECK_MARK} All required packages available")
else:
print(f" {CROSS_MARK} Some optional packages are missing")
if not args.auto_install:
print(" Hint: Run with --auto-install to automatically install missing packages")
except ImportError:
print(f" {CROSS_MARK} UniLabOS not installed")
all_passed = False
except Exception as e:
print(f" {CROSS_MARK} Environment check failed: {str(e)}")
print()
# Summary
print("=" * 60)
print("Verification Summary")
print("=" * 60)
if all_passed:
print(f"\n{CHECK_MARK} All checks passed! Your UniLabOS installation is ready.")
print("\nNext steps:")
print(" 1. Review the documentation: docs/user_guide/launch.md")
print(" 2. Try the examples: docs/boot_examples/")
print(" 3. Configure your devices: unilabos_data/startup_config.json")
return 0
else:
print(f"\n{CROSS_MARK} Some checks failed. Please review the errors above.")
print("\nTroubleshooting:")
print(" 1. Ensure you're in the correct conda environment: conda activate unilab")
print(" 2. Check the installation documentation: docs/user_guide/installation.md")
print(" 3. Try reinstalling: pip install .")
return 1
if __name__ == "__main__":
sys.exit(main())

View File

@@ -1,695 +0,0 @@
import json
import logging
import traceback
import uuid
import xml.etree.ElementTree as ET
from typing import Any, Dict, List
import networkx as nx
import matplotlib.pyplot as plt
import requests
logger = logging.getLogger(__name__)
class SimpleGraph:
"""简单的有向图实现,用于构建工作流图"""
def __init__(self):
self.nodes = {}
self.edges = []
def add_node(self, node_id, **attrs):
"""添加节点"""
self.nodes[node_id] = attrs
def add_edge(self, source, target, **attrs):
"""添加边"""
edge = {"source": source, "target": target, **attrs}
self.edges.append(edge)
def to_dict(self):
"""转换为工作流图格式"""
nodes_list = []
for node_id, attrs in self.nodes.items():
node_attrs = attrs.copy()
params = node_attrs.pop("parameters", {}) or {}
node_attrs.update(params)
nodes_list.append({"id": node_id, **node_attrs})
return {
"directed": True,
"multigraph": False,
"graph": {},
"nodes": nodes_list,
"links": self.edges,
}
def extract_json_from_markdown(text: str) -> str:
"""从markdown代码块中提取JSON"""
text = text.strip()
if text.startswith("```json\n"):
text = text[8:]
if text.startswith("```\n"):
text = text[4:]
if text.endswith("\n```"):
text = text[:-4]
return text
def convert_to_type(val: str) -> Any:
"""将字符串值转换为适当的数据类型"""
if val == "True":
return True
if val == "False":
return False
if val == "?":
return None
if val.endswith(" g"):
return float(val.split(" ")[0])
if val.endswith("mg"):
return float(val.split("mg")[0])
elif val.endswith("mmol"):
return float(val.split("mmol")[0]) / 1000
elif val.endswith("mol"):
return float(val.split("mol")[0])
elif val.endswith("ml"):
return float(val.split("ml")[0])
elif val.endswith("RPM"):
return float(val.split("RPM")[0])
elif val.endswith(" °C"):
return float(val.split(" ")[0])
elif val.endswith(" %"):
return float(val.split(" ")[0])
return val
def refactor_data(data: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
"""统一的数据重构函数,根据操作类型自动选择模板"""
refactored_data = []
# 定义操作映射,包含生物实验和有机化学的所有操作
OPERATION_MAPPING = {
# 生物实验操作
"transfer_liquid": "SynBioFactory-liquid_handler.prcxi-transfer_liquid",
"transfer": "SynBioFactory-liquid_handler.biomek-transfer",
"incubation": "SynBioFactory-liquid_handler.biomek-incubation",
"move_labware": "SynBioFactory-liquid_handler.biomek-move_labware",
"oscillation": "SynBioFactory-liquid_handler.biomek-oscillation",
# 有机化学操作
"HeatChillToTemp": "SynBioFactory-workstation-HeatChillProtocol",
"StopHeatChill": "SynBioFactory-workstation-HeatChillStopProtocol",
"StartHeatChill": "SynBioFactory-workstation-HeatChillStartProtocol",
"HeatChill": "SynBioFactory-workstation-HeatChillProtocol",
"Dissolve": "SynBioFactory-workstation-DissolveProtocol",
"Transfer": "SynBioFactory-workstation-TransferProtocol",
"Evaporate": "SynBioFactory-workstation-EvaporateProtocol",
"Recrystallize": "SynBioFactory-workstation-RecrystallizeProtocol",
"Filter": "SynBioFactory-workstation-FilterProtocol",
"Dry": "SynBioFactory-workstation-DryProtocol",
"Add": "SynBioFactory-workstation-AddProtocol",
}
UNSUPPORTED_OPERATIONS = ["Purge", "Wait", "Stir", "ResetHandling"]
for step in data:
operation = step.get("action")
if not operation or operation in UNSUPPORTED_OPERATIONS:
continue
# 处理重复操作
if operation == "Repeat":
times = step.get("times", step.get("parameters", {}).get("times", 1))
sub_steps = step.get("steps", step.get("parameters", {}).get("steps", []))
for i in range(int(times)):
sub_data = refactor_data(sub_steps)
refactored_data.extend(sub_data)
continue
# 获取模板名称
template = OPERATION_MAPPING.get(operation)
if not template:
# 自动推断模板类型
if operation.lower() in ["transfer", "incubation", "move_labware", "oscillation"]:
template = f"SynBioFactory-liquid_handler.biomek-{operation}"
else:
template = f"SynBioFactory-workstation-{operation}Protocol"
# 创建步骤数据
step_data = {
"template": template,
"description": step.get("description", step.get("purpose", f"{operation} operation")),
"lab_node_type": "Device",
"parameters": step.get("parameters", step.get("action_args", {})),
}
refactored_data.append(step_data)
return refactored_data
def build_protocol_graph(
labware_info: List[Dict[str, Any]], protocol_steps: List[Dict[str, Any]], workstation_name: str
) -> SimpleGraph:
"""统一的协议图构建函数,根据设备类型自动选择构建逻辑"""
G = SimpleGraph()
resource_last_writer = {}
LAB_NAME = "SynBioFactory"
protocol_steps = refactor_data(protocol_steps)
# 检查协议步骤中的模板来判断协议类型
has_biomek_template = any(
("biomek" in step.get("template", "")) or ("prcxi" in step.get("template", ""))
for step in protocol_steps
)
if has_biomek_template:
# 生物实验协议图构建
for labware_id, labware in labware_info.items():
node_id = str(uuid.uuid4())
labware_attrs = labware.copy()
labware_id = labware_attrs.pop("id", labware_attrs.get("name", f"labware_{uuid.uuid4()}"))
labware_attrs["description"] = labware_id
labware_attrs["lab_node_type"] = (
"Reagent" if "Plate" in str(labware_id) else "Labware" if "Rack" in str(labware_id) else "Sample"
)
labware_attrs["device_id"] = workstation_name
G.add_node(node_id, template=f"{LAB_NAME}-host_node-create_resource", **labware_attrs)
resource_last_writer[labware_id] = f"{node_id}:labware"
# 处理协议步骤
prev_node = None
for i, step in enumerate(protocol_steps):
node_id = str(uuid.uuid4())
G.add_node(node_id, **step)
# 添加控制流边
if prev_node is not None:
G.add_edge(prev_node, node_id, source_port="ready", target_port="ready")
prev_node = node_id
# 处理物料流
params = step.get("parameters", {})
if "sources" in params and params["sources"] in resource_last_writer:
source_node, source_port = resource_last_writer[params["sources"]].split(":")
G.add_edge(source_node, node_id, source_port=source_port, target_port="labware")
if "targets" in params:
resource_last_writer[params["targets"]] = f"{node_id}:labware"
# 添加协议结束节点
end_id = str(uuid.uuid4())
G.add_node(end_id, template=f"{LAB_NAME}-liquid_handler.biomek-run_protocol")
if prev_node is not None:
G.add_edge(prev_node, end_id, source_port="ready", target_port="ready")
else:
# 有机化学协议图构建
WORKSTATION_ID = workstation_name
# 为所有labware创建资源节点
for item_id, item in labware_info.items():
# item_id = item.get("id") or item.get("name", f"item_{uuid.uuid4()}")
node_id = str(uuid.uuid4())
# 判断节点类型
if item.get("type") == "hardware" or "reactor" in str(item_id).lower():
if "reactor" not in str(item_id).lower():
continue
lab_node_type = "Sample"
description = f"Prepare Reactor: {item_id}"
liquid_type = []
liquid_volume = []
else:
lab_node_type = "Reagent"
description = f"Add Reagent to Flask: {item_id}"
liquid_type = [item_id]
liquid_volume = [1e5]
G.add_node(
node_id,
template=f"{LAB_NAME}-host_node-create_resource",
description=description,
lab_node_type=lab_node_type,
res_id=item_id,
device_id=WORKSTATION_ID,
class_name="container",
parent=WORKSTATION_ID,
bind_locations={"x": 0.0, "y": 0.0, "z": 0.0},
liquid_input_slot=[-1],
liquid_type=liquid_type,
liquid_volume=liquid_volume,
slot_on_deck="",
role=item.get("role", ""),
)
resource_last_writer[item_id] = f"{node_id}:labware"
last_control_node_id = None
# 处理协议步骤
for step in protocol_steps:
node_id = str(uuid.uuid4())
G.add_node(node_id, **step)
# 控制流
if last_control_node_id is not None:
G.add_edge(last_control_node_id, node_id, source_port="ready", target_port="ready")
last_control_node_id = node_id
# 物料流
params = step.get("parameters", {})
input_resources = {
"Vessel": params.get("vessel"),
"ToVessel": params.get("to_vessel"),
"FromVessel": params.get("from_vessel"),
"reagent": params.get("reagent"),
"solvent": params.get("solvent"),
"compound": params.get("compound"),
"sources": params.get("sources"),
"targets": params.get("targets"),
}
for target_port, resource_name in input_resources.items():
if resource_name and resource_name in resource_last_writer:
source_node, source_port = resource_last_writer[resource_name].split(":")
G.add_edge(source_node, node_id, source_port=source_port, target_port=target_port)
output_resources = {
"VesselOut": params.get("vessel"),
"FromVesselOut": params.get("from_vessel"),
"ToVesselOut": params.get("to_vessel"),
"FiltrateOut": params.get("filtrate_vessel"),
"reagent": params.get("reagent"),
"solvent": params.get("solvent"),
"compound": params.get("compound"),
"sources_out": params.get("sources"),
"targets_out": params.get("targets"),
}
for source_port, resource_name in output_resources.items():
if resource_name:
resource_last_writer[resource_name] = f"{node_id}:{source_port}"
return G
def draw_protocol_graph(protocol_graph: SimpleGraph, output_path: str):
"""
(辅助功能) 使用 networkx 和 matplotlib 绘制协议工作流图,用于可视化。
"""
if not protocol_graph:
print("Cannot draw graph: Graph object is empty.")
return
G = nx.DiGraph()
for node_id, attrs in protocol_graph.nodes.items():
label = attrs.get("description", attrs.get("template", node_id[:8]))
G.add_node(node_id, label=label, **attrs)
for edge in protocol_graph.edges:
G.add_edge(edge["source"], edge["target"])
plt.figure(figsize=(20, 15))
try:
pos = nx.nx_agraph.graphviz_layout(G, prog="dot")
except Exception:
pos = nx.shell_layout(G) # Fallback layout
node_labels = {node: data["label"] for node, data in G.nodes(data=True)}
nx.draw(
G,
pos,
with_labels=False,
node_size=2500,
node_color="skyblue",
node_shape="o",
edge_color="gray",
width=1.5,
arrowsize=15,
)
nx.draw_networkx_labels(G, pos, labels=node_labels, font_size=8, font_weight="bold")
plt.title("Chemical Protocol Workflow Graph", size=15)
plt.savefig(output_path, dpi=300, bbox_inches="tight")
plt.close()
print(f" - Visualization saved to '{output_path}'")
from networkx.drawing.nx_agraph import to_agraph
import re
COMPASS = {"n","e","s","w","ne","nw","se","sw","c"}
def _is_compass(port: str) -> bool:
return isinstance(port, str) and port.lower() in COMPASS
def draw_protocol_graph_with_ports(protocol_graph, output_path: str, rankdir: str = "LR"):
"""
使用 Graphviz 端口语法绘制协议工作流图。
- 若边上的 source_port/target_port 是 compassn/e/s/w/...),直接用 compass。
- 否则自动为节点创建 record 形状并定义命名端口 <portname>。
最终由 PyGraphviz 渲染并输出到 output_path后缀决定格式如 .png/.svg/.pdf
"""
if not protocol_graph:
print("Cannot draw graph: Graph object is empty.")
return
# 1) 先用 networkx 搭建有向图,保留端口属性
G = nx.DiGraph()
for node_id, attrs in protocol_graph.nodes.items():
label = attrs.get("description", attrs.get("template", node_id[:8]))
# 保留一个干净的“中心标签”,用于放在 record 的中间槽
G.add_node(node_id, _core_label=str(label), **{k:v for k,v in attrs.items() if k not in ("label",)})
edges_data = []
in_ports_by_node = {} # 收集命名输入端口
out_ports_by_node = {} # 收集命名输出端口
for edge in protocol_graph.edges:
u = edge["source"]
v = edge["target"]
sp = edge.get("source_port")
tp = edge.get("target_port")
# 记录到图里(保留原始端口信息)
G.add_edge(u, v, source_port=sp, target_port=tp)
edges_data.append((u, v, sp, tp))
# 如果不是 compass就按“命名端口”先归类等会儿给节点造 record
if sp and not _is_compass(sp):
out_ports_by_node.setdefault(u, set()).add(str(sp))
if tp and not _is_compass(tp):
in_ports_by_node.setdefault(v, set()).add(str(tp))
# 2) 转为 AGraph使用 Graphviz 渲染
A = to_agraph(G)
A.graph_attr.update(rankdir=rankdir, splines="true", concentrate="false", fontsize="10")
A.node_attr.update(shape="box", style="rounded,filled", fillcolor="lightyellow", color="#999999", fontname="Helvetica")
A.edge_attr.update(arrowsize="0.8", color="#666666")
# 3) 为需要命名端口的节点设置 record 形状与 label
# 左列 = 输入端口;中间 = 核心标签;右列 = 输出端口
for n in A.nodes():
node = A.get_node(n)
core = G.nodes[n].get("_core_label", n)
in_ports = sorted(in_ports_by_node.get(n, []))
out_ports = sorted(out_ports_by_node.get(n, []))
# 如果该节点涉及命名端口,则用 record否则保留原 box
if in_ports or out_ports:
def port_fields(ports):
if not ports:
return " " # 必须留一个空槽占位
# 每个端口一个小格子,<p> name
return "|".join(f"<{re.sub(r'[^A-Za-z0-9_:.|-]', '_', p)}> {p}" for p in ports)
left = port_fields(in_ports)
right = port_fields(out_ports)
# 三栏:左(入) | 中(节点名) | 右(出)
record_label = f"{{ {left} | {core} | {right} }}"
node.attr.update(shape="record", label=record_label)
else:
# 没有命名端口:普通盒子,显示核心标签
node.attr.update(label=str(core))
# 4) 给边设置 headport / tailport
# - 若端口为 compass直接用 compasse.g., headport="e"
# - 若端口为命名端口:使用在 record 中定义的 <port> 名(同名即可)
for (u, v, sp, tp) in edges_data:
e = A.get_edge(u, v)
# Graphviz 属性tail 是源head 是目标
if sp:
if _is_compass(sp):
e.attr["tailport"] = sp.lower()
else:
# 与 record label 中 <port> 名一致;特殊字符已在 label 中做了清洗
e.attr["tailport"] = re.sub(r'[^A-Za-z0-9_:.|-]', '_', str(sp))
if tp:
if _is_compass(tp):
e.attr["headport"] = tp.lower()
else:
e.attr["headport"] = re.sub(r'[^A-Za-z0-9_:.|-]', '_', str(tp))
# 可选:若想让边更贴边缘,可设置 constraint/spline 等
# e.attr["arrowhead"] = "vee"
# 5) 输出
A.draw(output_path, prog="dot")
print(f" - Port-aware workflow rendered to '{output_path}'")
def flatten_xdl_procedure(procedure_elem: ET.Element) -> List[ET.Element]:
"""展平嵌套的XDL程序结构"""
flattened_operations = []
TEMP_UNSUPPORTED_PROTOCOL = ["Purge", "Wait", "Stir", "ResetHandling"]
def extract_operations(element: ET.Element):
if element.tag not in ["Prep", "Reaction", "Workup", "Purification", "Procedure"]:
if element.tag not in TEMP_UNSUPPORTED_PROTOCOL:
flattened_operations.append(element)
for child in element:
extract_operations(child)
for child in procedure_elem:
extract_operations(child)
return flattened_operations
def parse_xdl_content(xdl_content: str) -> tuple:
"""解析XDL内容"""
try:
xdl_content_cleaned = "".join(c for c in xdl_content if c.isprintable())
root = ET.fromstring(xdl_content_cleaned)
synthesis_elem = root.find("Synthesis")
if synthesis_elem is None:
return None, None, None
# 解析硬件组件
hardware_elem = synthesis_elem.find("Hardware")
hardware = []
if hardware_elem is not None:
hardware = [{"id": c.get("id"), "type": c.get("type")} for c in hardware_elem.findall("Component")]
# 解析试剂
reagents_elem = synthesis_elem.find("Reagents")
reagents = []
if reagents_elem is not None:
reagents = [{"name": r.get("name"), "role": r.get("role", "")} for r in reagents_elem.findall("Reagent")]
# 解析程序
procedure_elem = synthesis_elem.find("Procedure")
if procedure_elem is None:
return None, None, None
flattened_operations = flatten_xdl_procedure(procedure_elem)
return hardware, reagents, flattened_operations
except ET.ParseError as e:
raise ValueError(f"Invalid XDL format: {e}")
def convert_xdl_to_dict(xdl_content: str) -> Dict[str, Any]:
"""
将XDL XML格式转换为标准的字典格式
Args:
xdl_content: XDL XML内容
Returns:
转换结果,包含步骤和器材信息
"""
try:
hardware, reagents, flattened_operations = parse_xdl_content(xdl_content)
if hardware is None:
return {"error": "Failed to parse XDL content", "success": False}
# 将XDL元素转换为字典格式
steps_data = []
for elem in flattened_operations:
# 转换参数类型
parameters = {}
for key, val in elem.attrib.items():
converted_val = convert_to_type(val)
if converted_val is not None:
parameters[key] = converted_val
step_dict = {
"operation": elem.tag,
"parameters": parameters,
"description": elem.get("purpose", f"Operation: {elem.tag}"),
}
steps_data.append(step_dict)
# 合并硬件和试剂为统一的labware_info格式
labware_data = []
labware_data.extend({"id": hw["id"], "type": "hardware", **hw} for hw in hardware)
labware_data.extend({"name": reagent["name"], "type": "reagent", **reagent} for reagent in reagents)
return {
"success": True,
"steps": steps_data,
"labware": labware_data,
"message": f"Successfully converted XDL to dict format. Found {len(steps_data)} steps and {len(labware_data)} labware items.",
}
except Exception as e:
error_msg = f"XDL conversion failed: {str(e)}"
logger.error(error_msg)
return {"error": error_msg, "success": False}
def create_workflow(
steps_info: str,
labware_info: str,
workflow_name: str = "Generated Workflow",
workstation_name: str = "workstation",
workflow_description: str = "Auto-generated workflow from protocol",
) -> Dict[str, Any]:
"""
创建工作流,输入数据已经是统一的字典格式
Args:
steps_info: 步骤信息 (JSON字符串已经是list of dict格式)
labware_info: 实验器材和试剂信息 (JSON字符串已经是list of dict格式)
workflow_name: 工作流名称
workflow_description: 工作流描述
Returns:
创建结果包含工作流UUID和详细信息
"""
try:
# 直接解析JSON数据
steps_info_clean = extract_json_from_markdown(steps_info)
labware_info_clean = extract_json_from_markdown(labware_info)
steps_data = json.loads(steps_info_clean)
labware_data = json.loads(labware_info_clean)
# 统一处理所有数据
protocol_graph = build_protocol_graph(labware_data, steps_data, workstation_name=workstation_name)
# 检测协议类型(用于标签)
protocol_type = "bio" if any("biomek" in step.get("template", "") for step in refactored_steps) else "organic"
# 转换为工作流格式
data = protocol_graph.to_dict()
# 转换节点格式
for i, node in enumerate(data["nodes"]):
description = node.get("description", "")
onode = {
"template": node.pop("template"),
"id": node["id"],
"lab_node_type": node.get("lab_node_type", "Device"),
"name": description or f"Node {i + 1}",
"params": {"default": node},
"handles": {},
}
# 处理边连接
for edge in data["links"]:
if edge["source"] == node["id"]:
source_port = edge.get("source_port", "output")
if source_port not in onode["handles"]:
onode["handles"][source_port] = {"type": "source"}
if edge["target"] == node["id"]:
target_port = edge.get("target_port", "input")
if target_port not in onode["handles"]:
onode["handles"][target_port] = {"type": "target"}
data["nodes"][i] = onode
# 发送到API创建工作流
api_secret = configs.Lab.Key
if not api_secret:
return {"error": "API SecretKey is not configured", "success": False}
# Step 1: 创建工作流
workflow_url = f"{configs.Lab.Api}/api/v1/workflow/"
headers = {
"Content-Type": "application/json",
}
params = {"secret_key": api_secret}
graph_data = {"name": workflow_name, **data}
logger.info(f"Creating workflow: {workflow_name}")
response = requests.post(
workflow_url, params=params, json=graph_data, headers=headers, timeout=configs.Lab.Timeout
)
response.raise_for_status()
workflow_info = response.json()
if workflow_info.get("code") != 0:
error_msg = f"API returned an error: {workflow_info.get('msg', 'Unknown Error')}"
logger.error(error_msg)
return {"error": error_msg, "success": False}
workflow_uuid = workflow_info.get("data", {}).get("uuid")
if not workflow_uuid:
return {"error": "Failed to get workflow UUID from response", "success": False}
# Step 2: 添加到模板库(可选)
try:
library_url = f"{configs.Lab.Api}/api/flociety/vs/workflows/library/"
lib_payload = {
"workflow_uuid": workflow_uuid,
"title": workflow_name,
"description": workflow_description,
"labels": [protocol_type.title(), "Auto-generated"],
}
library_response = requests.post(
library_url, params=params, json=lib_payload, headers=headers, timeout=configs.Lab.Timeout
)
library_response.raise_for_status()
library_info = library_response.json()
logger.info(f"Workflow added to library: {library_info}")
return {
"success": True,
"workflow_uuid": workflow_uuid,
"workflow_info": workflow_info.get("data"),
"library_info": library_info.get("data"),
"protocol_type": protocol_type,
"message": f"Workflow '{workflow_name}' created successfully",
}
except Exception as e:
# 即使添加到库失败,工作流创建仍然成功
logger.warning(f"Failed to add workflow to library: {str(e)}")
return {
"success": True,
"workflow_uuid": workflow_uuid,
"workflow_info": workflow_info.get("data"),
"protocol_type": protocol_type,
"message": f"Workflow '{workflow_name}' created successfully (library addition failed)",
}
except requests.exceptions.RequestException as e:
error_msg = f"Network error when calling API: {str(e)}"
logger.error(error_msg)
return {"error": error_msg, "success": False}
except json.JSONDecodeError as e:
error_msg = f"JSON parsing error: {str(e)}"
logger.error(error_msg)
return {"error": error_msg, "success": False}
except Exception as e:
error_msg = f"An unexpected error occurred: {str(e)}"
logger.error(error_msg)
logger.error(traceback.format_exc())
return {"error": error_msg, "success": False}

View File

@@ -4,14 +4,13 @@ package_name = 'unilabos'
setup(
name=package_name,
version='0.10.7',
version='0.10.6',
packages=find_packages(),
include_package_data=True,
install_requires=['setuptools'],
zip_safe=True,
author="The unilabos developers",
maintainer='Junhan Chang, Xuwznln',
maintainer_email='Junhan Chang <changjh@pku.edu.cn>, Xuwznln <18435084+Xuwznln@users.noreply.github.com>',
maintainer='Junhan Chang',
maintainer_email='changjh@pku.edu.cn',
description='',
license='GPL v3',
tests_require=['pytest'],

View File

@@ -170,16 +170,15 @@
"z": 0
},
"config": {
"max_volume": 1000.0,
"type": "RegularContainer",
"category": "container",
"size_x": 200,
"size_y": 150,
"size_z": 0
"max_volume": 1000.0
},
"data": {
"liquids": [["DMF", 500.0]],
"pending_liquids": [["DMF", 500.0]]
"liquids": [
{
"liquid_type": "DMF",
"liquid_volume": 1000.0
}
]
}
},
{
@@ -195,16 +194,15 @@
"z": 0
},
"config": {
"max_volume": 1000.0,
"type": "RegularContainer",
"category": "container",
"size_x": 200,
"size_y": 150,
"size_z": 0
"max_volume": 1000.0
},
"data": {
"liquids": [["ethyl_acetate", 1000.0]],
"pending_liquids": [["ethyl_acetate", 1000.0]]
"liquids": [
{
"liquid_type": "ethyl_acetate",
"liquid_volume": 1000.0
}
]
}
},
{
@@ -220,16 +218,15 @@
"z": 0
},
"config": {
"max_volume": 1000.0,
"type": "RegularContainer",
"category": "container",
"size_x": 300,
"size_y": 150,
"size_z": 0
"max_volume": 1000.0
},
"data": {
"liquids": [["hexane", 1000.0]],
"pending_liquids": [["hexane", 1000.0]]
"liquids": [
{
"liquid_type": "hexane",
"liquid_volume": 1000.0
}
]
}
},
{
@@ -245,16 +242,15 @@
"z": 0
},
"config": {
"max_volume": 1000.0,
"type": "RegularContainer",
"category": "container",
"size_x": 900,
"size_y": 150,
"size_z": 0
"max_volume": 1000.0
},
"data": {
"liquids": [["methanol", 1000.0]],
"pending_liquids": [["methanol", 1000.0]]
"liquids": [
{
"liquid_type": "methanol",
"liquid_volume": 1000.0
}
]
}
},
{
@@ -270,16 +266,15 @@
"z": 0
},
"config": {
"max_volume": 1000.0,
"type": "RegularContainer",
"category": "container",
"size_x": 950,
"size_y": 150,
"size_z": 0
"max_volume": 1000.0
},
"data": {
"liquids": [["water", 1000.0]],
"pending_liquids": [["water", 1000.0]]
"liquids": [
{
"liquid_type": "water",
"liquid_volume": 1000.0
}
]
}
},
{
@@ -340,16 +335,14 @@
},
"config": {
"max_volume": 500.0,
"type": "RegularContainer",
"category": "container",
"max_temp": 200.0,
"min_temp": -20.0,
"has_stirrer": true,
"has_heater": true
},
"data": {
"liquids": [],
"pending_liquids": []
"liquids": [
]
}
},
{
@@ -426,16 +419,11 @@
"z": 0
},
"config": {
"max_volume": 2000.0,
"type": "RegularContainer",
"category": "container",
"size_x": 500,
"size_y": 400,
"size_z": 0
"max_volume": 2000.0
},
"data": {
"liquids": [],
"pending_liquids": []
"liquids": [
]
}
},
{
@@ -451,16 +439,11 @@
"z": 0
},
"config": {
"max_volume": 2000.0,
"type": "RegularContainer",
"category": "container",
"size_x": 1100,
"size_y": 500,
"size_z": 0
"max_volume": 2000.0
},
"data": {
"liquids": [],
"pending_liquids": []
"liquids": [
]
}
},
{
@@ -666,16 +649,11 @@
"z": 0
},
"config": {
"max_volume": 250.0,
"type": "RegularContainer",
"category": "container",
"size_x": 900,
"size_y": 500,
"size_z": 0
"max_volume": 250.0
},
"data": {
"liquids": [],
"pending_liquids": []
"liquids": [
]
}
},
{
@@ -691,16 +669,11 @@
"z": 0
},
"config": {
"max_volume": 250.0,
"type": "RegularContainer",
"category": "container",
"size_x": 950,
"size_y": 500,
"size_z": 0
"max_volume": 250.0
},
"data": {
"liquids": [],
"pending_liquids": []
"liquids": [
]
}
},
{
@@ -716,16 +689,11 @@
"z": 0
},
"config": {
"max_volume": 250.0,
"type": "RegularContainer",
"category": "container",
"size_x": 1050,
"size_y": 500,
"size_z": 0
"max_volume": 250.0
},
"data": {
"liquids": [],
"pending_liquids": []
"liquids": [
]
}
},
{
@@ -765,11 +733,6 @@
},
"config": {
"max_volume": 500.0,
"size_x": 550,
"size_y": 250,
"size_z": 0,
"type": "RegularContainer",
"category": "container",
"reagent": "sodium_chloride",
"physical_state": "solid"
},
@@ -793,11 +756,6 @@
},
"config": {
"volume": 500.0,
"size_x": 600,
"size_y": 250,
"size_z": 0,
"type": "RegularContainer",
"category": "container",
"reagent": "sodium_carbonate",
"physical_state": "solid"
},
@@ -821,11 +779,6 @@
},
"config": {
"volume": 500.0,
"size_x": 650,
"size_y": 250,
"size_z": 0,
"type": "RegularContainer",
"category": "container",
"reagent": "magnesium_chloride",
"physical_state": "solid"
},

View File

@@ -8,7 +8,7 @@
],
"parent": null,
"type": "device",
"class": "bioyond_dispensing_station",
"class": "dispensing_station.bioyond",
"config": {
"config": {
"api_key": "DE9BDDA0",
@@ -20,6 +20,13 @@
"_resource_type": "unilabos.resources.bioyond.decks:BIOYOND_PolymerPreparationStation_Deck"
}
},
"station_config": {
"station_type": "dispensing_station",
"enable_dispensing_station": true,
"enable_reaction_station": false,
"station_name": "DispensingStation_001",
"description": "Bioyond配液工作站"
},
"protocol_type": []
},
"data": {}
@@ -50,4 +57,4 @@
"data": {}
}
]
}
}

View File

@@ -1,148 +0,0 @@
{
"nodes": [
{
"id": "laiyu_liquid_station",
"name": "LaiYu液体处理工作站",
"children": [
"module_1_8tubes",
"module_2_96well_deep",
"module_3_beaker",
"module_4_96well_tips"
],
"parent": null,
"type": "device",
"class": "laiyu_liquid",
"position": {
"x": 500,
"y": 200,
"z": 0
},
"config": {
"total_modules": 4,
"total_wells": 201,
"safety_margin": {
"x": 5.0,
"y": 5.0,
"z": 5.0
},
"protocol_type": ["LiquidHandlingProtocol", "PipettingProtocol", "TransferProtocol"]
},
"data": {
"status": "Ready",
"version": "1.0"
}
},
{
"id": "module_1_8tubes",
"name": "8管位置模块",
"children": [],
"parent": "laiyu_liquid_station",
"type": "container",
"class": "opentrons_24_tuberack_nest_1point5ml_snapcap",
"position": {
"x": 100,
"y": 100,
"z": 0
},
"config": {
"module_type": "tube_rack",
"wells_count": 8,
"well_diameter": 29.0,
"well_depth": 117.0,
"well_volume": 77000.0,
"well_shape": "circular",
"layout": "2x4"
},
"data": {
"max_volume": 77000.0,
"current_volume": 0.0,
"wells": ["A1", "A2", "A3", "A4", "B1", "B2", "B3", "B4"]
}
},
{
"id": "module_2_96well_deep",
"name": "96深孔板",
"children": [],
"parent": "laiyu_liquid_station",
"type": "plate",
"class": "nest_96_wellplate_2ml_deep",
"position": {
"x": 300,
"y": 100,
"z": 0
},
"config": {
"module_type": "96_well_deep_plate",
"wells_count": 96,
"well_diameter": 8.2,
"well_depth": 39.4,
"well_volume": 2080.0,
"well_shape": "circular",
"layout": "8x12"
},
"data": {
"max_volume": 2080.0,
"current_volume": 0.0,
"plate_type": "deep_well"
}
},
{
"id": "module_3_beaker",
"name": "敞口玻璃瓶",
"children": [],
"parent": "laiyu_liquid_station",
"type": "container",
"class": "container",
"position": {
"x": 500,
"y": 100,
"z": 0
},
"config": {
"module_type": "beaker_holder",
"wells_count": 1,
"well_diameter": 85.0,
"well_depth": 120.0,
"well_volume": 500000.0,
"well_shape": "circular",
"supported_containers": ["250ml", "500ml", "1000ml"]
},
"data": {
"max_volume": 500000.0,
"current_volume": 0.0,
"container_type": "beaker",
"wells": ["A1"]
}
},
{
"id": "module_4_96well_tips",
"name": "96吸头架",
"children": [],
"parent": "laiyu_liquid_station",
"type": "container",
"class": "tip_rack",
"position": {
"x": 700,
"y": 100,
"z": 0
},
"config": {
"module_type": "tip_rack",
"wells_count": 96,
"well_diameter": 8.2,
"well_depth": 60.0,
"well_volume": 6000.0,
"well_shape": "circular",
"layout": "8x12",
"tip_type": "standard"
},
"data": {
"max_volume": 6000.0,
"current_volume": 0.0,
"tip_capacity": "1000μL",
"tips_available": 96
}
}
],
"links": []
}

View File

@@ -21,7 +21,7 @@
"timeout": 10.0,
"axis": "Left",
"channel_num": 8,
"setup": false,
"setup": true,
"debug": true,
"simulator": true,
"matrix_id": "71593"

View File

@@ -19,7 +19,7 @@
"host": "192.168.0.121",
"port": 9999,
"timeout": 10.0,
"axis": "Right",
"axis": "Left",
"channel_num": 1,
"setup": true,
"debug": false,

View File

@@ -10,7 +10,7 @@
"type": "device",
"class": "reaction_station.bioyond",
"config": {
"config": {
"bioyond_config": {
"api_key": "DE9BDDA0",
"api_host": "http://192.168.1.200:44402",
"workflow_mappings": {
@@ -19,18 +19,14 @@
"Solid_feeding_vials": "3a160877-87e7-7699-7bc6-ec72b05eb5e6",
"Liquid_feeding_vials(non-titration)": "3a167d99-6158-c6f0-15b5-eb030f7d8e47",
"Liquid_feeding_solvents": "3a160824-0665-01ed-285a-51ef817a9046",
"Liquid_feeding(titration)": "3a16082a-96ac-0449-446a-4ed39f3365b6",
"liquid_feeding_beaker": "3a16087e-124f-8ddb-8ec1-c2dff09ca784",
"Liquid_feeding(titration)": "3a160824-0665-01ed-285a-51ef817a9046",
"Liquid_feeding_beaker": "3a16087e-124f-8ddb-8ec1-c2dff09ca784",
"Drip_back": "3a162cf9-6aac-565a-ddd7-682ba1796a4a"
},
"material_type_mappings": {
"烧杯": ["YB_1FlaskCarrier", "3a14196b-24f2-ca49-9081-0cab8021bf1a"],
"试剂瓶": ["YB_1BottleCarrier", ""],
"样品板": ["YB_6StockCarrier", "3a14196e-b7a0-a5da-1931-35f3000281e9"],
"分装板": ["YB_6VialCarrier", "3a14196e-5dfe-6e21-0c79-fe2036d052c4"],
"样品瓶": ["YB_Solid_Stock", "3a14196a-cf7d-8aea-48d8-b9662c7dba94"],
"90%分装小瓶": ["YB_Solid_Vial", "3a14196c-cdcf-088d-dc7d-5cf38f0ad9ea"],
"10%分装小瓶": ["YB_Liquid_Vial", "3a14196c-76be-2279-4e22-7310d69aed68"]
"烧杯": "BIOYOND_PolymerStation_1FlaskCarrier",
"试剂瓶": "BIOYOND_PolymerStation_1BottleCarrier",
"样品板": "BIOYOND_PolymerStation_6VialCarrier"
}
},
"deck": {
@@ -46,6 +42,7 @@
{
"id": "Bioyond_Deck",
"name": "Bioyond_Deck",
"sample_id": null,
"children": [
],
"parent": "reaction_station_bioyond",

View File

@@ -24,9 +24,9 @@
"Drip_back": "3a162cf9-6aac-565a-ddd7-682ba1796a4a"
},
"material_type_mappings": {
"烧杯": "YB_1FlaskCarrier",
"试剂瓶": "YB_1BottleCarrier",
"样品板": "YB_6VialCarrier"
"烧杯": "BIOYOND_PolymerStation_1FlaskCarrier",
"试剂瓶": "BIOYOND_PolymerStation_1BottleCarrier",
"样品板": "BIOYOND_PolymerStation_6VialCarrier"
}
},
"deck": {

View File

@@ -1,394 +0,0 @@
{
"nodes": [
{
"id": "liquid_handler",
"name": "liquid_handler",
"parent": null,
"type": "device",
"class": "liquid_handler",
"position": {
"x": 0,
"y": 0,
"z": 0
},
"data": {},
"children": [
"deck"
],
"config": {
"deck": {
"_resource_child_name": "deck",
"_resource_type": "pylabrobot.resources.opentrons.deck:OTDeck",
"name": "deck"
},
"backend": {
"type": "UniLiquidHandlerRvizBackend"
},
"simulator": true,
"total_height": 300
}
},
{
"id": "deck",
"name": "deck",
"sample_id": null,
"children": [
"tip_rack",
"plate_well",
"tube_rack",
"bottle_rack"
],
"parent": "liquid_handler",
"type": "deck",
"class": "TransformXYZDeck",
"position": {
"x": 0,
"y": 0,
"z": 18
},
"config": {
"type": "TransformXYZDeck",
"size_x": 624.3,
"size_y": 565.2,
"size_z": 900,
"rotation": {
"x": 0,
"y": 0,
"z": 0,
"type": "Rotation"
}
},
"data": {}
},
{
"id": "tip_rack",
"name": "tip_rack",
"sample_id": null,
"children": [
"tip_rack_A1"
],
"parent": "deck",
"type": "tip_rack",
"class": "tiprack_box",
"position": {
"x": 150,
"y": 7,
"z": 103
},
"config": {
"type": "TipRack",
"size_x": 134,
"size_y": 96,
"size_z": 7.0,
"rotation": {
"x": 0,
"y": 0,
"z": 0,
"type": "Rotation"
},
"category": "tip_rack",
"model": "tiprack_box",
"ordering": [
"A1"
]
},
"data": {}
},
{
"id": "tip_rack_A1",
"name": "tip_rack_A1",
"sample_id": null,
"children": [],
"parent": "tip_rack",
"type": "container",
"class": "",
"position": {
"x": 11.12,
"y": 75,
"z": -91.54
},
"config": {
"type": "TipSpot",
"size_x": 9,
"size_y": 9,
"size_z": 95,
"rotation": {
"x": 0,
"y": 0,
"z": 0,
"type": "Rotation"
},
"category": "tip_spot",
"model": null,
"prototype_tip": {
"type": "Tip",
"total_tip_length": 95,
"has_filter": false,
"maximal_volume": 1000.0,
"fitting_depth": 3.29
}
},
"data": {
"tip": null,
"tip_state": null,
"pending_tip": null
}
},
{
"id": "plate_well",
"name": "plate_well",
"sample_id": null,
"children": [
"plate_well_A1"
],
"parent": "deck",
"type": "plate",
"class": "plate_96",
"position": {
"x": 161,
"y": 116,
"z": 48.5
},
"pose": {
"position_3d": {
"x": 161,
"y": 116,
"z": 48.5
},
"rotation": {
"x": 0,
"y": 0,
"z": 0
}
},
"config": {
"type": "Plate",
"size_x": 127.76,
"size_y": 85.48,
"size_z": 45.5,
"rotation": {
"x": 0,
"y": 0,
"z": 0,
"type": "Rotation"
},
"category": "plate",
"model": "plate_96",
"ordering": [
"A1"
]
},
"data": {}
},
{
"id": "plate_well_A1",
"name": "plate_well_A1",
"sample_id": null,
"children": [],
"parent": "plate_well",
"type": "device",
"class": "",
"position": {
"x": 10.1,
"y": 70,
"z": 6.1
},
"config": {
"type": "Well",
"size_x": 8.2,
"size_y": 8.2,
"size_z": 38,
"rotation": {
"x": 0,
"y": 0,
"z": 0,
"type": "Rotation"
},
"category": "well",
"model": null,
"max_volume": 2000,
"material_z_thickness": null,
"compute_volume_from_height": null,
"compute_height_from_volume": null,
"bottom_type": "unknown",
"cross_section_type": "rectangle"
},
"data": {
"liquids": [["water", 50.0]],
"pending_liquids": [["water", 50.0]],
"liquid_history": []
}
},
{
"id": "tube_rack",
"name": "tube_rack",
"sample_id": null,
"children": [
"tube_rack_A1"
],
"parent": "deck",
"type": "container",
"class": "tube_container",
"position": {
"x": 0,
"y": 127,
"z": 0
},
"config": {
"type": "Plate",
"size_x": 151,
"size_y": 75,
"size_z": 75,
"rotation": {
"x": 0,
"y": 0,
"z": 0,
"type": "Rotation"
},
"model": "tube_container",
"ordering": [
"A1"
]
},
"data": {}
},
{
"id": "tube_rack_A1",
"name": "tube_rack_A1",
"sample_id": null,
"children": [],
"parent": "tube_rack",
"type": "device",
"class": "",
"position": {
"x": 6,
"y": 38,
"z": 10
},
"config": {
"type": "Well",
"size_x": 34,
"size_y": 34,
"size_z": 117,
"rotation": {
"x": 0,
"y": 0,
"z": 0,
"type": "Rotation"
},
"category": "tube",
"model": null,
"max_volume": 2000,
"material_z_thickness": null,
"compute_volume_from_height": null,
"compute_height_from_volume": null,
"bottom_type": "unknown",
"cross_section_type": "rectangle"
},
"data": {
"liquids": [["water", 50.0]],
"pending_liquids": [["water", 50.0]],
"liquid_history": []
}
}
,
{
"id": "bottle_rack",
"name": "bottle_rack",
"sample_id": null,
"children": [
"bottle_rack_A1"
],
"parent": "deck",
"type": "container",
"class": "bottle_container",
"position": {
"x": 0,
"y": 0,
"z": 0
},
"config": {
"type": "Plate",
"size_x": 130,
"size_y": 117,
"size_z": 8,
"rotation": {
"x": 0,
"y": 0,
"z": 0,
"type": "Rotation"
},
"category": "tube_rack",
"model": "bottle_container",
"ordering": [
"A1"
]
},
"data": {}
},
{
"id": "bottle_rack_A1",
"name": "bottle_rack_A1",
"sample_id": null,
"children": [],
"parent": "bottle_rack",
"type": "device",
"class": "",
"position": {
"x": 25,
"y": 18.5,
"z": 8
},
"config": {
"type": "Well",
"size_x": 80,
"size_y": 80,
"size_z": 117,
"rotation": {
"x": 0,
"y": 0,
"z": 0,
"type": "Rotation"
},
"category": "tube",
"model": null,
"max_volume": 2000,
"material_z_thickness": null,
"compute_volume_from_height": null,
"compute_height_from_volume": null,
"bottom_type": "unknown",
"cross_section_type": "rectangle"
},
"data": {
"liquids": [["water", 50.0]],
"pending_liquids": [["water", 50.0]],
"liquid_history": []
}
}
],
"links": []
}

View File

@@ -3,8 +3,7 @@
"""
import asyncio
from typing import Dict, Any, List
from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode
from typing import Dict, Any, Optional, List
class SmartPumpController:
@@ -15,8 +14,6 @@ class SmartPumpController:
适用于实验室自动化系统中的液体处理任务。
"""
_ros_node: BaseROS2DeviceNode
def __init__(self, device_id: str = "smart_pump_01", port: str = "/dev/ttyUSB0"):
"""
初始化智能泵控制器
@@ -33,9 +30,6 @@ class SmartPumpController:
self.calibration_factor = 1.0
self.pump_mode = "continuous" # continuous, volume, rate
def post_init(self, ros_node: BaseROS2DeviceNode):
self._ros_node = ros_node
def connect_device(self, timeout: int = 10) -> bool:
"""
连接到泵设备
@@ -96,7 +90,7 @@ class SmartPumpController:
pump_time = (volume / flow_rate) * 60 # 转换为秒
self.current_flow_rate = flow_rate
await self._ros_node.sleep(min(pump_time, 3.0)) # 模拟泵送过程
await asyncio.sleep(min(pump_time, 3.0)) # 模拟泵送过程
self.total_volume_pumped += volume
self.current_flow_rate = 0.0
@@ -176,8 +170,6 @@ class AdvancedTemperatureController:
适用于需要精确温度控制的化学反应和材料处理过程。
"""
_ros_node: BaseROS2DeviceNode
def __init__(self, controller_id: str = "temp_controller_01"):
"""
初始化温度控制器
@@ -193,9 +185,6 @@ class AdvancedTemperatureController:
self.pid_enabled = True
self.temperature_history: List[Dict] = []
def post_init(self, ros_node: BaseROS2DeviceNode):
self._ros_node = ros_node
def set_target_temperature(self, temperature: float, rate: float = 10.0) -> bool:
"""
设置目标温度
@@ -249,7 +238,7 @@ class AdvancedTemperatureController:
}
)
await self._ros_node.sleep(step_time)
await asyncio.sleep(step_time)
# 保持历史记录不超过100条
if len(self.temperature_history) > 100:
@@ -341,8 +330,6 @@ class MultiChannelAnalyzer:
常用于光谱分析、电化学测量等应用场景。
"""
_ros_node: BaseROS2DeviceNode
def __init__(self, analyzer_id: str = "analyzer_01", channels: int = 8):
"""
初始化多通道分析仪
@@ -357,9 +344,6 @@ class MultiChannelAnalyzer:
self.is_measuring = False
self.sample_rate = 1000 # Hz
def post_init(self, ros_node: BaseROS2DeviceNode):
self._ros_node = ros_node
def configure_channel(self, channel: int, enabled: bool = True, unit: str = "V") -> bool:
"""
配置通道
@@ -392,7 +376,7 @@ class MultiChannelAnalyzer:
# 模拟数据采集
measurements = []
for _ in range(duration):
for second in range(duration):
timestamp = asyncio.get_event_loop().time()
frame_data = {}
@@ -407,7 +391,7 @@ class MultiChannelAnalyzer:
measurements.append({"timestamp": timestamp, "data": frame_data})
await self._ros_node.sleep(1.0) # 每秒采集一次
await asyncio.sleep(1.0) # 每秒采集一次
self.is_measuring = False
@@ -481,8 +465,6 @@ class AutomatedDispenser:
集成称重功能,确保分配精度和重现性。
"""
_ros_node: BaseROS2DeviceNode
def __init__(self, dispenser_id: str = "dispenser_01"):
"""
初始化自动分配器
@@ -497,9 +479,6 @@ class AutomatedDispenser:
self.container_capacity = 1000.0 # mL
self.precision_mode = True
def post_init(self, ros_node: BaseROS2DeviceNode):
self._ros_node = ros_node
def move_to_position(self, x: float, y: float, z: float) -> bool:
"""
移动到指定位置
@@ -538,7 +517,7 @@ class AutomatedDispenser:
if viscosity == "high":
dispense_time *= 2 # 高粘度液体需要更长时间
await self._ros_node.sleep(min(dispense_time, 5.0)) # 最多等待5秒
await asyncio.sleep(min(dispense_time, 5.0)) # 最多等待5秒
self.dispensed_total += volume

View File

@@ -1,52 +0,0 @@
[
{
"id": "3a1d377b-299d-d0f2-ced9-48257f60dfad",
"typeName": "加样头(大)",
"code": "0005-00145",
"barCode": "",
"name": "LiDFOB",
"quantity": 9999.0,
"lockQuantity": 0.0,
"unit": "个",
"status": 1,
"isUse": false,
"locations": [
{
"id": "3a19da56-1379-ff7c-1745-07e200b44ce2",
"whid": "3a19da56-1378-613b-29f2-871e1a287aa5",
"whName": "粉末加样头堆栈",
"code": "0005-0001",
"x": 1,
"y": 1,
"z": 1,
"quantity": 0
}
],
"detail": []
},
{
"id": "3a1d377b-6a81-6a7e-147c-f89f6463656d",
"typeName": "液",
"code": "0006-00141",
"barCode": "",
"name": "EMC",
"quantity": 99999.0,
"lockQuantity": 0.0,
"unit": "g",
"status": 1,
"isUse": false,
"locations": [
{
"id": "3a1baa20-a7b1-c665-8b9c-d8099d07d2f6",
"whid": "3a1baa20-a7b0-5c19-8844-5de8924d4e78",
"whName": "4号手套箱内部堆栈",
"code": "0015-0001",
"x": 1,
"y": 1,
"z": 1,
"quantity": 0
}
],
"detail": []
}
]

View File

@@ -1,4 +1,5 @@
[
{
"data": [
{
"id": "3a1c67a9-aed7-b94d-9e24-bfdf10c8baa9",
"typeName": "烧杯",
@@ -190,4 +191,8 @@
}
]
}
]
],
"code": 1,
"message": "",
"timestamp": 1758560573511
}

View File

@@ -1,181 +0,0 @@
[
{
"id": "3a1c62c4-c3d2-b803-b72d-7f1153ffef3b",
"typeName": "试剂瓶",
"code": "0004-00050",
"barCode": "",
"name": "NMP",
"quantity": 287.16699029126215,
"lockQuantity": 285.16699029126215,
"unit": "毫升",
"status": 1,
"isUse": false,
"locations": [
{
"id": "3a14198c-c2d0-efce-0939-69ca5a7dfd39",
"whid": "3a14198c-c2cc-0290-e086-44a428fba248",
"whName": "试剂堆栈",
"code": "0001-0008",
"x": 2,
"y": 4,
"z": 1,
"quantity": 0
}
],
"detail": []
},
{
"id": "3a1cdefe-0e03-1bc1-1296-dae1905c4108",
"typeName": "试剂瓶",
"code": "0004-00052",
"barCode": "",
"name": "NMP",
"quantity": 386.8990291262136,
"lockQuantity": 45.89902912621359,
"unit": "毫升",
"status": 1,
"isUse": false,
"locations": [
{
"id": "3a14198c-c2d0-f3e7-871a-e470d144296f",
"whid": "3a14198c-c2cc-0290-e086-44a428fba248",
"whName": "试剂堆栈",
"code": "0001-0005",
"x": 2,
"y": 1,
"z": 1,
"quantity": 0
}
],
"detail": []
},
{
"id": "3a1cdefe-0e03-68a4-bcb3-02fc6ba72d1b",
"typeName": "试剂瓶",
"code": "0004-00053",
"barCode": "",
"name": "NMP",
"quantity": 400.0,
"lockQuantity": 0.0,
"unit": "",
"status": 1,
"isUse": false,
"locations": [
{
"id": "3a14198c-c2d0-2070-efc8-44e245f10c6f",
"whid": "3a14198c-c2cc-0290-e086-44a428fba248",
"whName": "试剂堆栈",
"code": "0001-0006",
"x": 2,
"y": 2,
"z": 1,
"quantity": 0
}
],
"detail": []
},
{
"id": "3a1cdefe-d5e0-d850-5439-4499f20f07fe",
"typeName": "分装板",
"code": "0007-00185",
"barCode": "",
"name": "1010",
"quantity": 1.0,
"lockQuantity": 2.0,
"unit": "块",
"status": 1,
"isUse": false,
"locations": [
{
"id": "3a14198e-6929-46fe-841e-03dd753f1e4a",
"whid": "3a14198e-6928-121f-7ca6-88ad3ae7e6a0",
"whName": "粉末堆栈",
"code": "0002-0009",
"x": 3,
"y": 1,
"z": 1,
"quantity": 0
}
],
"detail": [
{
"id": "3a1cdefe-d5e0-28a4-f5d0-f7e2436c575f",
"detailMaterialId": "3a1cdefe-d5e0-94ae-f770-27847e73ad38",
"code": null,
"name": "90%分装小瓶",
"quantity": "1",
"lockQuantity": "1",
"unit": "个",
"x": 2,
"y": 3,
"z": 1,
"associateId": null
},
{
"id": "3a1cdefe-d5e0-3ed6-3607-133df89baf5b",
"detailMaterialId": "3a1cdefe-d5e0-f2fa-66bf-94c565d852fb",
"code": null,
"name": "10%分装小瓶",
"quantity": "1",
"lockQuantity": "1",
"unit": "个",
"x": 1,
"y": 3,
"z": 1,
"associateId": null
},
{
"id": "3a1cdefe-d5e0-72b6-e015-be7b93cf09eb",
"detailMaterialId": "3a1cdefe-d5e0-81cf-7dad-2e51cab9ffd6",
"code": null,
"name": "90%分装小瓶",
"quantity": "1",
"lockQuantity": "1",
"unit": "个",
"x": 2,
"y": 1,
"z": 1,
"associateId": null
},
{
"id": "3a1cdefe-d5e0-81d3-ad30-48134afc9ce7",
"detailMaterialId": "3a1cdefe-d5e0-3fa1-cc72-fda6276ae38d",
"code": null,
"name": "10%分装小瓶",
"quantity": "1",
"lockQuantity": "1",
"unit": "个",
"x": 1,
"y": 1,
"z": 1,
"associateId": null
},
{
"id": "3a1cdefe-d5e0-dbdf-d966-9a8926fe1e06",
"detailMaterialId": "3a1cdefe-d5e0-c632-c7da-02d385b18628",
"code": null,
"name": "10%分装小瓶",
"quantity": "1",
"lockQuantity": "1",
"unit": "个",
"x": 1,
"y": 2,
"z": 1,
"associateId": null
},
{
"id": "3a1cdefe-d5e0-f099-b260-e3089a2d08c3",
"detailMaterialId": "3a1cdefe-d5e0-561f-73b6-f8501f814dbb",
"code": null,
"name": "90%分装小瓶",
"quantity": "1",
"lockQuantity": "1",
"unit": "个",
"x": 2,
"y": 2,
"z": 1,
"associateId": null
}
]
}
]

View File

@@ -1,216 +0,0 @@
[
{
"id": "3a1cde21-a4f4-4f95-6221-eaafc2ae6a8d",
"typeName": "样品瓶",
"code": "0002-00407",
"barCode": "",
"name": "ODA",
"quantity": 25.0,
"lockQuantity": 2.0,
"unit": "克",
"status": 1,
"isUse": false,
"locations": [],
"detail": []
},
{
"id": "3a1cde21-a4f4-7887-9258-e8f8ab7c8a7a",
"typeName": "样品板",
"code": "0008-00160",
"barCode": "",
"name": "1010sample",
"quantity": 1.0,
"lockQuantity": 27.69187,
"unit": "块",
"status": 1,
"isUse": false,
"locations": [
{
"id": "3a14198e-6929-4379-affa-9a2935c17f99",
"whid": "3a14198e-6928-121f-7ca6-88ad3ae7e6a0",
"whName": "粉末堆栈",
"code": "0002-0002",
"x": 1,
"y": 2,
"z": 1,
"quantity": 0
}
],
"detail": [
{
"id": "3a1cde21-a4f4-0339-f2b6-8e680ad7e8c7",
"detailMaterialId": "3a1cde21-a4f4-ab37-f7a2-ecc3bc083e7c",
"code": null,
"name": "MPDA",
"quantity": "10.505",
"lockQuantity": "-0.0174",
"unit": "克",
"x": 2,
"y": 1,
"z": 1,
"associateId": null
},
{
"id": "3a1cde21-a4f4-a21a-23cf-bb7857b41947",
"detailMaterialId": "3a1cde21-a4f4-99c7-55e7-c80c7320e300",
"code": null,
"name": "ODA",
"quantity": "1.795",
"lockQuantity": "2.0093",
"unit": "克",
"x": 1,
"y": 1,
"z": 1,
"associateId": null
},
{
"id": "3a1cde21-a4f4-af1b-ba0b-2874836800e9",
"detailMaterialId": "3a1cde21-a4f4-4f95-6221-eaafc2ae6a8d",
"code": null,
"name": "ODA",
"quantity": "25",
"lockQuantity": "2",
"unit": "克",
"x": 1,
"y": 2,
"z": 1,
"associateId": null
}
]
},
{
"id": "3a1cde21-a4f4-99c7-55e7-c80c7320e300",
"typeName": "样品瓶",
"code": "0002-00406",
"barCode": "",
"name": "ODA",
"quantity": 1.795,
"lockQuantity": 2.00927,
"unit": "克",
"status": 1,
"isUse": false,
"locations": [],
"detail": []
},
{
"id": "3a1cde21-a4f4-ab37-f7a2-ecc3bc083e7c",
"typeName": "样品瓶",
"code": "0002-00408",
"barCode": "",
"name": "MPDA",
"quantity": 10.505,
"lockQuantity": -0.0174,
"unit": "克",
"status": 1,
"isUse": false,
"locations": [],
"detail": []
},
{
"id": "3a1cdeff-c92a-08f6-c822-732ab734154c",
"typeName": "样品板",
"code": "0008-00161",
"barCode": "",
"name": "1010sample2",
"quantity": 1.0,
"lockQuantity": 3.0,
"unit": "块",
"status": 1,
"isUse": false,
"locations": [
{
"id": "3a14198e-6929-31f0-8a22-0f98f72260df",
"whid": "3a14198e-6928-121f-7ca6-88ad3ae7e6a0",
"whName": "粉末堆栈",
"code": "0002-0001",
"x": 1,
"y": 1,
"z": 1,
"quantity": 0
}
],
"detail": [
{
"id": "3a1cdeff-c92b-3ace-9623-0bcdef6fa07d",
"detailMaterialId": "3a1cdeff-c92b-d084-2a96-5d62746d9321",
"code": null,
"name": "BTDA1",
"quantity": "0.362",
"lockQuantity": "14.494",
"unit": "克",
"x": 1,
"y": 1,
"z": 1,
"associateId": null
},
{
"id": "3a1cdeff-c92b-856e-f481-792b91b6dbde",
"detailMaterialId": "3a1cdeff-c92b-30f2-f907-8f5e2fe0586b",
"code": null,
"name": "BTDA3",
"quantity": "1.935",
"lockQuantity": "13.067",
"unit": "克",
"x": 1,
"y": 2,
"z": 1,
"associateId": null
},
{
"id": "3a1cdeff-c92b-d144-c5e5-ab9d94e21187",
"detailMaterialId": "3a1cdeff-c92b-519f-a70f-0bb71af537a7",
"code": null,
"name": "BTDA2",
"quantity": "1.903",
"lockQuantity": "13.035",
"unit": "克",
"x": 2,
"y": 1,
"z": 1,
"associateId": null
}
]
},
{
"id": "3a1cdeff-c92b-30f2-f907-8f5e2fe0586b",
"typeName": "样品瓶",
"code": "0002-00411",
"barCode": "",
"name": "BTDA3",
"quantity": 1.935,
"lockQuantity": 13.067,
"unit": "克",
"status": 1,
"isUse": false,
"locations": [],
"detail": []
},
{
"id": "3a1cdeff-c92b-519f-a70f-0bb71af537a7",
"typeName": "样品瓶",
"code": "0002-00410",
"barCode": "",
"name": "BTDA2",
"quantity": 1.903,
"lockQuantity": 13.035,
"unit": "克",
"status": 1,
"isUse": false,
"locations": [],
"detail": []
},
{
"id": "3a1cdeff-c92b-d084-2a96-5d62746d9321",
"typeName": "样品瓶",
"code": "0002-00409",
"barCode": "",
"name": "BTDA1",
"quantity": 0.362,
"lockQuantity": 14.494,
"unit": "克",
"status": 1,
"isUse": false,
"locations": [],
"detail": []
}
]

View File

@@ -1,99 +0,0 @@
{
"typeId": "3a190c8b-3284-af78-d29f-9a69463ad047",
"code": "",
"barCode": "",
"name": "test",
"unit": "",
"parameters": "{}",
"quantity": "",
"details": [
{
"typeId": "3a190c8c-fe8f-bf48-0dc3-97afc7f508eb",
"code": "",
"name": "配液瓶(小)11",
"quantity": "1",
"x": 1,
"y": 1,
"z": 1,
"unit": "",
"parameters": "{}"
},
{
"typeId": "3a190c8c-fe8f-bf48-0dc3-97afc7f508eb",
"code": "",
"name": "配液瓶(小)21",
"quantity": "1",
"x": 2,
"y": 1,
"z": 1,
"unit": "",
"parameters": "{}"
},
{
"typeId": "3a190c8c-fe8f-bf48-0dc3-97afc7f508eb",
"code": "",
"name": "配液瓶(小)12",
"quantity": "1",
"x": 1,
"y": 2,
"z": 1,
"unit": "",
"parameters": "{}"
},
{
"typeId": "3a190c8c-fe8f-bf48-0dc3-97afc7f508eb",
"code": "",
"name": "配液瓶(小)22",
"quantity": "1",
"x": 2,
"y": 2,
"z": 1,
"unit": "",
"parameters": "{}"
},
{
"typeId": "3a190c8c-fe8f-bf48-0dc3-97afc7f508eb",
"code": "",
"name": "配液瓶(小)13",
"quantity": "1",
"x": 1,
"y": 3,
"z": 1,
"unit": "",
"parameters": "{}"
},
{
"typeId": "3a190c8c-fe8f-bf48-0dc3-97afc7f508eb",
"code": "",
"name": "配液瓶(小)23",
"quantity": "1",
"x": 2,
"y": 3,
"z": 1,
"unit": "",
"parameters": "{}"
},
{
"typeId": "3a190c8c-fe8f-bf48-0dc3-97afc7f508eb",
"code": "",
"name": "配液瓶(小)14",
"quantity": "1",
"x": 1,
"y": 4,
"z": 1,
"unit": "",
"parameters": "{}"
},
{
"typeId": "3a190c8c-fe8f-bf48-0dc3-97afc7f508eb",
"code": "",
"name": "配液瓶(小)24",
"quantity": "1",
"x": 2,
"y": 4,
"z": 1,
"unit": "",
"parameters": "{}"
}
]
}

View File

@@ -1,148 +0,0 @@
[
{
"id": "3a1d4c14-a9fb-d7dc-9e96-7a3ad6e50219",
"typeName": "配液瓶(小)板",
"code": "0001-00093",
"barCode": "",
"name": "test",
"quantity": 2.0,
"lockQuantity": 0.0,
"unit": "块",
"status": 1,
"isUse": false,
"locations": [
{
"id": "3a19deae-2c7a-36f5-5e41-02c5b66feaea",
"whid": "3a19deae-2c79-05a3-9c76-8e6760424841",
"whName": "手动堆栈",
"code": "1",
"x": 1,
"y": 1,
"z": 1,
"quantity": 0
}
],
"detail": [
{
"id": "3a1d4c14-a9fc-1daa-71fa-146cb1ccb930",
"detailMaterialId": "3a1d4c14-a9fc-4f38-4c48-68486c391c42",
"code": "0001-00093 - 05",
"name": "配液瓶(小)",
"quantity": "1",
"lockQuantity": "0",
"unit": "个",
"x": 1,
"y": 3,
"z": 1,
"associateId": null,
"typeName": "配液瓶(小)",
"typeId": "3a190c8c-fe8f-bf48-0dc3-97afc7f508eb"
},
{
"id": "3a1d4c14-a9fc-3659-ea61-cd587da9e131",
"detailMaterialId": "3a1d4c14-a9fc-018f-93e5-c49343d37758",
"code": "0001-00093 - 08",
"name": "配液瓶(小)",
"quantity": "1",
"lockQuantity": "0",
"unit": "个",
"x": 2,
"y": 4,
"z": 1,
"associateId": null,
"typeName": "配液瓶(小)",
"typeId": "3a190c8c-fe8f-bf48-0dc3-97afc7f508eb"
},
{
"id": "3a1d4c14-a9fc-3f94-de83-979d2646e313",
"detailMaterialId": "3a1d4c14-a9fc-9987-c0ef-4b7cbad49e6b",
"code": "0001-00093 - 01",
"name": "配液瓶(小)",
"quantity": "1",
"lockQuantity": "0",
"unit": "个",
"x": 1,
"y": 1,
"z": 1,
"associateId": null,
"typeName": "配液瓶(小)",
"typeId": "3a190c8c-fe8f-bf48-0dc3-97afc7f508eb"
},
{
"id": "3a1d4c14-a9fc-8c35-6b25-913b11dbaf4e",
"detailMaterialId": "3a1d4c14-a9fc-9a83-865b-0c26ea5e8cc4",
"code": "0001-00093 - 03",
"name": "配液瓶(小)",
"quantity": "1",
"lockQuantity": "0",
"unit": "个",
"x": 1,
"y": 2,
"z": 1,
"associateId": null,
"typeName": "配液瓶(小)",
"typeId": "3a190c8c-fe8f-bf48-0dc3-97afc7f508eb"
},
{
"id": "3a1d4c14-a9fc-b41f-e968-64953bfddccd",
"detailMaterialId": "3a1d4c14-a9fc-daf7-9d64-e5ec8d3ae0e2",
"code": "0001-00093 - 07",
"name": "配液瓶(小)",
"quantity": "1",
"lockQuantity": "0",
"unit": "个",
"x": 1,
"y": 4,
"z": 1,
"associateId": null,
"typeName": "配液瓶(小)",
"typeId": "3a190c8c-fe8f-bf48-0dc3-97afc7f508eb"
},
{
"id": "3a1d4c14-a9fc-c20f-c26e-b1bb2cdc3bca",
"detailMaterialId": "3a1d4c14-a9fc-673b-ac83-aaaf71287f1f",
"code": "0001-00093 - 06",
"name": "配液瓶(小)",
"quantity": "1",
"lockQuantity": "0",
"unit": "个",
"x": 2,
"y": 3,
"z": 1,
"associateId": null,
"typeName": "配液瓶(小)",
"typeId": "3a190c8c-fe8f-bf48-0dc3-97afc7f508eb"
},
{
"id": "3a1d4c14-a9fc-cf21-059c-fde361d82b6f",
"detailMaterialId": "3a1d4c14-a9fc-25b1-e736-6b0d8dac0fae",
"code": "0001-00093 - 02",
"name": "配液瓶(小)",
"quantity": "1",
"lockQuantity": "0",
"unit": "个",
"x": 2,
"y": 1,
"z": 1,
"associateId": null,
"typeName": "配液瓶(小)",
"typeId": "3a190c8c-fe8f-bf48-0dc3-97afc7f508eb"
},
{
"id": "3a1d4c14-a9fc-d732-2b93-9b2bd2bf581b",
"detailMaterialId": "3a1d4c14-a9fc-7f5d-b6b6-8bcb2e15f320",
"code": "0001-00093 - 04",
"name": "配液瓶(小)",
"quantity": "1",
"lockQuantity": "0",
"unit": "个",
"x": 2,
"y": 2,
"z": 1,
"associateId": null,
"typeName": "配液瓶(小)",
"typeId": "3a190c8c-fe8f-bf48-0dc3-97afc7f508eb"
}
]
}
]

View File

@@ -1,7 +1,7 @@
import pytest
from unilabos.resources.bioyond.bottle_carriers import BIOYOND_Electrolyte_6VialCarrier, BIOYOND_Electrolyte_1BottleCarrier
from unilabos.resources.bioyond.bottles import YB_Solid_Vial, YB_Solution_Beaker, YB_Reagent_Bottle
from unilabos.resources.bioyond.bottles import BIOYOND_PolymerStation_Solid_Vial, BIOYOND_PolymerStation_Solution_Beaker, BIOYOND_PolymerStation_Reagent_Bottle
def test_bottle_carrier() -> "BottleCarrier":
@@ -16,9 +16,9 @@ def test_bottle_carrier() -> "BottleCarrier":
print(f"1烧杯载架: {beaker_carrier.name}, 位置数: {len(beaker_carrier.sites)}")
# 创建瓶子和烧杯
powder_bottle = YB_Solid_Vial("powder_bottle_01")
solution_beaker = YB_Solution_Beaker("solution_beaker_01")
reagent_bottle = YB_Reagent_Bottle("reagent_bottle_01")
powder_bottle = BIOYOND_PolymerStation_Solid_Vial("powder_bottle_01")
solution_beaker = BIOYOND_PolymerStation_Solution_Beaker("solution_beaker_01")
reagent_bottle = BIOYOND_PolymerStation_Reagent_Bottle("reagent_bottle_01")
print(f"\n创建的物料:")
print(f"粉末瓶: {powder_bottle.name} - {powder_bottle.diameter}mm x {powder_bottle.height}mm, {powder_bottle.max_volume}μL")

View File

@@ -2,7 +2,6 @@ import pytest
import json
import os
from pylabrobot.resources import Resource as ResourcePLR
from unilabos.resources.graphio import resource_bioyond_to_plr
from unilabos.registry.registry import lab_registry
@@ -12,55 +11,25 @@ lab_registry.setup()
type_mapping = {
"烧杯": ("YB_1FlaskCarrier", "3a14196b-24f2-ca49-9081-0cab8021bf1a"),
"试剂瓶": ("YB_1BottleCarrier", ""),
"样品板": ("YB_6StockCarrier", "3a14196e-b7a0-a5da-1931-35f3000281e9"),
"分装板": ("YB_6VialCarrier", "3a14196e-5dfe-6e21-0c79-fe2036d052c4"),
"样品瓶": ("YB_Solid_Stock", "3a14196a-cf7d-8aea-48d8-b9662c7dba94"),
"90%分装小瓶": ("YB_Solid_Vial", "3a14196c-cdcf-088d-dc7d-5cf38f0ad9ea"),
"10%分装小瓶": ("YB_Liquid_Vial", "3a14196c-76be-2279-4e22-7310d69aed68"),
"烧杯": "BIOYOND_PolymerStation_1FlaskCarrier",
"试剂瓶": "BIOYOND_PolymerStation_1BottleCarrier",
"样品板": "BIOYOND_PolymerStation_6VialCarrier",
}
@pytest.fixture
def bioyond_materials_reaction() -> list[dict]:
def bioyond_materials() -> list[dict]:
print("加载 BioYond 物料数据...")
print(os.getcwd())
with open("bioyond_materials_reaction.json", "r", encoding="utf-8") as f:
data = json.load(f)
with open("bioyond_materials.json", "r", encoding="utf-8") as f:
data = json.load(f)["data"]
print(f"加载了 {len(data)} 条物料数据")
return data
@pytest.fixture
def bioyond_materials_liquidhandling_1() -> list[dict]:
print("加载 BioYond 物料数据...")
print(os.getcwd())
with open("bioyond_materials_liquidhandling_1.json", "r", encoding="utf-8") as f:
data = json.load(f)
print(f"加载了 {len(data)} 条物料数据")
return data
@pytest.fixture
def bioyond_materials_liquidhandling_2() -> list[dict]:
print("加载 BioYond 物料数据...")
print(os.getcwd())
with open("bioyond_materials_liquidhandling_2.json", "r", encoding="utf-8") as f:
data = json.load(f)
print(f"加载了 {len(data)} 条物料数据")
return data
@pytest.mark.parametrize("materials_fixture", [
"bioyond_materials_reaction",
"bioyond_materials_liquidhandling_1",
])
def test_bioyond_to_plr(materials_fixture, request) -> list[dict]:
materials = request.getfixturevalue(materials_fixture)
def test_bioyond_to_plr(bioyond_materials) -> list[dict]:
deck = BIOYOND_PolymerReactionStation_Deck("test_deck")
output = resource_bioyond_to_plr(materials, type_mapping=type_mapping, deck=deck)
print("将 BioYond 物料数据转换为 PLR 格式...")
output = resource_bioyond_to_plr(bioyond_materials, type_mapping=type_mapping, deck=deck)
print(deck.summary())
print([resource.serialize() for resource in output])
print([resource.serialize_all_state() for resource in output])
json.dump(deck.serialize(), open("test.json", "w", encoding="utf-8"), indent=4)

View File

@@ -1,115 +0,0 @@
#!/usr/bin/env python3
"""
测试修改后的 get_child_identifier 函数
"""
from unilabos.resources.itemized_carrier import ItemizedCarrier, Bottle
from pylabrobot.resources.coordinate import Coordinate
def test_get_child_identifier_with_indices():
"""测试返回x,y,z索引的 get_child_identifier 函数"""
# 创建一些测试瓶子
bottle1 = Bottle("bottle1", diameter=25.0, height=50.0, max_volume=15.0)
bottle1.location = Coordinate(10, 20, 5)
bottle2 = Bottle("bottle2", diameter=25.0, height=50.0, max_volume=15.0)
bottle2.location = Coordinate(50, 20, 5)
bottle3 = Bottle("bottle3", diameter=25.0, height=50.0, max_volume=15.0)
bottle3.location = Coordinate(90, 20, 5)
# 创建载架,指定维度
sites = {
"A1": bottle1,
"A2": bottle2,
"A3": bottle3,
"B1": None, # 空位
"B2": None,
"B3": None
}
carrier = ItemizedCarrier(
name="test_carrier",
size_x=150,
size_y=100,
size_z=30,
num_items_x=3, # 3列
num_items_y=2, # 2行
num_items_z=1, # 1层
sites=sites
)
print("测试载架维度:")
print(f"num_items_x: {carrier.num_items_x}")
print(f"num_items_y: {carrier.num_items_y}")
print(f"num_items_z: {carrier.num_items_z}")
print()
# 测试获取bottle1的标识符信息 (A1 = idx:0, x:0, y:0, z:0)
result1 = carrier.get_child_identifier(bottle1)
print("测试bottle1 (A1):")
print(f" identifier: {result1['identifier']}")
print(f" idx: {result1['idx']}")
print(f" x index: {result1['x']}")
print(f" y index: {result1['y']}")
print(f" z index: {result1['z']}")
# Assert 验证 bottle1 (A1) 的结果
assert result1['identifier'] == 'A1', f"Expected identifier 'A1', got '{result1['identifier']}'"
assert result1['idx'] == 0, f"Expected idx 0, got {result1['idx']}"
assert result1['x'] == 0, f"Expected x index 0, got {result1['x']}"
assert result1['y'] == 0, f"Expected y index 0, got {result1['y']}"
assert result1['z'] == 0, f"Expected z index 0, got {result1['z']}"
print(" ✓ bottle1 (A1) 测试通过")
print()
# 测试获取bottle2的标识符信息 (A2 = idx:1, x:1, y:0, z:0)
result2 = carrier.get_child_identifier(bottle2)
print("测试bottle2 (A2):")
print(f" identifier: {result2['identifier']}")
print(f" idx: {result2['idx']}")
print(f" x index: {result2['x']}")
print(f" y index: {result2['y']}")
print(f" z index: {result2['z']}")
# Assert 验证 bottle2 (A2) 的结果
assert result2['identifier'] == 'A2', f"Expected identifier 'A2', got '{result2['identifier']}'"
assert result2['idx'] == 1, f"Expected idx 1, got {result2['idx']}"
assert result2['x'] == 1, f"Expected x index 1, got {result2['x']}"
assert result2['y'] == 0, f"Expected y index 0, got {result2['y']}"
assert result2['z'] == 0, f"Expected z index 0, got {result2['z']}"
print(" ✓ bottle2 (A2) 测试通过")
print()
# 测试获取bottle3的标识符信息 (A3 = idx:2, x:2, y:0, z:0)
result3 = carrier.get_child_identifier(bottle3)
print("测试bottle3 (A3):")
print(f" identifier: {result3['identifier']}")
print(f" idx: {result3['idx']}")
print(f" x index: {result3['x']}")
print(f" y index: {result3['y']}")
print(f" z index: {result3['z']}")
# Assert 验证 bottle3 (A3) 的结果
assert result3['identifier'] == 'A3', f"Expected identifier 'A3', got '{result3['identifier']}'"
assert result3['idx'] == 2, f"Expected idx 2, got {result3['idx']}"
assert result3['x'] == 2, f"Expected x index 2, got {result3['x']}"
assert result3['y'] == 0, f"Expected y index 0, got {result3['y']}"
assert result3['z'] == 0, f"Expected z index 0, got {result3['z']}"
print(" ✓ bottle3 (A3) 测试通过")
print()
# 测试错误情况:查找不存在的资源
bottle_not_exists = Bottle("bottle_not_exists", diameter=25.0, height=50.0, max_volume=15.0)
try:
carrier.get_child_identifier(bottle_not_exists)
assert False, "应该抛出 ValueError 异常"
except ValueError as e:
print("✓ 正确抛出了 ValueError 异常:", str(e))
assert "is not assigned to this carrier" in str(e), "异常消息应该包含预期的文本"
print("\n🎉 所有测试都通过了!")
if __name__ == "__main__":
test_get_child_identifier_with_indices()

View File

@@ -1,75 +0,0 @@
from ast import If
import pytest
import json
import os
from pylabrobot.resources import Resource as ResourcePLR
from unilabos.resources.graphio import resource_bioyond_to_plr
from unilabos.ros.nodes.resource_tracker import ResourceTreeSet
from unilabos.registry.registry import lab_registry
from unilabos.resources.bioyond.decks import BIOYOND_PolymerReactionStation_Deck
from unilabos.resources.bioyond.decks import YB_Deck
lab_registry.setup()
type_mapping = {
"加样头(大)": ("YB_jia_yang_tou_da", "3a190ca0-b2f6-9aeb-8067-547e72c11469"),
"": ("YB_1BottleCarrier", "3a190ca1-2add-2b23-f8e1-bbd348b7f790"),
"配液瓶(小)板": ("YB_peiyepingxiaoban", "3a190c8b-3284-af78-d29f-9a69463ad047"),
"配液瓶(小)": ("YB_pei_ye_xiao_Bottler", "3a190c8c-fe8f-bf48-0dc3-97afc7f508eb"),
}
@pytest.fixture
def bioyond_materials_reaction() -> list[dict]:
print("加载 BioYond 物料数据...")
print(os.getcwd())
with open("bioyond_materials_reaction.json", "r", encoding="utf-8") as f:
data = json.load(f)
print(f"加载了 {len(data)} 条物料数据")
return data
@pytest.fixture
def bioyond_materials_liquidhandling_1() -> list[dict]:
print("加载 BioYond 物料数据...")
print(os.getcwd())
with open("bioyond_materials_liquidhandling_1.json", "r", encoding="utf-8") as f:
data = json.load(f)
print(f"加载了 {len(data)} 条物料数据")
return data
@pytest.fixture
def bioyond_materials_liquidhandling_2() -> list[dict]:
print("加载 BioYond 物料数据...")
print(os.getcwd())
with open("bioyond_materials_liquidhandling_2.json", "r", encoding="utf-8") as f:
data = json.load(f)
print(f"加载了 {len(data)} 条物料数据")
return data
@pytest.mark.parametrize("materials_fixture", [
"bioyond_materials_reaction",
"bioyond_materials_liquidhandling_1",
])
def test_resourcetreeset_from_plr() -> list[dict]:
# 直接加载 bioyond_materials_reaction.json 文件
current_dir = os.path.dirname(os.path.abspath(__file__))
json_path = os.path.join(current_dir, "test.json")
with open(json_path, "r", encoding="utf-8") as f:
materials = json.load(f)
deck = YB_Deck("test_deck")
output = resource_bioyond_to_plr(materials, type_mapping=type_mapping, deck=deck)
print(output)
# print(deck.summary())
r = ResourceTreeSet.from_plr_resources([deck])
print(r.dump())
# json.dump(deck.serialize(), open("test.json", "w", encoding="utf-8"), indent=4)
if __name__ == "__main__":
test_resourcetreeset_from_plr()

View File

@@ -1,186 +0,0 @@
{
"workflow": [
{
"action": "transfer_liquid",
"action_args": {
"sources": "Liquid_1",
"targets": "Liquid_2",
"asp_vol": 66.0,
"dis_vol": 66.0,
"asp_flow_rate": 94.0,
"dis_flow_rate": 94.0
}
},
{
"action": "transfer_liquid",
"action_args": {
"sources": "Liquid_2",
"targets": "Liquid_3",
"asp_vol": 58.0,
"dis_vol": 96.0,
"asp_flow_rate": 94.0,
"dis_flow_rate": 94.0
}
},
{
"action": "transfer_liquid",
"action_args": {
"sources": "Liquid_4",
"targets": "Liquid_2",
"asp_vol": 85.0,
"dis_vol": 170.0,
"asp_flow_rate": 94.0,
"dis_flow_rate": 94.0
}
},
{
"action": "transfer_liquid",
"action_args": {
"sources": "Liquid_4",
"targets": "Liquid_2",
"asp_vol": 63.333333333333336,
"dis_vol": 170.0,
"asp_flow_rate": 94.0,
"dis_flow_rate": 94.0
}
},
{
"action": "transfer_liquid",
"action_args": {
"sources": "Liquid_2",
"targets": "Liquid_3",
"asp_vol": 72.0,
"dis_vol": 150.0,
"asp_flow_rate": 94.0,
"dis_flow_rate": 94.0
}
},
{
"action": "transfer_liquid",
"action_args": {
"sources": "Liquid_4",
"targets": "Liquid_2",
"asp_vol": 85.0,
"dis_vol": 170.0,
"asp_flow_rate": 94.0,
"dis_flow_rate": 94.0
}
},
{
"action": "transfer_liquid",
"action_args": {
"sources": "Liquid_4",
"targets": "Liquid_2",
"asp_vol": 63.333333333333336,
"dis_vol": 170.0,
"asp_flow_rate": 94.0,
"dis_flow_rate": 94.0
}
},
{
"action": "transfer_liquid",
"action_args": {
"sources": "Liquid_2",
"targets": "Liquid_3",
"asp_vol": 72.0,
"dis_vol": 150.0,
"asp_flow_rate": 94.0,
"dis_flow_rate": 94.0
}
},
{
"action": "transfer_liquid",
"action_args": {
"sources": "Liquid_2",
"targets": "Liquid_3",
"asp_vol": 20.0,
"dis_vol": 20.0,
"asp_flow_rate": 7.6,
"dis_flow_rate": 7.6
}
},
{
"action": "transfer_liquid",
"action_args": {
"sources": "Liquid_5",
"targets": "Liquid_2",
"asp_vol": 6.0,
"dis_vol": 12.0,
"asp_flow_rate": 7.6,
"dis_flow_rate": 7.6
}
},
{
"action": "transfer_liquid",
"action_args": {
"sources": "Liquid_5",
"targets": "Liquid_2",
"asp_vol": 10.666666666666666,
"dis_vol": 12.0,
"asp_flow_rate": 7.599999999999999,
"dis_flow_rate": 7.6
}
},
{
"action": "transfer_liquid",
"action_args": {
"sources": "Liquid_2",
"targets": "Liquid_6",
"asp_vol": 12.0,
"dis_vol": 10.0,
"asp_flow_rate": 7.6,
"dis_flow_rate": 7.6
}
}
],
"reagent": {
"Liquid_6": {
"slot": 1,
"well": [
"A2"
],
"labware": "elution plate"
},
"Liquid_1": {
"slot": 2,
"well": [
"A1",
"A2",
"A4"
],
"labware": "reagent reservoir"
},
"Liquid_4": {
"slot": 2,
"well": [
"A1",
"A2",
"A4"
],
"labware": "reagent reservoir"
},
"Liquid_5": {
"slot": 2,
"well": [
"A1",
"A2",
"A4"
],
"labware": "reagent reservoir"
},
"Liquid_2": {
"slot": 4,
"well": [
"A2"
],
"labware": "TAG1 plate on Magnetic Module GEN2"
},
"Liquid_3": {
"slot": 12,
"well": [
"A1"
],
"labware": "Opentrons Fixed Trash"
}
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 148 KiB

View File

@@ -1,63 +0,0 @@
{
"steps_info": [
{
"step_number": 1,
"action": "transfer_liquid",
"parameters": {
"source": "sample supernatant",
"target": "antibody-coated well",
"volume": 100
}
},
{
"step_number": 2,
"action": "transfer_liquid",
"parameters": {
"source": "washing buffer",
"target": "antibody-coated well",
"volume": 200
}
},
{
"step_number": 3,
"action": "transfer_liquid",
"parameters": {
"source": "washing buffer",
"target": "antibody-coated well",
"volume": 200
}
},
{
"step_number": 4,
"action": "transfer_liquid",
"parameters": {
"source": "washing buffer",
"target": "antibody-coated well",
"volume": 200
}
},
{
"step_number": 5,
"action": "transfer_liquid",
"parameters": {
"source": "TMB substrate",
"target": "antibody-coated well",
"volume": 100
}
}
],
"labware_info": [
{"reagent_name": "sample supernatant", "material_name": "96深孔板", "positions": 1},
{"reagent_name": "washing buffer", "material_name": "储液槽", "positions": 2},
{"reagent_name": "TMB substrate", "material_name": "储液槽", "positions": 3},
{"reagent_name": "antibody-coated well", "material_name": "96 细胞培养皿", "positions": 4},
{"reagent_name": "", "material_name": "300μL Tip头", "positions": 5},
{"reagent_name": "", "material_name": "300μL Tip头", "positions": 6},
{"reagent_name": "", "material_name": "300μL Tip头", "positions": 7},
{"reagent_name": "", "material_name": "300μL Tip头", "positions": 8},
{"reagent_name": "", "material_name": "300μL Tip头", "positions": 9},
{"reagent_name": "", "material_name": "300μL Tip头", "positions": 10},
{"reagent_name": "", "material_name": "300μL Tip头", "positions": 11},
{"reagent_name": "", "material_name": "300μL Tip头", "positions": 13}
]
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 117 KiB

View File

@@ -1,94 +0,0 @@
import json
import sys
from datetime import datetime
from pathlib import Path
ROOT_DIR = Path(__file__).resolve().parents[2]
if str(ROOT_DIR) not in sys.path:
sys.path.insert(0, str(ROOT_DIR))
import pytest
from scripts.workflow import build_protocol_graph, draw_protocol_graph, draw_protocol_graph_with_ports
ROOT_DIR = Path(__file__).resolve().parents[2]
if str(ROOT_DIR) not in sys.path:
sys.path.insert(0, str(ROOT_DIR))
def _normalize_steps(data):
normalized = []
for step in data:
action = step.get("action") or step.get("operation")
if not action:
continue
raw_params = step.get("parameters") or step.get("action_args") or {}
params = dict(raw_params)
if "source" in raw_params and "sources" not in raw_params:
params["sources"] = raw_params["source"]
if "target" in raw_params and "targets" not in raw_params:
params["targets"] = raw_params["target"]
description = step.get("description") or step.get("purpose")
step_dict = {"action": action, "parameters": params}
if description:
step_dict["description"] = description
normalized.append(step_dict)
return normalized
def _normalize_labware(data):
labware = {}
for item in data:
reagent_name = item.get("reagent_name")
key = reagent_name or item.get("material_name") or item.get("name")
if not key:
continue
key = str(key)
idx = 1
original_key = key
while key in labware:
idx += 1
key = f"{original_key}_{idx}"
labware[key] = {
"slot": item.get("positions") or item.get("slot"),
"labware": item.get("material_name") or item.get("labware"),
"well": item.get("well", []),
"type": item.get("type", "reagent"),
"role": item.get("role", ""),
"name": key,
}
return labware
@pytest.mark.parametrize("protocol_name", [
"example_bio",
# "bioyond_materials_liquidhandling_1",
"example_prcxi",
])
def test_build_protocol_graph(protocol_name):
data_path = Path(__file__).with_name(f"{protocol_name}.json")
with data_path.open("r", encoding="utf-8") as fp:
d = json.load(fp)
if "workflow" in d and "reagent" in d:
protocol_steps = d["workflow"]
labware_info = d["reagent"]
elif "steps_info" in d and "labware_info" in d:
protocol_steps = _normalize_steps(d["steps_info"])
labware_info = _normalize_labware(d["labware_info"])
else:
raise ValueError("Unsupported protocol format")
graph = build_protocol_graph(
labware_info=labware_info,
protocol_steps=protocol_steps,
workstation_name="PRCXi",
)
timestamp = datetime.now().strftime("%Y%m%d_%H%M")
output_path = data_path.with_name(f"{protocol_name}_graph_{timestamp}.png")
draw_protocol_graph_with_ports(graph, str(output_path))
print(graph)

View File

@@ -1 +0,0 @@
__version__ = "0.10.7"

View File

@@ -1,19 +1,18 @@
import threading
from unilabos.ros.nodes.resource_tracker import ResourceTreeSet
from unilabos.utils import logger
# 根据选择的 backend 启动相应的功能
def start_backend(
backend: str,
devices_config: ResourceTreeSet,
resources_config: ResourceTreeSet,
resources_edge_config: list[dict] = [],
devices_config: dict = {},
resources_config: list = [],
resources_edge_config: list = [],
graph=None,
controllers_config: dict = {},
bridges=[],
is_slave: bool = False,
without_host: bool = False,
visual: str = "None",
resources_mesh_config: dict = {},
**kwargs,
@@ -32,7 +31,7 @@ def start_backend(
raise ValueError(f"Unsupported backend: {backend}")
backend_thread = threading.Thread(
target=main if not is_slave else slave,
target=main if not without_host else slave,
args=(
devices_config,
resources_config,

View File

@@ -6,19 +6,21 @@ import signal
import sys
import threading
import time
from typing import Dict, Any, List
from copy import deepcopy
import networkx as nx
import yaml
# 首先添加项目根目录到路径
current_dir = os.path.dirname(os.path.abspath(__file__))
unilabos_dir = os.path.dirname(os.path.dirname(current_dir))
if unilabos_dir not in sys.path:
sys.path.append(unilabos_dir)
from unilabos.utils.banner_print import print_status, print_unilab_banner
from unilabos.config.config import load_config, BasicConfig, HTTPConfig
from unilabos.utils.banner_print import print_status, print_unilab_banner
from unilabos.resources.graphio import modify_to_backend_format
def load_config_from_file(config_path):
if config_path is None:
@@ -41,7 +43,7 @@ def convert_argv_dashes_to_underscores(args: argparse.ArgumentParser):
for i, arg in enumerate(sys.argv):
for option_string in option_strings:
if arg.startswith(option_string):
new_arg = arg[:2] + arg[2:len(option_string)].replace("-", "_") + arg[len(option_string):]
new_arg = arg[:2] + arg[2 : len(option_string)].replace("-", "_") + arg[len(option_string) :]
sys.argv[i] = new_arg
break
@@ -180,7 +182,6 @@ def main():
working_dir = os.path.abspath(os.getcwd())
else:
working_dir = os.path.abspath(os.path.join(os.getcwd(), "unilabos_data"))
if args_dict.get("working_dir"):
working_dir = args_dict.get("working_dir", "")
if config_path and not os.path.exists(config_path):
@@ -212,14 +213,6 @@ def main():
# 加载配置文件
print_status(f"当前工作目录为 {working_dir}", "info")
load_config_from_file(config_path)
# 根据配置重新设置日志级别
from unilabos.utils.log import configure_logger, logger
if hasattr(BasicConfig, "log_level"):
logger.info(f"Log level set to '{BasicConfig.log_level}' from config file.")
configure_logger(loglevel=BasicConfig.log_level)
if args_dict["addr"] == "test":
print_status("使用测试环境地址", "info")
HTTPConfig.remote_addr = "https://uni-lab.test.bohrium.com/api/v1"
@@ -232,15 +225,6 @@ def main():
else:
HTTPConfig.remote_addr = args_dict.get("addr", "")
# 设置BasicConfig参数
if args_dict.get("ak", ""):
BasicConfig.ak = args_dict.get("ak", "")
print_status("传入了ak参数优先采用传入参数", "info")
if args_dict.get("sk", ""):
BasicConfig.sk = args_dict.get("sk", "")
print_status("传入了sk参数优先采用传入参数", "info")
# 使用远程资源启动
if args_dict["use_remote_resource"]:
print_status("使用远程资源启动", "info")
from unilabos.app.web import http_client
@@ -252,6 +236,13 @@ def main():
else:
print_status("远程资源不存在,本地将进行首次上报!", "info")
# 设置BasicConfig参数
if args_dict.get("ak", ""):
BasicConfig.ak = args_dict.get("ak", "")
print_status("传入了ak参数优先采用传入参数", "info")
if args_dict.get("sk", ""):
BasicConfig.sk = args_dict.get("sk", "")
print_status("传入了sk参数优先采用传入参数", "info")
BasicConfig.working_dir = working_dir
BasicConfig.is_host_mode = not args_dict.get("is_slave", False)
BasicConfig.slave_no_host = args_dict.get("slave_no_host", False)
@@ -266,6 +257,8 @@ def main():
read_node_link_json,
read_graphml,
dict_from_graph,
dict_to_nested_dict,
initialize_resources,
)
from unilabos.app.communication import get_communication_client
from unilabos.registry.registry import build_registry
@@ -273,8 +266,6 @@ def main():
from unilabos.app.web import http_client
from unilabos.app.web import start_server
from unilabos.app.register import register_devices_and_resources
from unilabos.resources.graphio import modify_to_backend_format
from unilabos.ros.nodes.resource_tracker import ResourceTreeSet, ResourceDict
# 显示启动横幅
print_unilab_banner(args_dict)
@@ -287,11 +278,8 @@ def main():
if not BasicConfig.ak or not BasicConfig.sk:
print_status("后续运行必须拥有一个实验室,请前往 https://uni-lab.bohrium.com 注册实验室!", "warning")
os._exit(1)
graph: nx.Graph
resource_tree_set: ResourceTreeSet
resource_links: List[Dict[str, Any]]
request_startup_json = http_client.request_startup_json()
if args_dict["graph"] is None:
request_startup_json = http_client.request_startup_json()
if not request_startup_json:
print_status(
"未指定设备加载文件路径尝试从HTTP获取失败请检查网络或者使用-g参数指定设备加载文件路径", "error"
@@ -299,64 +287,61 @@ def main():
os._exit(1)
else:
print_status("联网获取设备加载文件成功", "info")
graph, resource_tree_set, resource_links = read_node_link_json(request_startup_json)
graph, data = read_node_link_json(request_startup_json)
else:
file_path = args_dict["graph"]
if file_path.endswith(".json"):
graph, resource_tree_set, resource_links = read_node_link_json(file_path)
graph, data = read_node_link_json(file_path)
else:
graph, resource_tree_set, resource_links = read_graphml(file_path)
graph, data = read_graphml(file_path)
import unilabos.resources.graphio as graph_res
graph_res.physical_setup_graph = graph
resource_edge_info = modify_to_backend_format(resource_links)
resource_edge_info = modify_to_backend_format(data["links"])
materials = lab_registry.obtain_registry_resource_info()
materials.extend(lab_registry.obtain_registry_device_info())
materials = {k["id"]: k for k in materials}
# 从 ResourceTreeSet 中获取节点信息
nodes = {node.res_content.id: node.res_content for node in resource_tree_set.all_nodes}
nodes = {k["id"]: k for k in data["nodes"]}
edge_info = len(resource_edge_info)
for ind, i in enumerate(resource_edge_info[::-1]):
source_node: ResourceDict = nodes[i["source"]]
target_node: ResourceDict = nodes[i["target"]]
source_node = nodes[i["source"]]
target_node = nodes[i["target"]]
source_handle = i["sourceHandle"]
target_handle = i["targetHandle"]
source_handler_keys = [
h["handler_key"] for h in materials[source_node.klass]["handles"] if h["io_type"] == "source"
h["handler_key"] for h in materials[source_node["class"]]["handles"] if h["io_type"] == "source"
]
target_handler_keys = [
h["handler_key"] for h in materials[target_node.klass]["handles"] if h["io_type"] == "target"
h["handler_key"] for h in materials[target_node["class"]]["handles"] if h["io_type"] == "target"
]
if source_handle not in source_handler_keys:
print_status(
f"节点 {source_node.id} 的source端点 {source_handle} 不存在,请检查,支持的端点 {source_handler_keys}",
f"节点 {source_node['id']} 的source端点 {source_handle} 不存在,请检查,支持的端点 {source_handler_keys}",
"error",
)
resource_edge_info.pop(edge_info - ind - 1)
continue
if target_handle not in target_handler_keys:
print_status(
f"节点 {target_node.id} 的target端点 {target_handle} 不存在,请检查,支持的端点 {target_handler_keys}",
f"节点 {target_node['id']} 的target端点 {target_handle} 不存在,请检查,支持的端点 {target_handler_keys}",
"error",
)
resource_edge_info.pop(edge_info - ind - 1)
continue
# 如果从远端获取了物料信息,则与本地物料进行同步
if request_startup_json and "nodes" in request_startup_json:
print_status("开始同步远端物料到本地...", "info")
remote_tree_set = ResourceTreeSet.from_raw_list(request_startup_json["nodes"])
resource_tree_set.merge_remote_resources(remote_tree_set)
print_status("远端物料同步完成", "info")
# 使用 ResourceTreeSet 代替 list
args_dict["resources_config"] = resource_tree_set
args_dict["devices_config"] = resource_tree_set
devices_and_resources = dict_from_graph(graph_res.physical_setup_graph)
# args_dict["resources_config"] = initialize_resources(list(deepcopy(devices_and_resources).values()))
args_dict["resources_config"] = list(devices_and_resources.values())
args_dict["devices_config"] = dict_to_nested_dict(deepcopy(devices_and_resources), devices_only=False)
args_dict["graph"] = graph_res.physical_setup_graph
print_status(f"{len(args_dict['resources_config'])} Resources loaded:", "info")
for i in args_dict["resources_config"]:
print_status(f"DeviceId: {i['id']}, Class: {i['class']}", "info")
if BasicConfig.upload_registry:
# 设备注册到服务端 - 需要 ak 和 sk
if BasicConfig.ak and BasicConfig.sk:
if args_dict.get("ak") and args_dict.get("sk"):
print_status("开始注册设备到服务端...", "info")
try:
register_devices_and_resources(lab_registry)
@@ -366,7 +351,9 @@ def main():
else:
print_status("未提供 ak 和 sk跳过设备注册", "info")
else:
print_status("本次启动注册表不报送云端,如果您需要联网调试,请在启动命令增加--upload_registry", "warning")
print_status(
"本次启动注册表不报送云端,如果您需要联网调试,请在启动命令增加--upload_registry", "warning"
)
if args_dict["controllers"] is not None:
args_dict["controllers_config"] = yaml.safe_load(open(args_dict["controllers"], encoding="utf-8"))
@@ -375,38 +362,34 @@ def main():
args_dict["bridges"] = []
# 获取通信客户端仅支持WebSocket
comm_client = get_communication_client()
if "websocket" in args_dict["app_bridges"]:
args_dict["bridges"].append(comm_client)
if "fastapi" in args_dict["app_bridges"]:
args_dict["bridges"].append(http_client)
# 获取通信客户端仅支持WebSocket
if BasicConfig.is_host_mode:
comm_client = get_communication_client()
if "websocket" in args_dict["app_bridges"]:
args_dict["bridges"].append(comm_client)
def _exit(signum, frame):
comm_client.stop()
sys.exit(0)
if "websocket" in args_dict["app_bridges"]:
signal.signal(signal.SIGINT, _exit)
signal.signal(signal.SIGTERM, _exit)
comm_client.start()
else:
print_status("SlaveMode跳过Websocket连接")
def _exit(signum, frame):
comm_client.stop()
sys.exit(0)
signal.signal(signal.SIGINT, _exit)
signal.signal(signal.SIGTERM, _exit)
comm_client.start()
args_dict["resources_mesh_config"] = {}
args_dict["resources_edge_config"] = resource_edge_info
# web visiualize 2D
if args_dict["visual"] != "disable":
enable_rviz = args_dict["visual"] == "rviz"
devices_and_resources = dict_from_graph(graph_res.physical_setup_graph)
if devices_and_resources is not None:
from unilabos.device_mesh.resource_visalization import (
ResourceVisualization,
) # 此处开启后logger会变更为INFO有需要请调整
resource_visualization = ResourceVisualization(
devices_and_resources,
[n.res_content for n in args_dict["resources_config"].all_nodes], # type: ignore # FIXME
enable_rviz=enable_rviz,
devices_and_resources, args_dict["resources_config"], enable_rviz=enable_rviz
)
args_dict["resources_mesh_config"] = resource_visualization.resource_model
start_backend(**args_dict)

View File

@@ -1,12 +1,16 @@
import argparse
import json
import time
from typing import Optional, Tuple, Dict, Any
from unilabos.config.config import BasicConfig
from unilabos.registry.registry import build_registry
from unilabos.app.main import load_config_from_file
from unilabos.utils.log import logger
from unilabos.utils.type_check import TypeEncoder
def register_devices_and_resources(lab_registry, gather_only=False) -> Optional[Tuple[Dict[str, Any], Dict[str, Any]]]:
def register_devices_and_resources(lab_registry):
"""
注册设备和资源到服务器仅支持HTTP
"""
@@ -29,8 +33,6 @@ def register_devices_and_resources(lab_registry, gather_only=False) -> Optional[
resources_to_register[resource_info["id"]] = resource_info
logger.debug(f"[UniLab Register] 收集资源: {resource_info['id']}")
if gather_only:
return devices_to_register, resources_to_register
# 注册设备
if devices_to_register:
try:

View File

@@ -6,12 +6,9 @@ HTTP客户端模块
import json
import os
import time
from threading import Thread
from typing import List, Dict, Any, Optional
import requests
from unilabos.ros.nodes.resource_tracker import ResourceTreeSet
from unilabos.utils.log import info
from unilabos.config.config import HTTPConfig, BasicConfig
from unilabos.utils import logger
@@ -49,7 +46,7 @@ class HTTPClient:
Response: API响应对象
"""
response = requests.post(
f"{self.remote_addr}/edge/material/edge",
f"{self.remote_addr}/lab/material/edge",
json={
"edges": resources,
},
@@ -64,91 +61,6 @@ class HTTPClient:
logger.error(f"添加物料关系失败: {response.status_code}, {response.text}")
return response
def resource_tree_add(self, resources: ResourceTreeSet, mount_uuid: str, first_add: bool) -> Dict[str, str]:
"""
添加资源
Args:
resources: 要添加的资源树集合ResourceTreeSet
mount_uuid: 要挂载的资源的uuid
first_add: 是否为首次添加资源可以是host也可以是slave来的
Returns:
Dict[str, str]: 旧UUID到新UUID的映射关系 {old_uuid: new_uuid}
"""
with open(os.path.join(BasicConfig.working_dir, "req_resource_tree_add.json"), "w", encoding="utf-8") as f:
f.write(json.dumps({"nodes": [x for xs in resources.dump() for x in xs], "mount_uuid": mount_uuid}, indent=4))
# 从序列化数据中提取所有节点的UUID保存旧UUID
old_uuids = {n.res_content.uuid: n for n in resources.all_nodes}
if not self.initialized or first_add:
self.initialized = True
info(f"首次添加资源,当前远程地址: {self.remote_addr}")
response = requests.post(
f"{self.remote_addr}/edge/material",
json={"nodes": [x for xs in resources.dump() for x in xs], "mount_uuid": mount_uuid},
headers={"Authorization": f"Lab {self.auth}"},
timeout=60,
)
else:
response = requests.put(
f"{self.remote_addr}/edge/material",
json={"nodes": [x for xs in resources.dump() for x in xs], "mount_uuid": mount_uuid},
headers={"Authorization": f"Lab {self.auth}"},
timeout=10,
)
with open(os.path.join(BasicConfig.working_dir, "res_resource_tree_add.json"), "w", encoding="utf-8") as f:
f.write(f"{response.status_code}" + "\n" + response.text)
# 处理响应构建UUID映射
uuid_mapping = {}
if response.status_code == 200:
res = response.json()
if "code" in res and res["code"] != 0:
logger.error(f"添加物料失败: {response.text}")
else:
data = res["data"]
for i in data:
uuid_mapping[i["uuid"]] = i["cloud_uuid"]
else:
logger.error(f"添加物料失败: {response.text}")
for u, n in old_uuids.items():
if u in uuid_mapping:
n.res_content.uuid = uuid_mapping[u]
for c in n.children:
c.res_content.parent_uuid = n.res_content.uuid
else:
logger.warning(f"资源UUID未更新: {u}")
return uuid_mapping
def resource_tree_get(self, uuid_list: List[str], with_children: bool) -> List[Dict[str, Any]]:
"""
添加资源
Args:
uuid_list: List[str]
Returns:
Dict[str, str]: 旧UUID到新UUID的映射关系 {old_uuid: new_uuid}
"""
with open(os.path.join(BasicConfig.working_dir, "req_resource_tree_get.json"), "w", encoding="utf-8") as f:
f.write(json.dumps({"uuids": uuid_list, "with_children": with_children}, indent=4))
response = requests.post(
f"{self.remote_addr}/edge/material/query",
json={"uuids": uuid_list, "with_children": with_children},
headers={"Authorization": f"Lab {self.auth}"},
timeout=100,
)
with open(os.path.join(BasicConfig.working_dir, "res_resource_tree_get.json"), "w", encoding="utf-8") as f:
f.write(f"{response.status_code}" + "\n" + response.text)
if response.status_code == 200:
res = response.json()
if "code" in res and res["code"] != 0:
logger.error(f"查询物料失败: {response.text}")
else:
data = res["data"]["nodes"]
return data
else:
logger.error(f"查询物料失败: {response.text}")
return []
def resource_add(self, resources: List[Dict[str, Any]]) -> requests.Response:
"""
添加资源
@@ -193,16 +105,12 @@ class HTTPClient:
Returns:
Dict: 返回的资源数据
"""
with open(os.path.join(BasicConfig.working_dir, "req_resource_get.json"), "w", encoding="utf-8") as f:
f.write(json.dumps({"id": id, "with_children": with_children}, indent=4))
response = requests.get(
f"{self.remote_addr}/lab/material",
params={"id": id, "with_children": with_children},
headers={"Authorization": f"Lab {self.auth}"},
timeout=20,
)
with open(os.path.join(BasicConfig.working_dir, "res_resource_get.json"), "w", encoding="utf-8") as f:
f.write(f"{response.status_code}" + "\n" + response.text)
return response.json()
def resource_del(self, id: str) -> requests.Response:
@@ -312,7 +220,7 @@ class HTTPClient:
Response: API响应对象
"""
response = requests.get(
f"{self.remote_addr}/edge/material/download",
f"{self.remote_addr}/lab/resource/graph_info/",
headers={"Authorization": f"Lab {self.auth}"},
timeout=(3, 30),
)

View File

@@ -19,12 +19,9 @@ import websockets
import ssl as ssl_module
from queue import Queue, Empty
from dataclasses import dataclass, field
from typing import Optional, Dict, Any, List
from typing import Optional, Dict, Any, Callable, List, Set
from urllib.parse import urlparse
from enum import Enum
from jedi.inference.gradual.typing import TypedDict
from unilabos.app.model import JobAddReq
from unilabos.ros.nodes.presets.host_node import HostNode
from unilabos.utils.type_check import serialize_result_info
@@ -99,14 +96,6 @@ class WebSocketMessage:
timestamp: float = field(default_factory=time.time)
class WSResourceChatData(TypedDict):
uuid: str
device_uuid: str
device_id: str
device_old_uuid: str
device_old_id: str
class DeviceActionManager:
"""设备动作管理器 - 管理每个device_action_key的任务队列"""
@@ -421,7 +410,7 @@ class MessageProcessor:
ssl_context = ssl_module.create_default_context()
ws_logger = logging.getLogger("websockets.client")
# 日志级别已在 unilabos.utils.log 中统一配置为 WARNING
ws_logger.setLevel(logging.INFO)
async with websockets.connect(
self.websocket_url,
@@ -554,7 +543,7 @@ class MessageProcessor:
async def _process_message(self, data: Dict[str, Any]):
"""处理收到的消息"""
message_type = data.get("action", "")
message_data = data.get("data")
message_data = data.get("data", {})
logger.debug(f"[MessageProcessor] Processing message: {message_type}")
@@ -567,12 +556,8 @@ class MessageProcessor:
await self._handle_job_start(message_data)
elif message_type == "cancel_action" or message_type == "cancel_task":
await self._handle_cancel_action(message_data)
elif message_type == "add_material":
await self._handle_resource_tree_update(message_data, "add")
elif message_type == "update_material":
await self._handle_resource_tree_update(message_data, "update")
elif message_type == "remove_material":
await self._handle_resource_tree_update(message_data, "remove")
elif message_type == "":
return
else:
logger.debug(f"[MessageProcessor] Unknown message type: {message_type}")
@@ -589,7 +574,6 @@ class MessageProcessor:
async def _handle_query_action_state(self, data: Dict[str, Any]):
"""处理query_action_state消息"""
device_id = data.get("device_id", "")
device_uuid = data.get("device_uuid", "")
action_name = data.get("action_name", "")
task_id = data.get("task_id", "")
job_id = data.get("job_id", "")
@@ -776,92 +760,6 @@ class MessageProcessor:
else:
logger.warning("[MessageProcessor] Cancel request missing both task_id and job_id")
async def _handle_resource_tree_update(self, resource_uuid_list: List[WSResourceChatData], action: str):
"""处理资源树更新消息add_material/update_material/remove_material"""
if not resource_uuid_list:
return
# 按device_id和action分组
# device_action_groups: {(device_id, action): [uuid_list]}
device_action_groups = {}
for item in resource_uuid_list:
device_id = item["device_id"]
if not device_id:
device_id = "host_node"
# 特殊处理update action: 检查是否设备迁移
if action == "update":
device_old_id = item.get("device_old_id", "")
if not device_old_id:
device_old_id = "host_node"
# 设备迁移device_id != device_old_id
if device_id != device_old_id:
# 给旧设备发送remove
key_remove = (device_old_id, "remove")
if key_remove not in device_action_groups:
device_action_groups[key_remove] = []
device_action_groups[key_remove].append(item["uuid"])
# 给新设备发送add
key_add = (device_id, "add")
if key_add not in device_action_groups:
device_action_groups[key_add] = []
device_action_groups[key_add].append(item["uuid"])
logger.info(
f"[MessageProcessor] Resource migrated: {item['uuid'][:8]} from {device_old_id} to {device_id}"
)
else:
# 正常update
key = (device_id, "update")
if key not in device_action_groups:
device_action_groups[key] = []
device_action_groups[key].append(item["uuid"])
else:
# add或remove action直接分组
key = (device_id, action)
if key not in device_action_groups:
device_action_groups[key] = []
device_action_groups[key].append(item["uuid"])
logger.info(f"触发物料更新 {action} 分组数量: {len(device_action_groups)}, 总数量: {len(resource_uuid_list)}")
# 为每个(device_id, action)创建独立的更新线程
for (device_id, actual_action), items in device_action_groups.items():
logger.info(f"设备 {device_id} 物料更新 {actual_action} 数量: {len(items)}")
def _notify_resource_tree(dev_id, act, item_list):
try:
host_node = HostNode.get_instance(timeout=5)
if not host_node:
logger.error(f"[MessageProcessor] HostNode instance not available for {act}")
return
success = host_node.notify_resource_tree_update(dev_id, act, item_list)
if success:
logger.info(
f"[MessageProcessor] Resource tree {act} completed for device {dev_id}, "
f"items: {len(item_list)}"
)
else:
logger.warning(f"[MessageProcessor] Resource tree {act} failed for device {dev_id}")
except Exception as e:
logger.error(f"[MessageProcessor] Error in resource tree {act} for device {dev_id}: {str(e)}")
logger.error(traceback.format_exc())
# 在新线程中执行通知
thread = threading.Thread(
target=_notify_resource_tree,
args=(device_id, actual_action, items),
daemon=True,
name=f"ResourceTreeUpdate-{actual_action}-{device_id}",
)
thread.start()
async def _send_action_state_response(
self, device_id: str, action_name: str, task_id: str, job_id: str, typ: str, free: bool, need_more: int
):
@@ -1110,8 +1008,6 @@ class WebSocketClient(BaseCommunicationClient):
# 构建WebSocket URL
self.websocket_url = self._build_websocket_url()
if not self.websocket_url:
self.websocket_url = "" # 默认空字符串避免None
# 两个核心线程
self.message_processor = MessageProcessor(self.websocket_url, self.send_queue, self.device_manager)
@@ -1197,7 +1093,7 @@ class WebSocketClient(BaseCommunicationClient):
},
}
self.message_processor.send_message(message)
logger.trace(f"[WebSocketClient] Device status published: {device_id}.{property_name}")
logger.debug(f"[WebSocketClient] Device status published: {device_id}.{property_name}")
def publish_job_status(
self, feedback_data: dict, item: QueueItem, status: str, return_info: Optional[dict] = None

View File

@@ -2,7 +2,7 @@ import base64
import traceback
import os
import importlib.util
from typing import Optional, Literal
from typing import Optional
from unilabos.utils import logger
@@ -18,7 +18,6 @@ class BasicConfig:
vis_2d_enable = False
enable_resource_load = True
communication_protocol = "websocket"
log_level: Literal['TRACE', 'DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'] = "DEBUG" # 'TRACE', 'DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'
@classmethod
def auth_secret(cls):

View File

@@ -1,150 +0,0 @@
<?xml version="1.0" ?>
<robot name="liquid_transform_xyz" xmlns:xacro="http://www.ros.org/wiki/xacro">
<xacro:macro name="liquid_transform_xyz" params="mesh_path:='' parent_link:='' station_name:='' device_name:='' x:=0 y:=0 z:=0 rx:=0 ry:=0 r:=0">
<joint name="${station_name}${device_name}base_link_joint" type="fixed">
<origin xyz="${x} ${y} ${z}" rpy="${rx} ${ry} ${r}" />
<parent link="${parent_link}"/>
<child link="${station_name}${device_name}device_link"/>
<axis xyz="0 0 0"/>
</joint>
<link name="${station_name}${device_name}device_link"/>
<joint name="${station_name}${device_name}device_link_joint" type="fixed">
<origin xyz="0 0 0" rpy="0 0 0" />
<parent link="${station_name}${device_name}device_link"/>
<child link="${station_name}${device_name}base_link"/>
<axis xyz="0 0 0"/>
</joint>
<!-- =================================================================================== -->
<!-- | This document was autogenerated by xacro from xyz.urdf | -->
<!-- | EDITING THIS FILE BY HAND IS NOT RECOMMENDED | -->
<!-- =================================================================================== -->
<!-- This URDF was automatically created by SolidWorks to URDF Exporter! Originally created by Stephen Brawner (brawner@gmail.com)
Commit Version: 1.6.0-4-g7f85cfe Build Version: 1.6.7995.38578
For more information, please see http://wiki.ros.org/sw_urdf_exporter -->
<link name="${station_name}${device_name}base_link">
<inertial>
<origin rpy="0 0 0" xyz="0.15478184748283 0.171048654921622 0.119246989054835"/>
<mass value="10.6178517218032"/>
<inertia ixx="0.178863713357329" ixy="1.50019641847353E-05" ixz="1.35368730492005E-05" iyy="0.174395775755846" iyz="-9.90771939078091E-06" izz="0.34100152139765"/>
</inertial>
<visual>
<origin rpy="0 0 0" xyz="0 0 0"/>
<geometry>
<mesh filename="file://${mesh_path}/devices/liquid_transform_xyz/meshes/base_link.STL"/>
</geometry>
<material name="">
<color rgba="0.792156862745098 0.819607843137255 0.933333333333333 1"/>
</material>
</visual>
<collision>
<origin rpy="0 0 0" xyz="0 0 0"/>
<geometry>
<mesh filename="file://${mesh_path}/devices/liquid_transform_xyz/meshes/base_link.STL"/>
</geometry>
</collision>
</link>
<link name="${station_name}${device_name}x_link">
<inertial>
<origin rpy="0 0 0" xyz="0.325214039540178 0.00943452607370124 0.0482611114301988"/>
<mass value="2.10887387421016"/>
<inertia ixx="0.0012305846984949" ixy="5.54649260270946E-07" ixz="3.84099347741331E-07" iyy="0.0349382006090243" iyz="-0.000103697818531446" izz="0.0354178972785773"/>
</inertial>
<visual>
<origin rpy="0 0 0" xyz="0 0 0"/>
<geometry>
<mesh filename="file://${mesh_path}/devices/liquid_transform_xyz/meshes/x_link.STL"/>
</geometry>
<material name="">
<color rgba="0.792156862745098 0.819607843137255 0.933333333333333 1"/>
</material>
</visual>
<collision>
<origin rpy="0 0 0" xyz="0 0 0"/>
<geometry>
<mesh filename="file://${mesh_path}/devices/liquid_transform_xyz/meshes/x_link.STL"/>
</geometry>
</collision>
</link>
<joint name="${station_name}${device_name}x_joint" type="prismatic">
<origin rpy="0 0 0" xyz="-0.141499999999982 0.334850000000045 0.357700036886815"/>
<parent link="${station_name}${device_name}base_link"/>
<child link="${station_name}${device_name}x_link"/>
<axis xyz="0 1 0"/>
<limit effort="50" lower="-0.3" upper="0" velocity="1"/>
</joint>
<link name="${station_name}${device_name}y_link">
<inertial>
<origin rpy="0 0 0" xyz="-1.50235389641123E-05 -0.00104302241099613 -0.0439486470514941"/>
<mass value="0.57605998885478"/>
<inertia ixx="0.00193021581150653" ixy="3.53777102560584E-08" ixz="2.57202248177777E-07" iyy="0.00224712797067005" iyz="-3.96170906880708E-07" izz="0.000419338880142789"/>
</inertial>
<visual>
<origin rpy="0 0 0" xyz="0 0 0"/>
<geometry>
<mesh filename="file://${mesh_path}/devices/liquid_transform_xyz/meshes/y_link.STL"/>
</geometry>
<material name="">
<color rgba="0.792156862745098 0.819607843137255 0.933333333333333 1"/>
</material>
</visual>
<collision>
<origin rpy="0 0 0" xyz="0 0 0"/>
<geometry>
<mesh filename="file://${mesh_path}/devices/liquid_transform_xyz/meshes/y_link.STL"/>
</geometry>
</collision>
</link>
<joint name="${station_name}${device_name}y_joint" type="prismatic">
<origin rpy="0 0 0" xyz="0.2855 -0.0330000368868711 0.0578"/>
<parent link="${station_name}${device_name}x_link"/>
<child link="${station_name}${device_name}y_link"/>
<axis xyz="-1 0 0"/>
<limit effort="50" lower="-0.25" upper="0.25" velocity="1"/>
</joint>
<link name="${station_name}${device_name}z_link">
<inertial>
<origin rpy="0 0 0" xyz="-1.07272060046598E-06 -0.00954902784618396 0.017834416924223"/>
<mass value="0.199932032754258"/>
<inertia ixx="0.000219989530768707" ixy="-7.50956522121896E-10" ixz="-1.265045524863E-07" iyy="0.000245054780375167" iyz="-3.76753893185657E-06" izz="4.29092763044732E-05"/>
</inertial>
<visual>
<origin rpy="0 0 0" xyz="0 0 0"/>
<geometry>
<mesh filename="file://${mesh_path}/devices/liquid_transform_xyz/meshes/z_link.STL"/>
</geometry>
<material name="">
<color rgba="0.792156862745098 0.819607843137255 0.933333333333333 1"/>
</material>
</visual>
<collision>
<origin rpy="0 0 0" xyz="0 0 0"/>
<geometry>
<mesh filename="file://${mesh_path}/devices/liquid_transform_xyz/meshes/z_link.STL"/>
</geometry>
</collision>
</link>
<joint name="${station_name}${device_name}z_joint" type="prismatic">
<origin rpy="0 0 0" xyz="0.000950000000001387 -0.0737000000000002 0"/>
<parent link="${station_name}${device_name}y_link"/>
<child link="${station_name}${device_name}z_link"/>
<axis xyz="0 0 1"/>
<limit effort="50" lower="-0.2" upper="0" velocity="1"/>
</joint>
<link name="${station_name}${device_name}p_link">
</link>
<joint name="${station_name}${device_name}p_joint" type="fixed">
<origin rpy="0 0 0" xyz="0 -0.0139999999999999 -0.10575"/>
<parent link="${station_name}${device_name}z_link"/>
<child link="${station_name}${device_name}p_link"/>
<axis xyz="0 0 0"/>
</joint>
</xacro:macro>
</robot>

View File

@@ -12,7 +12,6 @@ from serial import Serial
from serial.serialutil import SerialException
from unilabos.messages import Point3D
from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode
class GrblCNCConnectionError(Exception):
@@ -33,7 +32,6 @@ class GrblCNCInfo:
class GrblCNCAsync:
_status: str = "Offline"
_position: Point3D = Point3D(x=0.0, y=0.0, z=0.0)
_ros_node: BaseROS2DeviceNode
def __init__(self, port: str, address: str = "1", limits: tuple[int, int, int, int, int, int] = (-150, 150, -200, 0, 0, 60)):
self.port = port
@@ -60,9 +58,6 @@ class GrblCNCAsync:
self._run_future: Optional[Future[Any]] = None
self._run_lock = Lock()
def post_init(self, ros_node: BaseROS2DeviceNode):
self._ros_node = ros_node
def _read_all(self):
data = self._serial.read_until(b"\n")
data_decoded = data.decode()
@@ -153,7 +148,7 @@ class GrblCNCAsync:
try:
await self._query(command)
while True:
await self._ros_node.sleep(0.2) # Wait for 0.5 seconds before polling again
await asyncio.sleep(0.2) # Wait for 0.5 seconds before polling again
status = await self.get_status()
if "Idle" in status:
@@ -219,7 +214,7 @@ class GrblCNCAsync:
self._pose_number = i
self.pose_number_remaining = len(points) - i
await self.set_position(point)
await self._ros_node.sleep(0.5)
await asyncio.sleep(0.5)
self._step_number = -1
async def stop_operation(self):
@@ -240,7 +235,7 @@ class GrblCNCAsync:
async def open(self):
if self._read_task:
raise GrblCNCConnectionError
self._read_task = self._ros_node.create_task(self._read_loop())
self._read_task = asyncio.create_task(self._read_loop())
try:
await self.get_status()

View File

@@ -2,8 +2,6 @@ import time
import asyncio
from pydantic import BaseModel
from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode
class Point3D(BaseModel):
x: float
@@ -16,14 +14,9 @@ def d(a: Point3D, b: Point3D) -> float:
class MockCNCAsync:
_ros_node: BaseROS2DeviceNode["MockCNCAsync"]
def __init__(self):
self._position: Point3D = Point3D(x=0.0, y=0.0, z=0.0)
self._status = "Idle"
def post_create(self, ros_node):
self._ros_node = ros_node
@property
def position(self) -> Point3D:
@@ -45,5 +38,5 @@ class MockCNCAsync:
self._position.x = current_pos.x + (position.x - current_pos.x) / 20 * (i+1)
self._position.y = current_pos.y + (position.y - current_pos.y) / 20 * (i+1)
self._position.z = current_pos.z + (position.z - current_pos.z) / 20 * (i+1)
await self._ros_node.sleep(move_time / 20)
await asyncio.sleep(move_time / 20)
self._status = "Idle"

View File

@@ -1,296 +0,0 @@
# -*- coding: utf-8 -*-
import serial
import time
import csv
import threading
import os
from collections import deque
from typing import Dict, Any, Optional
from pylabrobot.resources import Deck
from unilabos.devices.workstation.workstation_base import WorkstationBase
class ElectrolysisWaterPlatform(WorkstationBase):
"""
电解水平台工作站
基于 WorkstationBase 的电解水实验平台,支持串口通信和数据采集
"""
def __init__(
self,
deck: Deck,
port: str = "COM10",
baudrate: int = 115200,
csv_path: Optional[str] = None,
timeout: float = 0.2,
**kwargs
):
super().__init__(deck, **kwargs)
# ========== 配置 ==========
self.port = port
self.baudrate = baudrate
# 如果没有指定路径,默认保存在代码文件所在目录
if csv_path is None:
current_dir = os.path.dirname(os.path.abspath(__file__))
self.csv_path = os.path.join(current_dir, "stm32_data.csv")
else:
self.csv_path = csv_path
self.ser_timeout = timeout
self.chunk_read = 128
# 串口对象
self.ser: Optional[serial.Serial] = None
self.stop_flag = False
# 线程对象
self.rx_thread: Optional[threading.Thread] = None
self.tx_thread: Optional[threading.Thread] = None
# ==== 接收(下位机->上位机):固定 1+13+1 = 15 字节 ====
self.RX_HEAD = 0x3E
self.RX_TAIL = 0x3E
self.RX_FRAME_LEN = 1 + 13 + 1 # 15
# ==== 发送(上位机->下位机):固定 1+9+1 = 11 字节 ====
self.TX_HEAD = 0x3E
self.TX_TAIL = 0xE3 # 协议图中标注 E3 作为帧尾
self.TX_FRAME_LEN = 1 + 9 + 1 # 11
def open_serial(self, port: Optional[str] = None, baudrate: Optional[int] = None, timeout: Optional[float] = None) -> Optional[serial.Serial]:
"""打开串口"""
port = port or self.port
baudrate = baudrate or self.baudrate
timeout = timeout or self.ser_timeout
try:
ser = serial.Serial(port, baudrate, timeout=timeout)
print(f"[OK] 串口 {port} 已打开,波特率 {baudrate}")
ser.reset_input_buffer()
ser.reset_output_buffer()
self.ser = ser
return ser
except serial.SerialException as e:
print(f"[ERR] 无法打开串口 {port}: {e}")
return None
def close_serial(self):
"""关闭串口"""
if self.ser and self.ser.is_open:
self.ser.close()
print("[INFO] 串口已关闭")
@staticmethod
def u16_be(h: int, l: int) -> int:
"""将两个字节组合成16位无符号整数大端序"""
return ((h & 0xFF) << 8) | (l & 0xFF)
@staticmethod
def split_u16_be(val: int) -> tuple:
"""返回 (高字节, 低字节),输入会夹到 0..65535"""
v = int(max(0, min(65535, int(val))))
return (v >> 8) & 0xFF, v & 0xFF
# ================== 接收固定15字节 ==================
def parse_rx_payload(self, dat13: bytes) -> Optional[Dict[str, Any]]:
"""解析 13 字节数据区(下位机发送到上位机)"""
if len(dat13) != 13:
return None
current_mA = self.u16_be(dat13[0], dat13[1])
voltage_mV = self.u16_be(dat13[2], dat13[3])
temperature_raw = self.u16_be(dat13[4], dat13[5])
tds_ppm = self.u16_be(dat13[6], dat13[7])
gas_sccm = self.u16_be(dat13[8], dat13[9])
liquid_mL = self.u16_be(dat13[10], dat13[11])
ph_raw = dat13[12] & 0xFF
return {
"Current_mA": current_mA,
"Voltage_mV": voltage_mV,
"Temperature_C": round(temperature_raw / 100.0, 2),
"TDS_ppm": tds_ppm,
"GasFlow_sccm": gas_sccm,
"LiquidFlow_mL": liquid_mL,
"pH": round(ph_raw / 10.0, 2)
}
def try_parse_rx_frame(self, frame15: bytes) -> Optional[Dict[str, Any]]:
"""尝试解析接收帧"""
if len(frame15) != self.RX_FRAME_LEN:
return None
if frame15[0] != self.RX_HEAD or frame15[-1] != self.RX_TAIL:
return None
return self.parse_rx_payload(frame15[1:-1])
def rx_thread_fn(self):
"""接收线程函数"""
headers = ["Timestamp", "Current_mA", "Voltage_mV",
"Temperature_C", "TDS_ppm", "GasFlow_sccm", "LiquidFlow_mL", "pH"]
new_file = not os.path.exists(self.csv_path)
f = open(self.csv_path, mode='a', newline='', encoding='utf-8')
writer = csv.writer(f)
if new_file:
writer.writerow(headers)
f.flush()
buf = deque(maxlen=8192)
print(f"[RX] 开始接收(帧长 {self.RX_FRAME_LEN} 字节);写入:{self.csv_path}")
try:
while not self.stop_flag and self.ser and self.ser.is_open:
chunk = self.ser.read(self.chunk_read)
if chunk:
buf.extend(chunk)
while True:
# 找帧头
try:
start = next(i for i, b in enumerate(buf) if b == self.RX_HEAD)
except StopIteration:
buf.clear()
break
if start > 0:
for _ in range(start):
buf.popleft()
if len(buf) < self.RX_FRAME_LEN:
break
candidate = bytes([buf[i] for i in range(self.RX_FRAME_LEN)])
if candidate[-1] == self.RX_TAIL:
parsed = self.try_parse_rx_frame(candidate)
for _ in range(self.RX_FRAME_LEN):
buf.popleft()
if parsed:
ts = time.strftime("%Y-%m-%d %H:%M:%S")
row = [ts,
parsed["Current_mA"], parsed["Voltage_mV"],
parsed["Temperature_C"], parsed["TDS_ppm"],
parsed["GasFlow_sccm"], parsed["LiquidFlow_mL"],
parsed["pH"]]
writer.writerow(row)
f.flush()
# 若不想打印可注释下一行
# print(f"[{ts}] I={parsed['Current_mA']} mA, V={parsed['Voltage_mV']} mV, "
# f"T={parsed['Temperature_C']} °C, TDS={parsed['TDS_ppm']}, "
# f"Gas={parsed['GasFlow_sccm']} sccm, Liq={parsed['LiquidFlow_mL']} mL, pH={parsed['pH']}")
else:
# 头不变尾不对丢1字节继续对齐
buf.popleft()
else:
time.sleep(0.01)
finally:
f.close()
print("[RX] 接收线程退出CSV 已关闭")
# ================== 发送固定11字节 ==================
def build_tx_frame(self, mode: int, current_ma: int, voltage_mv: int, temp_c: float, ki: float, pump_percent: float) -> bytes:
"""
发送帧HEAD + [mode, I_hi, I_lo, V_hi, V_lo, T_hi, T_lo, Ki_byte, Pump_byte] + TAIL
- mode: 0=恒压, 1=恒流
- current_ma: mA (0..65535)
- voltage_mv: mV (0..65535)
- temp_c: ℃,将 *100 后拆分为高/低字节
- ki: 0.0..20.0 -> byte = round(ki * 10) 夹到 0..200
- pump_percent: 0..100 -> byte = round(pump * 2) 夹到 0..200
"""
mode_b = 1 if int(mode) == 1 else 0
i_hi, i_lo = self.split_u16_be(current_ma)
v_hi, v_lo = self.split_u16_be(voltage_mv)
t100 = int(round(float(temp_c) * 100.0))
t_hi, t_lo = self.split_u16_be(t100)
ki_b = int(max(0, min(200, round(float(ki) * 10))))
pump_b = int(max(0, min(200, round(float(pump_percent) * 2))))
return bytes((
self.TX_HEAD,
mode_b,
i_hi, i_lo,
v_hi, v_lo,
t_hi, t_lo,
ki_b,
pump_b,
self.TX_TAIL
))
def tx_thread_fn(self):
"""
发送线程函数
用户输入 6 个用逗号分隔的数值:
mode,current_mA,voltage_mV,set_temp_C,Ki,pump_percent
例如: 0,1000,500,0,0,50
"""
print("\n输入 6 个值(用英文逗号分隔),顺序为:")
print("mode,current_mA,voltage_mV,set_temp_C,Ki,pump_percent")
print("示例恒压0,500,1000,25,0,100 stop 结束)\n")
print("示例恒流1,1000,500,25,0,100 stop 结束)\n")
print("示例恒流1,2000,500,25,0,100 stop 结束)\n")
# 1,2000,500,25,0,100
while not self.stop_flag and self.ser and self.ser.is_open:
try:
line = input(">>> ").strip()
except EOFError:
self.stop_flag = True
break
if not line:
continue
if line.lower() == "stop":
self.stop_flag = True
print("[SYS] 停止程序")
break
try:
parts = [p.strip() for p in line.split(",")]
if len(parts) != 6:
raise ValueError("需要 6 个逗号分隔的数值")
mode = int(parts[0])
i_ma = int(float(parts[1]))
v_mv = int(float(parts[2]))
t_c = float(parts[3])
ki = float(parts[4])
pump = float(parts[5])
frame = self.build_tx_frame(mode, i_ma, v_mv, t_c, ki, pump)
self.ser.write(frame)
print("[TX]", " ".join(f"{b:02X}" for b in frame))
except Exception as e:
print("[TX] 输入/打包失败:", e)
print("格式mode,current_mA,voltage_mV,set_temp_C,Ki,pump_percent")
continue
def start(self):
"""启动电解水平台"""
self.ser = self.open_serial()
if self.ser:
try:
self.rx_thread = threading.Thread(target=self.rx_thread_fn, daemon=True)
self.tx_thread = threading.Thread(target=self.tx_thread_fn, daemon=True)
self.rx_thread.start()
self.tx_thread.start()
print("[INFO] 电解水平台已启动")
self.tx_thread.join() # 等待用户输入线程结束(输入 stop
finally:
self.close_serial()
def stop(self):
"""停止电解水平台"""
self.stop_flag = True
if self.rx_thread and self.rx_thread.is_alive():
self.rx_thread.join(timeout=2.0)
if self.tx_thread and self.tx_thread.is_alive():
self.tx_thread.join(timeout=2.0)
self.close_serial()
print("[INFO] 电解水平台已停止")
# ================== 主入口 ==================
if __name__ == "__main__":
# 创建一个简单的 Deck 用于测试
from pylabrobot.resources import Deck
deck = Deck()
platform = ElectrolysisWaterPlatform(deck)
platform.start()

View File

@@ -1,307 +0,0 @@
"""
LaiYu_Liquid 液体处理工作站集成模块
该模块提供了 LaiYu_Liquid 工作站与 UniLabOS 的完整集成,包括:
- 硬件后端和抽象接口
- 资源定义和管理
- 协议执行和液体传输
- 工作台配置和布局
主要组件:
- LaiYuLiquidBackend: 硬件后端实现
- LaiYuLiquid: 液体处理器抽象接口
- 各种资源类:枪头架、板、容器等
- 便捷创建函数和配置管理
使用示例:
from unilabos.devices.laiyu_liquid import (
LaiYuLiquid,
LaiYuLiquidBackend,
create_standard_deck,
create_tip_rack_1000ul
)
# 创建后端和液体处理器
backend = LaiYuLiquidBackend()
lh = LaiYuLiquid(backend=backend)
# 创建工作台
deck = create_standard_deck()
lh.deck = deck
# 设置和运行
await lh.setup()
"""
# 版本信息
__version__ = "1.0.0"
__author__ = "LaiYu_Liquid Integration Team"
__description__ = "LaiYu_Liquid 液体处理工作站 UniLabOS 集成模块"
# 驱动程序导入
from .drivers import (
XYZStepperController,
SOPAPipette,
MotorAxis,
MotorStatus,
SOPAConfig,
SOPAStatusCode,
StepperMotorDriver
)
# 控制器导入
from .controllers import (
XYZController,
PipetteController,
)
# 后端导入
from .backend.rviz_backend import (
LiquidHandlerRvizBackend,
)
# 资源类和创建函数导入
from .core.laiyu_liquid_res import (
LaiYuLiquidDeck,
LaiYuLiquidContainer,
LaiYuLiquidTipRack
)
# 主设备类和配置
from .core.laiyu_liquid_main import (
LaiYuLiquid,
LaiYuLiquidConfig,
LaiYuLiquidDeck,
LaiYuLiquidContainer,
LaiYuLiquidTipRack,
create_quick_setup
)
# 后端创建函数导入
from .backend import (
LaiYuLiquidBackend,
create_laiyu_backend,
)
# 导出所有公共接口
__all__ = [
# 版本信息
"__version__",
"__author__",
"__description__",
# 驱动程序
"SOPAPipette",
"SOPAConfig",
"StepperMotorDriver",
"XYZStepperController",
# 控制器
"PipetteController",
"XYZController",
# 后端
"LiquidHandlerRvizBackend",
# 资源创建函数
"create_tip_rack_1000ul",
"create_tip_rack_200ul",
"create_96_well_plate",
"create_deep_well_plate",
"create_8_tube_rack",
"create_standard_deck",
"create_waste_container",
"create_wash_container",
"create_reagent_container",
"load_deck_config",
# 后端创建函数
"create_laiyu_backend",
# 主要类
"LaiYuLiquid",
"LaiYuLiquidConfig",
"LaiYuLiquidBackend",
"LaiYuLiquidDeck",
# 工具函数
"get_version",
"get_supported_resources",
"create_quick_setup",
"validate_installation",
"print_module_info",
"setup_logging",
]
# 别名定义,为了向后兼容
LaiYuLiquidDevice = LaiYuLiquid # 主设备类别名
LaiYuLiquidController = XYZController # 控制器别名
LaiYuLiquidDriver = XYZStepperController # 驱动器别名
# 模块级别的便捷函数
def get_version() -> str:
"""
获取模块版本
Returns:
str: 版本号
"""
return __version__
def get_supported_resources() -> dict:
"""
获取支持的资源类型
Returns:
dict: 支持的资源类型字典
"""
return {
"tip_racks": {
"LaiYuLiquidTipRack": LaiYuLiquidTipRack,
},
"containers": {
"LaiYuLiquidContainer": LaiYuLiquidContainer,
},
"decks": {
"LaiYuLiquidDeck": LaiYuLiquidDeck,
},
"devices": {
"LaiYuLiquid": LaiYuLiquid,
}
}
def create_quick_setup() -> tuple:
"""
快速创建基本设置
Returns:
tuple: (backend, controllers, resources) 的元组
"""
# 创建后端
backend = LiquidHandlerRvizBackend()
# 创建控制器(使用默认端口进行演示)
pipette_controller = PipetteController(port="/dev/ttyUSB0", address=4)
xyz_controller = XYZController(port="/dev/ttyUSB1", auto_connect=False)
# 创建测试资源
tip_rack_1000 = create_tip_rack_1000ul("tip_rack_1000")
tip_rack_200 = create_tip_rack_200ul("tip_rack_200")
well_plate = create_96_well_plate("96_well_plate")
controllers = {
'pipette': pipette_controller,
'xyz': xyz_controller
}
resources = {
'tip_rack_1000': tip_rack_1000,
'tip_rack_200': tip_rack_200,
'well_plate': well_plate
}
return backend, controllers, resources
def validate_installation() -> bool:
"""
验证模块安装是否正确
Returns:
bool: 安装是否正确
"""
try:
# 检查核心类是否可以导入
from .core.laiyu_liquid_main import LaiYuLiquid, LaiYuLiquidConfig
from .backend import LaiYuLiquidBackend
from .controllers import XYZController, PipetteController
from .drivers import XYZStepperController, SOPAPipette
# 尝试创建基本对象
config = LaiYuLiquidConfig()
backend = create_laiyu_backend("validation_test")
print("模块安装验证成功")
return True
except Exception as e:
print(f"模块安装验证失败: {e}")
return False
def print_module_info():
"""打印模块信息"""
print(f"LaiYu_Liquid 集成模块")
print(f"版本: {__version__}")
print(f"作者: {__author__}")
print(f"描述: {__description__}")
print(f"")
print(f"支持的资源类型:")
resources = get_supported_resources()
for category, types in resources.items():
print(f" {category}:")
for type_name, type_class in types.items():
print(f" - {type_name}: {type_class.__name__}")
print(f"")
print(f"主要功能:")
print(f" - 硬件集成: LaiYuLiquidBackend")
print(f" - 抽象接口: LaiYuLiquid")
print(f" - 资源管理: 各种资源类和创建函数")
print(f" - 协议执行: transfer_liquid 和相关函数")
print(f" - 配置管理: deck.json 和加载函数")
# 模块初始化时的检查
def _check_dependencies():
"""检查依赖项"""
try:
import pylabrobot
import asyncio
import json
import logging
return True
except ImportError as e:
import logging
logging.warning(f"缺少依赖项 {e}")
return False
# 执行依赖检查
_dependencies_ok = _check_dependencies()
if not _dependencies_ok:
import logging
logging.warning("某些依赖项缺失,模块功能可能受限")
# 模块级别的日志配置
import logging
def setup_logging(level: str = "INFO"):
"""
设置模块日志
Args:
level: 日志级别 (DEBUG, INFO, WARNING, ERROR)
"""
logger = logging.getLogger("LaiYu_Liquid")
logger.setLevel(getattr(logging, level.upper()))
if not logger.handlers:
handler = logging.StreamHandler()
formatter = logging.Formatter(
'%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
handler.setFormatter(formatter)
logger.addHandler(handler)
return logger
# 默认日志设置
_logger = setup_logging()

View File

@@ -1,9 +0,0 @@
"""
LaiYu液体处理设备后端模块
提供设备后端接口和实现
"""
from .laiyu_backend import LaiYuLiquidBackend, create_laiyu_backend
__all__ = ['LaiYuLiquidBackend', 'create_laiyu_backend']

View File

@@ -1,334 +0,0 @@
"""
LaiYu液体处理设备后端实现
提供设备的后端接口和控制逻辑
"""
import logging
from typing import Dict, Any, Optional, List
from abc import ABC, abstractmethod
# 尝试导入PyLabRobot后端
try:
from pylabrobot.liquid_handling.backends import LiquidHandlerBackend
PYLABROBOT_AVAILABLE = True
except ImportError:
PYLABROBOT_AVAILABLE = False
# 创建模拟后端基类
class LiquidHandlerBackend:
def __init__(self, name: str):
self.name = name
self.is_connected = False
def connect(self):
"""连接设备"""
pass
def disconnect(self):
"""断开连接"""
pass
class LaiYuLiquidBackend(LiquidHandlerBackend):
"""LaiYu液体处理设备后端"""
def __init__(self, name: str = "LaiYu_Liquid_Backend"):
"""
初始化LaiYu液体处理设备后端
Args:
name: 后端名称
"""
if PYLABROBOT_AVAILABLE:
# PyLabRobot 的 LiquidHandlerBackend 不接受参数
super().__init__()
else:
# 模拟版本接受 name 参数
super().__init__(name)
self.name = name
self.logger = logging.getLogger(__name__)
self.is_connected = False
self.device_info = {
"name": "LaiYu液体处理设备",
"version": "1.0.0",
"manufacturer": "LaiYu",
"model": "LaiYu_Liquid_Handler"
}
def connect(self) -> bool:
"""
连接到LaiYu液体处理设备
Returns:
bool: 连接是否成功
"""
try:
self.logger.info("正在连接到LaiYu液体处理设备...")
# 这里应该实现实际的设备连接逻辑
# 目前返回模拟连接成功
self.is_connected = True
self.logger.info("成功连接到LaiYu液体处理设备")
return True
except Exception as e:
self.logger.error(f"连接LaiYu液体处理设备失败: {e}")
self.is_connected = False
return False
def disconnect(self) -> bool:
"""
断开与LaiYu液体处理设备的连接
Returns:
bool: 断开连接是否成功
"""
try:
self.logger.info("正在断开与LaiYu液体处理设备的连接...")
# 这里应该实现实际的设备断开连接逻辑
self.is_connected = False
self.logger.info("成功断开与LaiYu液体处理设备的连接")
return True
except Exception as e:
self.logger.error(f"断开LaiYu液体处理设备连接失败: {e}")
return False
def is_device_connected(self) -> bool:
"""
检查设备是否已连接
Returns:
bool: 设备是否已连接
"""
return self.is_connected
def get_device_info(self) -> Dict[str, Any]:
"""
获取设备信息
Returns:
Dict[str, Any]: 设备信息字典
"""
return self.device_info.copy()
def home_device(self) -> bool:
"""
设备归零操作
Returns:
bool: 归零是否成功
"""
if not self.is_connected:
self.logger.error("设备未连接,无法执行归零操作")
return False
try:
self.logger.info("正在执行设备归零操作...")
# 这里应该实现实际的设备归零逻辑
self.logger.info("设备归零操作完成")
return True
except Exception as e:
self.logger.error(f"设备归零操作失败: {e}")
return False
def aspirate(self, volume: float, location: Dict[str, Any]) -> bool:
"""
吸液操作
Args:
volume: 吸液体积 (微升)
location: 吸液位置信息
Returns:
bool: 吸液是否成功
"""
if not self.is_connected:
self.logger.error("设备未连接,无法执行吸液操作")
return False
try:
self.logger.info(f"正在执行吸液操作: 体积={volume}μL, 位置={location}")
# 这里应该实现实际的吸液逻辑
self.logger.info("吸液操作完成")
return True
except Exception as e:
self.logger.error(f"吸液操作失败: {e}")
return False
def dispense(self, volume: float, location: Dict[str, Any]) -> bool:
"""
排液操作
Args:
volume: 排液体积 (微升)
location: 排液位置信息
Returns:
bool: 排液是否成功
"""
if not self.is_connected:
self.logger.error("设备未连接,无法执行排液操作")
return False
try:
self.logger.info(f"正在执行排液操作: 体积={volume}μL, 位置={location}")
# 这里应该实现实际的排液逻辑
self.logger.info("排液操作完成")
return True
except Exception as e:
self.logger.error(f"排液操作失败: {e}")
return False
def pick_up_tip(self, location: Dict[str, Any]) -> bool:
"""
取枪头操作
Args:
location: 枪头位置信息
Returns:
bool: 取枪头是否成功
"""
if not self.is_connected:
self.logger.error("设备未连接,无法执行取枪头操作")
return False
try:
self.logger.info(f"正在执行取枪头操作: 位置={location}")
# 这里应该实现实际的取枪头逻辑
self.logger.info("取枪头操作完成")
return True
except Exception as e:
self.logger.error(f"取枪头操作失败: {e}")
return False
def drop_tip(self, location: Dict[str, Any]) -> bool:
"""
丢弃枪头操作
Args:
location: 丢弃位置信息
Returns:
bool: 丢弃枪头是否成功
"""
if not self.is_connected:
self.logger.error("设备未连接,无法执行丢弃枪头操作")
return False
try:
self.logger.info(f"正在执行丢弃枪头操作: 位置={location}")
# 这里应该实现实际的丢弃枪头逻辑
self.logger.info("丢弃枪头操作完成")
return True
except Exception as e:
self.logger.error(f"丢弃枪头操作失败: {e}")
return False
def move_to(self, location: Dict[str, Any]) -> bool:
"""
移动到指定位置
Args:
location: 目标位置信息
Returns:
bool: 移动是否成功
"""
if not self.is_connected:
self.logger.error("设备未连接,无法执行移动操作")
return False
try:
self.logger.info(f"正在移动到位置: {location}")
# 这里应该实现实际的移动逻辑
self.logger.info("移动操作完成")
return True
except Exception as e:
self.logger.error(f"移动操作失败: {e}")
return False
def get_status(self) -> Dict[str, Any]:
"""
获取设备状态
Returns:
Dict[str, Any]: 设备状态信息
"""
return {
"connected": self.is_connected,
"device_info": self.device_info,
"status": "ready" if self.is_connected else "disconnected"
}
# PyLabRobot 抽象方法实现
def stop(self):
"""停止所有操作"""
self.logger.info("停止所有操作")
pass
@property
def num_channels(self) -> int:
"""返回通道数量"""
return 1 # 单通道移液器
def can_pick_up_tip(self, tip_rack, tip_position) -> bool:
"""检查是否可以拾取吸头"""
return True # 简化实现总是返回True
def pick_up_tips(self, tip_rack, tip_positions):
"""拾取多个吸头"""
self.logger.info(f"拾取吸头: {tip_positions}")
pass
def drop_tips(self, tip_rack, tip_positions):
"""丢弃多个吸头"""
self.logger.info(f"丢弃吸头: {tip_positions}")
pass
def pick_up_tips96(self, tip_rack):
"""拾取96个吸头"""
self.logger.info("拾取96个吸头")
pass
def drop_tips96(self, tip_rack):
"""丢弃96个吸头"""
self.logger.info("丢弃96个吸头")
pass
def aspirate96(self, volume, plate, well_positions):
"""96通道吸液"""
self.logger.info(f"96通道吸液: 体积={volume}")
pass
def dispense96(self, volume, plate, well_positions):
"""96通道排液"""
self.logger.info(f"96通道排液: 体积={volume}")
pass
def pick_up_resource(self, resource, location):
"""拾取资源"""
self.logger.info(f"拾取资源: {resource}")
pass
def drop_resource(self, resource, location):
"""放置资源"""
self.logger.info(f"放置资源: {resource}")
pass
def move_picked_up_resource(self, resource, location):
"""移动已拾取的资源"""
self.logger.info(f"移动资源: {resource}{location}")
pass
def create_laiyu_backend(name: str = "LaiYu_Liquid_Backend") -> LaiYuLiquidBackend:
"""
创建LaiYu液体处理设备后端实例
Args:
name: 后端名称
Returns:
LaiYuLiquidBackend: 后端实例
"""
return LaiYuLiquidBackend(name)

View File

@@ -1,209 +0,0 @@
import json
from typing import List, Optional, Union
from pylabrobot.liquid_handling.backends.backend import (
LiquidHandlerBackend,
)
from pylabrobot.liquid_handling.standard import (
Drop,
DropTipRack,
MultiHeadAspirationContainer,
MultiHeadAspirationPlate,
MultiHeadDispenseContainer,
MultiHeadDispensePlate,
Pickup,
PickupTipRack,
ResourceDrop,
ResourceMove,
ResourcePickup,
SingleChannelAspiration,
SingleChannelDispense,
)
from pylabrobot.resources import Resource, Tip
import rclpy
from rclpy.node import Node
from sensor_msgs.msg import JointState
import time
from rclpy.action import ActionClient
from unilabos_msgs.action import SendCmd
import re
from unilabos.devices.ros_dev.liquid_handler_joint_publisher import JointStatePublisher
class LiquidHandlerRvizBackend(LiquidHandlerBackend):
"""Chatter box backend for device-free testing. Prints out all operations."""
_pip_length = 5
_vol_length = 8
_resource_length = 20
_offset_length = 16
_flow_rate_length = 10
_blowout_length = 10
_lld_z_length = 10
_kwargs_length = 15
_tip_type_length = 12
_max_volume_length = 16
_fitting_depth_length = 20
_tip_length_length = 16
# _pickup_method_length = 20
_filter_length = 10
def __init__(self, num_channels: int = 8):
"""Initialize a chatter box backend."""
super().__init__()
self._num_channels = num_channels
# rclpy.init()
if not rclpy.ok():
rclpy.init()
self.joint_state_publisher = None
async def setup(self):
self.joint_state_publisher = JointStatePublisher()
await super().setup()
async def stop(self):
pass
def serialize(self) -> dict:
return {**super().serialize(), "num_channels": self.num_channels}
@property
def num_channels(self) -> int:
return self._num_channels
async def assigned_resource_callback(self, resource: Resource):
pass
async def unassigned_resource_callback(self, name: str):
pass
async def pick_up_tips(self, ops: List[Pickup], use_channels: List[int], **backend_kwargs):
for op, channel in zip(ops, use_channels):
offset = f"{round(op.offset.x, 1)},{round(op.offset.y, 1)},{round(op.offset.z, 1)}"
row = (
f" p{channel}: "
f"{op.resource.name[-30:]:<{LiquidHandlerRvizBackend._resource_length}} "
f"{offset:<{LiquidHandlerRvizBackend._offset_length}} "
f"{op.tip.__class__.__name__:<{LiquidHandlerRvizBackend._tip_type_length}} "
f"{op.tip.maximal_volume:<{LiquidHandlerRvizBackend._max_volume_length}} "
f"{op.tip.fitting_depth:<{LiquidHandlerRvizBackend._fitting_depth_length}} "
f"{op.tip.total_tip_length:<{LiquidHandlerRvizBackend._tip_length_length}} "
# f"{str(op.tip.pickup_method)[-20:]:<{ChatterboxBackend._pickup_method_length}} "
f"{'Yes' if op.tip.has_filter else 'No':<{LiquidHandlerRvizBackend._filter_length}}"
)
coordinate = ops[0].resource.get_absolute_location(x="c",y="c")
x = coordinate.x
y = coordinate.y
z = coordinate.z + 70
self.joint_state_publisher.send_resource_action(ops[0].resource.name, x, y, z, "pick")
# goback()
async def drop_tips(self, ops: List[Drop], use_channels: List[int], **backend_kwargs):
coordinate = ops[0].resource.get_absolute_location(x="c",y="c")
x = coordinate.x
y = coordinate.y
z = coordinate.z + 70
self.joint_state_publisher.send_resource_action(ops[0].resource.name, x, y, z, "drop_trash")
# goback()
async def aspirate(
self,
ops: List[SingleChannelAspiration],
use_channels: List[int],
**backend_kwargs,
):
# 执行吸液操作
pass
for o, p in zip(ops, use_channels):
offset = f"{round(o.offset.x, 1)},{round(o.offset.y, 1)},{round(o.offset.z, 1)}"
row = (
f" p{p}: "
f"{o.volume:<{LiquidHandlerRvizBackend._vol_length}} "
f"{o.resource.name[-20:]:<{LiquidHandlerRvizBackend._resource_length}} "
f"{offset:<{LiquidHandlerRvizBackend._offset_length}} "
f"{str(o.flow_rate):<{LiquidHandlerRvizBackend._flow_rate_length}} "
f"{str(o.blow_out_air_volume):<{LiquidHandlerRvizBackend._blowout_length}} "
f"{str(o.liquid_height):<{LiquidHandlerRvizBackend._lld_z_length}} "
# f"{o.liquids if o.liquids is not None else 'none'}"
)
for key, value in backend_kwargs.items():
if isinstance(value, list) and all(isinstance(v, bool) for v in value):
value = "".join("T" if v else "F" for v in value)
if isinstance(value, list):
value = "".join(map(str, value))
row += f" {value:<15}"
coordinate = ops[0].resource.get_absolute_location(x="c",y="c")
x = coordinate.x
y = coordinate.y
z = coordinate.z + 70
self.joint_state_publisher.send_resource_action(ops[0].resource.name, x, y, z, "")
async def dispense(
self,
ops: List[SingleChannelDispense],
use_channels: List[int],
**backend_kwargs,
):
for o, p in zip(ops, use_channels):
offset = f"{round(o.offset.x, 1)},{round(o.offset.y, 1)},{round(o.offset.z, 1)}"
row = (
f" p{p}: "
f"{o.volume:<{LiquidHandlerRvizBackend._vol_length}} "
f"{o.resource.name[-20:]:<{LiquidHandlerRvizBackend._resource_length}} "
f"{offset:<{LiquidHandlerRvizBackend._offset_length}} "
f"{str(o.flow_rate):<{LiquidHandlerRvizBackend._flow_rate_length}} "
f"{str(o.blow_out_air_volume):<{LiquidHandlerRvizBackend._blowout_length}} "
f"{str(o.liquid_height):<{LiquidHandlerRvizBackend._lld_z_length}} "
# f"{o.liquids if o.liquids is not None else 'none'}"
)
for key, value in backend_kwargs.items():
if isinstance(value, list) and all(isinstance(v, bool) for v in value):
value = "".join("T" if v else "F" for v in value)
if isinstance(value, list):
value = "".join(map(str, value))
row += f" {value:<{LiquidHandlerRvizBackend._kwargs_length}}"
coordinate = ops[0].resource.get_absolute_location(x="c",y="c")
x = coordinate.x
y = coordinate.y
z = coordinate.z + 70
self.joint_state_publisher.send_resource_action(ops[0].resource.name, x, y, z, "")
async def pick_up_tips96(self, pickup: PickupTipRack, **backend_kwargs):
pass
async def drop_tips96(self, drop: DropTipRack, **backend_kwargs):
pass
async def aspirate96(
self, aspiration: Union[MultiHeadAspirationPlate, MultiHeadAspirationContainer]
):
pass
async def dispense96(self, dispense: Union[MultiHeadDispensePlate, MultiHeadDispenseContainer]):
pass
async def pick_up_resource(self, pickup: ResourcePickup):
# 执行资源拾取操作
pass
async def move_picked_up_resource(self, move: ResourceMove):
# 执行资源移动操作
pass
async def drop_resource(self, drop: ResourceDrop):
# 执行资源放置操作
pass
def can_pick_up_tip(self, channel_idx: int, tip: Tip) -> bool:
return True

File diff suppressed because it is too large Load Diff

View File

@@ -1,14 +0,0 @@
goto 171 178 57 H1
goto 171 117 57 A1
goto 172 178 130
goto 173 179 133
goto 173 180 133
goto 173 180 138
goto 173 180 125 +10mm在空的上面边缘
goto 173 180 130 取不到
goto 173 180 133 取不到
goto 173 180 135
goto 173 180 137 取到了!!!!
goto 173 180 131 弹出枪头 H1
goto 173 117 137 A1 +10mm可以取到新枪头了

View File

@@ -1,25 +0,0 @@
"""
LaiYu_Liquid 控制器模块
该模块包含了LaiYu_Liquid液体处理工作站的高级控制器
- 移液器控制器:提供液体处理的高级接口
- XYZ运动控制器提供三轴运动的高级接口
"""
# 移液器控制器导入
from .pipette_controller import PipetteController
# XYZ运动控制器导入
from .xyz_controller import XYZController
__all__ = [
# 移液器控制器
"PipetteController",
# XYZ运动控制器
"XYZController",
]
__version__ = "1.0.0"
__author__ = "LaiYu_Liquid Controller Team"
__description__ = "LaiYu_Liquid 高级控制器集合"

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,44 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
LaiYu液体处理设备核心模块
该模块包含LaiYu液体处理设备的核心功能组件
- LaiYu_Liquid.py: 主设备类和配置管理
- abstract_protocol.py: 抽象协议定义
- laiyu_liquid_res.py: 设备资源管理
作者: UniLab团队
版本: 2.0.0
"""
from .laiyu_liquid_main import (
LaiYuLiquid,
LaiYuLiquidConfig,
LaiYuLiquidBackend,
LaiYuLiquidDeck,
LaiYuLiquidContainer,
LaiYuLiquidTipRack,
create_quick_setup
)
from .laiyu_liquid_res import (
LaiYuLiquidDeck,
LaiYuLiquidContainer,
LaiYuLiquidTipRack
)
__all__ = [
# 主设备类
'LaiYuLiquid',
'LaiYuLiquidConfig',
'LaiYuLiquidBackend',
# 设备资源
'LaiYuLiquidDeck',
'LaiYuLiquidContainer',
'LaiYuLiquidTipRack',
# 工具函数
'create_quick_setup'
]

View File

@@ -1,529 +0,0 @@
"""
LaiYu_Liquid 抽象协议实现
该模块提供了液体资源管理和转移的抽象协议,包括:
- MaterialResource: 液体资源管理类
- transfer_liquid: 液体转移函数
- 相关的辅助类和函数
主要功能:
- 管理多孔位的液体资源
- 计算和跟踪液体体积
- 处理液体转移操作
- 提供资源状态查询
"""
import logging
from typing import Dict, List, Optional, Union, Any, Tuple
from dataclasses import dataclass, field
from enum import Enum
import uuid
import time
# pylabrobot 导入
from pylabrobot.resources import Resource, Well, Plate
logger = logging.getLogger(__name__)
class LiquidType(Enum):
"""液体类型枚举"""
WATER = "water"
ETHANOL = "ethanol"
DMSO = "dmso"
BUFFER = "buffer"
SAMPLE = "sample"
REAGENT = "reagent"
WASTE = "waste"
UNKNOWN = "unknown"
@dataclass
class LiquidInfo:
"""液体信息类"""
liquid_type: LiquidType = LiquidType.UNKNOWN
volume: float = 0.0 # 体积 (μL)
concentration: Optional[float] = None # 浓度 (mg/ml, M等)
ph: Optional[float] = None # pH值
temperature: Optional[float] = None # 温度 (°C)
viscosity: Optional[float] = None # 粘度 (cP)
density: Optional[float] = None # 密度 (g/ml)
description: str = "" # 描述信息
def __str__(self) -> str:
return f"{self.liquid_type.value}({self.description})"
@dataclass
class WellContent:
"""孔位内容类"""
volume: float = 0.0 # 当前体积 (ul)
max_volume: float = 1000.0 # 最大容量 (ul)
liquid_info: LiquidInfo = field(default_factory=LiquidInfo)
last_updated: float = field(default_factory=time.time)
@property
def is_empty(self) -> bool:
"""检查是否为空"""
return self.volume <= 0.0
@property
def is_full(self) -> bool:
"""检查是否已满"""
return self.volume >= self.max_volume
@property
def available_volume(self) -> float:
"""可用体积"""
return max(0.0, self.max_volume - self.volume)
@property
def fill_percentage(self) -> float:
"""填充百分比"""
return (self.volume / self.max_volume) * 100.0 if self.max_volume > 0 else 0.0
def can_add_volume(self, volume: float) -> bool:
"""检查是否可以添加指定体积"""
return (self.volume + volume) <= self.max_volume
def can_remove_volume(self, volume: float) -> bool:
"""检查是否可以移除指定体积"""
return self.volume >= volume
def add_volume(self, volume: float, liquid_info: Optional[LiquidInfo] = None) -> bool:
"""
添加液体体积
Args:
volume: 要添加的体积 (ul)
liquid_info: 液体信息
Returns:
bool: 是否成功添加
"""
if not self.can_add_volume(volume):
return False
self.volume += volume
if liquid_info:
self.liquid_info = liquid_info
self.last_updated = time.time()
return True
def remove_volume(self, volume: float) -> bool:
"""
移除液体体积
Args:
volume: 要移除的体积 (ul)
Returns:
bool: 是否成功移除
"""
if not self.can_remove_volume(volume):
return False
self.volume -= volume
self.last_updated = time.time()
# 如果完全清空,重置液体信息
if self.volume <= 0.0:
self.volume = 0.0
self.liquid_info = LiquidInfo()
return True
class MaterialResource:
"""
液体资源管理类
该类用于管理液体处理过程中的资源状态,包括:
- 跟踪多个孔位的液体体积和类型
- 计算总体积和可用体积
- 处理液体的添加和移除
- 提供资源状态查询
"""
def __init__(
self,
resource: Resource,
wells: Optional[List[Well]] = None,
default_max_volume: float = 1000.0
):
"""
初始化材料资源
Args:
resource: pylabrobot 资源对象
wells: 孔位列表如果为None则自动获取
default_max_volume: 默认最大体积 (ul)
"""
self.resource = resource
self.resource_id = str(uuid.uuid4())
self.default_max_volume = default_max_volume
# 获取孔位列表
if wells is None:
if hasattr(resource, 'get_wells'):
self.wells = resource.get_wells()
elif hasattr(resource, 'wells'):
self.wells = resource.wells
else:
# 如果没有孔位,创建一个虚拟孔位
self.wells = [resource]
else:
self.wells = wells
# 初始化孔位内容
self.well_contents: Dict[str, WellContent] = {}
for well in self.wells:
well_id = self._get_well_id(well)
self.well_contents[well_id] = WellContent(
max_volume=default_max_volume
)
logger.info(f"初始化材料资源: {resource.name}, 孔位数: {len(self.wells)}")
def _get_well_id(self, well: Union[Well, Resource]) -> str:
"""获取孔位ID"""
if hasattr(well, 'name'):
return well.name
else:
return str(id(well))
@property
def name(self) -> str:
"""资源名称"""
return self.resource.name
@property
def total_volume(self) -> float:
"""总液体体积"""
return sum(content.volume for content in self.well_contents.values())
@property
def total_max_volume(self) -> float:
"""总最大容量"""
return sum(content.max_volume for content in self.well_contents.values())
@property
def available_volume(self) -> float:
"""总可用体积"""
return sum(content.available_volume for content in self.well_contents.values())
@property
def well_count(self) -> int:
"""孔位数量"""
return len(self.wells)
@property
def empty_wells(self) -> List[str]:
"""空孔位列表"""
return [well_id for well_id, content in self.well_contents.items()
if content.is_empty]
@property
def full_wells(self) -> List[str]:
"""满孔位列表"""
return [well_id for well_id, content in self.well_contents.items()
if content.is_full]
@property
def occupied_wells(self) -> List[str]:
"""有液体的孔位列表"""
return [well_id for well_id, content in self.well_contents.items()
if not content.is_empty]
def get_well_content(self, well_id: str) -> Optional[WellContent]:
"""获取指定孔位的内容"""
return self.well_contents.get(well_id)
def get_well_volume(self, well_id: str) -> float:
"""获取指定孔位的体积"""
content = self.get_well_content(well_id)
return content.volume if content else 0.0
def set_well_volume(
self,
well_id: str,
volume: float,
liquid_info: Optional[LiquidInfo] = None
) -> bool:
"""
设置指定孔位的体积
Args:
well_id: 孔位ID
volume: 体积 (ul)
liquid_info: 液体信息
Returns:
bool: 是否成功设置
"""
if well_id not in self.well_contents:
logger.error(f"孔位 {well_id} 不存在")
return False
content = self.well_contents[well_id]
if volume > content.max_volume:
logger.error(f"体积 {volume} 超过最大容量 {content.max_volume}")
return False
content.volume = max(0.0, volume)
if liquid_info:
content.liquid_info = liquid_info
content.last_updated = time.time()
logger.info(f"设置孔位 {well_id} 体积: {volume}ul")
return True
def add_liquid(
self,
well_id: str,
volume: float,
liquid_info: Optional[LiquidInfo] = None
) -> bool:
"""
向指定孔位添加液体
Args:
well_id: 孔位ID
volume: 添加的体积 (ul)
liquid_info: 液体信息
Returns:
bool: 是否成功添加
"""
if well_id not in self.well_contents:
logger.error(f"孔位 {well_id} 不存在")
return False
content = self.well_contents[well_id]
success = content.add_volume(volume, liquid_info)
if success:
logger.info(f"向孔位 {well_id} 添加 {volume}ul 液体")
else:
logger.error(f"无法向孔位 {well_id} 添加 {volume}ul 液体")
return success
def remove_liquid(self, well_id: str, volume: float) -> bool:
"""
从指定孔位移除液体
Args:
well_id: 孔位ID
volume: 移除的体积 (ul)
Returns:
bool: 是否成功移除
"""
if well_id not in self.well_contents:
logger.error(f"孔位 {well_id} 不存在")
return False
content = self.well_contents[well_id]
success = content.remove_volume(volume)
if success:
logger.info(f"从孔位 {well_id} 移除 {volume}ul 液体")
else:
logger.error(f"无法从孔位 {well_id} 移除 {volume}ul 液体")
return success
def find_wells_with_volume(self, min_volume: float) -> List[str]:
"""
查找具有指定最小体积的孔位
Args:
min_volume: 最小体积 (ul)
Returns:
List[str]: 符合条件的孔位ID列表
"""
return [well_id for well_id, content in self.well_contents.items()
if content.volume >= min_volume]
def find_wells_with_space(self, min_space: float) -> List[str]:
"""
查找具有指定最小空间的孔位
Args:
min_space: 最小空间 (ul)
Returns:
List[str]: 符合条件的孔位ID列表
"""
return [well_id for well_id, content in self.well_contents.items()
if content.available_volume >= min_space]
def get_status_summary(self) -> Dict[str, Any]:
"""获取资源状态摘要"""
return {
"resource_name": self.name,
"resource_id": self.resource_id,
"well_count": self.well_count,
"total_volume": self.total_volume,
"total_max_volume": self.total_max_volume,
"available_volume": self.available_volume,
"fill_percentage": (self.total_volume / self.total_max_volume) * 100.0,
"empty_wells": len(self.empty_wells),
"full_wells": len(self.full_wells),
"occupied_wells": len(self.occupied_wells)
}
def get_detailed_status(self) -> Dict[str, Any]:
"""获取详细状态信息"""
well_details = {}
for well_id, content in self.well_contents.items():
well_details[well_id] = {
"volume": content.volume,
"max_volume": content.max_volume,
"available_volume": content.available_volume,
"fill_percentage": content.fill_percentage,
"liquid_type": content.liquid_info.liquid_type.value,
"description": content.liquid_info.description,
"last_updated": content.last_updated
}
return {
"summary": self.get_status_summary(),
"wells": well_details
}
def transfer_liquid(
source: MaterialResource,
target: MaterialResource,
volume: float,
source_well_id: Optional[str] = None,
target_well_id: Optional[str] = None,
liquid_info: Optional[LiquidInfo] = None
) -> bool:
"""
在两个材料资源之间转移液体
Args:
source: 源资源
target: 目标资源
volume: 转移体积 (ul)
source_well_id: 源孔位ID如果为None则自动选择
target_well_id: 目标孔位ID如果为None则自动选择
liquid_info: 液体信息
Returns:
bool: 转移是否成功
"""
try:
# 自动选择源孔位
if source_well_id is None:
available_wells = source.find_wells_with_volume(volume)
if not available_wells:
logger.error(f"源资源 {source.name} 没有足够体积的孔位")
return False
source_well_id = available_wells[0]
# 自动选择目标孔位
if target_well_id is None:
available_wells = target.find_wells_with_space(volume)
if not available_wells:
logger.error(f"目标资源 {target.name} 没有足够空间的孔位")
return False
target_well_id = available_wells[0]
# 检查源孔位是否有足够液体
if not source.get_well_content(source_well_id).can_remove_volume(volume):
logger.error(f"源孔位 {source_well_id} 液体不足")
return False
# 检查目标孔位是否有足够空间
if not target.get_well_content(target_well_id).can_add_volume(volume):
logger.error(f"目标孔位 {target_well_id} 空间不足")
return False
# 获取源液体信息
source_content = source.get_well_content(source_well_id)
transfer_liquid_info = liquid_info or source_content.liquid_info
# 执行转移
if source.remove_liquid(source_well_id, volume):
if target.add_liquid(target_well_id, volume, transfer_liquid_info):
logger.info(f"成功转移 {volume}ul 液体: {source.name}[{source_well_id}] -> {target.name}[{target_well_id}]")
return True
else:
# 如果目标添加失败,回滚源操作
source.add_liquid(source_well_id, volume, source_content.liquid_info)
logger.error("目标添加失败,已回滚源操作")
return False
else:
logger.error("源移除失败")
return False
except Exception as e:
logger.error(f"液体转移失败: {e}")
return False
def create_material_resource(
name: str,
resource: Resource,
initial_volumes: Optional[Dict[str, float]] = None,
liquid_info: Optional[LiquidInfo] = None,
max_volume: float = 1000.0
) -> MaterialResource:
"""
创建材料资源的便捷函数
Args:
name: 资源名称
resource: pylabrobot 资源对象
initial_volumes: 初始体积字典 {well_id: volume}
liquid_info: 液体信息
max_volume: 最大体积
Returns:
MaterialResource: 创建的材料资源
"""
material_resource = MaterialResource(
resource=resource,
default_max_volume=max_volume
)
# 设置初始体积
if initial_volumes:
for well_id, volume in initial_volumes.items():
material_resource.set_well_volume(well_id, volume, liquid_info)
return material_resource
def batch_transfer_liquid(
transfers: List[Tuple[MaterialResource, MaterialResource, float]],
liquid_info: Optional[LiquidInfo] = None
) -> List[bool]:
"""
批量液体转移
Args:
transfers: 转移列表 [(source, target, volume), ...]
liquid_info: 液体信息
Returns:
List[bool]: 每个转移操作的结果
"""
results = []
for source, target, volume in transfers:
result = transfer_liquid(source, target, volume, liquid_info=liquid_info)
results.append(result)
if not result:
logger.warning(f"批量转移中的操作失败: {source.name} -> {target.name}")
success_count = sum(results)
logger.info(f"批量转移完成: {success_count}/{len(transfers)} 成功")
return results

View File

@@ -1,888 +0,0 @@
"""
LaiYu_Liquid 液体处理工作站主要集成文件
该模块实现了 LaiYu_Liquid 与 UniLabOS 系统的集成,提供标准化的液体处理接口。
主要包含:
- LaiYuLiquidBackend: 硬件通信后端
- LaiYuLiquid: 主要接口类
- 相关的异常类和容器类
"""
import asyncio
import logging
import time
from typing import List, Optional, Dict, Any, Union, Tuple
from dataclasses import dataclass
from abc import ABC, abstractmethod
from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode
# 基础导入
try:
from pylabrobot.resources import Deck, Plate, TipRack, Tip, Resource, Well
PYLABROBOT_AVAILABLE = True
except ImportError:
# 如果 pylabrobot 不可用,创建基础的模拟类
PYLABROBOT_AVAILABLE = False
class Resource:
def __init__(self, name: str):
self.name = name
class Deck(Resource):
pass
class Plate(Resource):
pass
class TipRack(Resource):
pass
class Tip(Resource):
pass
class Well(Resource):
pass
# LaiYu_Liquid 控制器导入
try:
from .controllers.pipette_controller import PipetteController, TipStatus, LiquidClass, LiquidParameters
from .controllers.xyz_controller import XYZController, MachineConfig, CoordinateOrigin, MotorAxis
CONTROLLERS_AVAILABLE = True
except ImportError:
CONTROLLERS_AVAILABLE = False
# 创建模拟的控制器类
class PipetteController:
def __init__(self, *args, **kwargs):
pass
def connect(self):
return True
def initialize(self):
return True
class XYZController:
def __init__(self, *args, **kwargs):
pass
def connect_device(self):
return True
logger = logging.getLogger(__name__)
class LaiYuLiquidError(RuntimeError):
"""LaiYu_Liquid 设备异常"""
pass
@dataclass
class LaiYuLiquidConfig:
"""LaiYu_Liquid 设备配置"""
port: str = "/dev/cu.usbserial-3130" # RS485转USB端口
address: int = 1 # 设备地址
baudrate: int = 9600 # 波特率
timeout: float = 5.0 # 通信超时时间
# 工作台尺寸
deck_width: float = 340.0 # 工作台宽度 (mm)
deck_height: float = 250.0 # 工作台高度 (mm)
deck_depth: float = 160.0 # 工作台深度 (mm)
# 移液参数
max_volume: float = 1000.0 # 最大体积 (μL)
min_volume: float = 0.1 # 最小体积 (μL)
# 运动参数
max_speed: float = 100.0 # 最大速度 (mm/s)
acceleration: float = 50.0 # 加速度 (mm/s²)
# 安全参数
safe_height: float = 50.0 # 安全高度 (mm)
tip_pickup_depth: float = 10.0 # 吸头拾取深度 (mm)
liquid_detection: bool = True # 液面检测
# 取枪头相关参数
tip_pickup_speed: int = 30 # 取枪头时的移动速度 (rpm)
tip_pickup_acceleration: int = 500 # 取枪头时的加速度 (rpm/s)
tip_approach_height: float = 5.0 # 接近枪头时的高度 (mm)
tip_pickup_force_depth: float = 2.0 # 强制插入深度 (mm)
tip_pickup_retract_height: float = 20.0 # 取枪头后的回退高度 (mm)
# 丢弃枪头相关参数
tip_drop_height: float = 10.0 # 丢弃枪头时的高度 (mm)
tip_drop_speed: int = 50 # 丢弃枪头时的移动速度 (rpm)
trash_position: Tuple[float, float, float] = (300.0, 200.0, 0.0) # 垃圾桶位置 (mm)
# 安全范围配置
deck_width: float = 300.0 # 工作台宽度 (mm)
deck_height: float = 200.0 # 工作台高度 (mm)
deck_depth: float = 100.0 # 工作台深度 (mm)
safe_height: float = 50.0 # 安全高度 (mm)
position_validation: bool = True # 启用位置验证
emergency_stop_enabled: bool = True # 启用紧急停止
class LaiYuLiquidDeck:
"""LaiYu_Liquid 工作台管理"""
def __init__(self, config: LaiYuLiquidConfig):
self.config = config
self.resources: Dict[str, Resource] = {}
self.positions: Dict[str, Tuple[float, float, float]] = {}
def add_resource(self, name: str, resource: Resource, position: Tuple[float, float, float]):
"""添加资源到工作台"""
self.resources[name] = resource
self.positions[name] = position
def get_resource(self, name: str) -> Optional[Resource]:
"""获取资源"""
return self.resources.get(name)
def get_position(self, name: str) -> Optional[Tuple[float, float, float]]:
"""获取资源位置"""
return self.positions.get(name)
def list_resources(self) -> List[str]:
"""列出所有资源"""
return list(self.resources.keys())
class LaiYuLiquidContainer:
"""LaiYu_Liquid 容器类"""
def __init__(
self,
name: str,
size_x: float = 0,
size_y: float = 0,
size_z: float = 0,
container_type: str = "",
volume: float = 0.0,
max_volume: float = 1000.0,
lid_height: float = 0.0,
):
self.name = name
self.size_x = size_x
self.size_y = size_y
self.size_z = size_z
self.lid_height = lid_height
self.container_type = container_type
self.volume = volume
self.max_volume = max_volume
self.last_updated = time.time()
self.child_resources = {} # 存储子资源
@property
def is_empty(self) -> bool:
return self.volume <= 0.0
@property
def is_full(self) -> bool:
return self.volume >= self.max_volume
@property
def available_volume(self) -> float:
return max(0.0, self.max_volume - self.volume)
def add_volume(self, volume: float) -> bool:
"""添加体积"""
if self.volume + volume <= self.max_volume:
self.volume += volume
self.last_updated = time.time()
return True
return False
def remove_volume(self, volume: float) -> bool:
"""移除体积"""
if self.volume >= volume:
self.volume -= volume
self.last_updated = time.time()
return True
return False
def assign_child_resource(self, resource, location=None):
"""分配子资源 - 与 PyLabRobot 资源管理系统兼容"""
if hasattr(resource, "name"):
self.child_resources[resource.name] = {"resource": resource, "location": location}
class LaiYuLiquidTipRack:
"""LaiYu_Liquid 吸头架类"""
def __init__(
self,
name: str,
size_x: float = 0,
size_y: float = 0,
size_z: float = 0,
tip_count: int = 96,
tip_volume: float = 1000.0,
):
self.name = name
self.size_x = size_x
self.size_y = size_y
self.size_z = size_z
self.tip_count = tip_count
self.tip_volume = tip_volume
self.tips_available = [True] * tip_count
self.child_resources = {} # 存储子资源
@property
def available_tips(self) -> int:
return sum(self.tips_available)
@property
def is_empty(self) -> bool:
return self.available_tips == 0
def pick_tip(self, position: int) -> bool:
"""拾取吸头"""
if 0 <= position < self.tip_count and self.tips_available[position]:
self.tips_available[position] = False
return True
return False
def has_tip(self, position: int) -> bool:
"""检查位置是否有吸头"""
if 0 <= position < self.tip_count:
return self.tips_available[position]
return False
def assign_child_resource(self, resource, location=None):
"""分配子资源到指定位置"""
self.child_resources[resource.name] = {"resource": resource, "location": location}
def get_module_info():
"""获取模块信息"""
return {
"name": "LaiYu_Liquid",
"version": "1.0.0",
"description": "LaiYu液体处理工作站模块提供移液器控制、XYZ轴控制和资源管理功能",
"author": "UniLabOS Team",
"capabilities": ["移液器控制", "XYZ轴运动控制", "吸头架管理", "板和容器管理", "资源位置管理"],
"dependencies": {"required": ["serial"], "optional": ["pylabrobot"]},
}
class LaiYuLiquidBackend:
"""LaiYu_Liquid 硬件通信后端"""
_ros_node: BaseROS2DeviceNode
def __init__(self, config: LaiYuLiquidConfig, deck: Optional["LaiYuLiquidDeck"] = None):
self.config = config
self.deck = deck # 工作台引用,用于获取资源位置信息
self.pipette_controller = None
self.xyz_controller = None
self.is_connected = False
self.is_initialized = False
# 状态跟踪
self.current_position = (0.0, 0.0, 0.0)
self.tip_attached = False
self.current_volume = 0.0
def post_init(self, ros_node: BaseROS2DeviceNode):
self._ros_node = ros_node
def _validate_position(self, x: float, y: float, z: float) -> bool:
"""验证位置是否在安全范围内"""
try:
# 检查X轴范围
if not (0 <= x <= self.config.deck_width):
logger.error(f"X轴位置 {x:.2f}mm 超出范围 [0, {self.config.deck_width}]")
return False
# 检查Y轴范围
if not (0 <= y <= self.config.deck_height):
logger.error(f"Y轴位置 {y:.2f}mm 超出范围 [0, {self.config.deck_height}]")
return False
# 检查Z轴范围负值表示向下0为工作台表面
if not (-self.config.deck_depth <= z <= self.config.safe_height):
logger.error(f"Z轴位置 {z:.2f}mm 超出安全范围 [{-self.config.deck_depth}, {self.config.safe_height}]")
return False
return True
except Exception as e:
logger.error(f"位置验证失败: {e}")
return False
def _check_hardware_ready(self) -> bool:
"""检查硬件是否准备就绪"""
if not self.is_connected:
logger.error("设备未连接")
return False
if CONTROLLERS_AVAILABLE:
if self.xyz_controller is None:
logger.error("XYZ控制器未初始化")
return False
return True
async def emergency_stop(self) -> bool:
"""紧急停止所有运动"""
try:
logger.warning("执行紧急停止")
if CONTROLLERS_AVAILABLE and self.xyz_controller:
# 停止XYZ控制器
await self.xyz_controller.stop_all_motion()
logger.info("XYZ控制器已停止")
if self.pipette_controller:
# 停止移液器控制器
await self.pipette_controller.stop()
logger.info("移液器控制器已停止")
return True
except Exception as e:
logger.error(f"紧急停止失败: {e}")
return False
async def move_to_safe_position(self) -> bool:
"""移动到安全位置"""
try:
if not self._check_hardware_ready():
return False
safe_position = (
self.config.deck_width / 2, # 工作台中心X
self.config.deck_height / 2, # 工作台中心Y
self.config.safe_height, # 安全高度Z
)
if not self._validate_position(*safe_position):
logger.error("安全位置无效")
return False
if CONTROLLERS_AVAILABLE and self.xyz_controller:
await self.xyz_controller.move_to_work_coord(*safe_position)
self.current_position = safe_position
logger.info(f"已移动到安全位置: {safe_position}")
return True
else:
# 模拟模式
self.current_position = safe_position
logger.info("模拟移动到安全位置")
return True
except Exception as e:
logger.error(f"移动到安全位置失败: {e}")
return False
async def setup(self) -> bool:
"""设置硬件连接"""
try:
if CONTROLLERS_AVAILABLE:
# 初始化移液器控制器
self.pipette_controller = PipetteController(port=self.config.port, address=self.config.address)
# 初始化XYZ控制器
machine_config = MachineConfig()
self.xyz_controller = XYZController(
port=self.config.port, baudrate=self.config.baudrate, machine_config=machine_config
)
# 连接设备
pipette_connected = await asyncio.to_thread(self.pipette_controller.connect)
xyz_connected = await asyncio.to_thread(self.xyz_controller.connect_device)
if pipette_connected and xyz_connected:
self.is_connected = True
logger.info("LaiYu_Liquid 硬件连接成功")
return True
else:
logger.error("LaiYu_Liquid 硬件连接失败")
return False
else:
# 模拟模式
logger.info("LaiYu_Liquid 运行在模拟模式")
self.is_connected = True
return True
except Exception as e:
logger.error(f"LaiYu_Liquid 设置失败: {e}")
return False
async def stop(self):
"""停止设备"""
try:
if self.pipette_controller and hasattr(self.pipette_controller, "disconnect"):
await asyncio.to_thread(self.pipette_controller.disconnect)
if self.xyz_controller and hasattr(self.xyz_controller, "disconnect"):
await asyncio.to_thread(self.xyz_controller.disconnect)
self.is_connected = False
self.is_initialized = False
logger.info("LaiYu_Liquid 已停止")
except Exception as e:
logger.error(f"LaiYu_Liquid 停止失败: {e}")
async def move_to(self, x: float, y: float, z: float) -> bool:
"""移动到指定位置"""
try:
if not self.is_connected:
raise LaiYuLiquidError("设备未连接")
# 模拟移动
await self._ros_node.sleep(0.1) # 模拟移动时间
self.current_position = (x, y, z)
logger.debug(f"移动到位置: ({x}, {y}, {z})")
return True
except Exception as e:
logger.error(f"移动失败: {e}")
return False
async def pick_up_tip(self, tip_rack: str, position: int) -> bool:
"""拾取吸头 - 包含真正的Z轴下降控制"""
try:
# 硬件准备检查
if not self._check_hardware_ready():
return False
if self.tip_attached:
logger.warning("已有吸头附着,无法拾取新吸头")
return False
logger.info(f"开始从 {tip_rack} 位置 {position} 拾取吸头")
# 获取枪头架位置信息
if self.deck is None:
logger.error("工作台未初始化")
return False
tip_position = self.deck.get_position(tip_rack)
if tip_position is None:
logger.error(f"未找到枪头架 {tip_rack} 的位置信息")
return False
# 计算具体枪头位置这里简化处理实际应根据position计算偏移
tip_x, tip_y, tip_z = tip_position
# 验证所有关键位置的安全性
safe_z = tip_z + self.config.tip_approach_height
pickup_z = tip_z - self.config.tip_pickup_force_depth
retract_z = tip_z + self.config.tip_pickup_retract_height
if not (
self._validate_position(tip_x, tip_y, safe_z)
and self._validate_position(tip_x, tip_y, pickup_z)
and self._validate_position(tip_x, tip_y, retract_z)
):
logger.error("枪头拾取位置超出安全范围")
return False
if CONTROLLERS_AVAILABLE and self.xyz_controller:
# 真实硬件控制流程
logger.info("使用真实XYZ控制器进行枪头拾取")
try:
# 1. 移动到枪头上方的安全位置
safe_z = tip_z + self.config.tip_approach_height
logger.info(f"移动到枪头上方安全位置: ({tip_x:.2f}, {tip_y:.2f}, {safe_z:.2f})")
move_success = await asyncio.to_thread(
self.xyz_controller.move_to_work_coord, tip_x, tip_y, safe_z
)
if not move_success:
logger.error("移动到枪头上方失败")
return False
# 2. Z轴下降到枪头位置
pickup_z = tip_z - self.config.tip_pickup_force_depth
logger.info(f"Z轴下降到枪头拾取位置: {pickup_z:.2f}mm")
z_down_success = await asyncio.to_thread(
self.xyz_controller.move_to_work_coord, tip_x, tip_y, pickup_z
)
if not z_down_success:
logger.error("Z轴下降到枪头位置失败")
return False
# 3. 等待一小段时间确保枪头牢固附着
await self._ros_node.sleep(0.2)
# 4. Z轴上升到回退高度
retract_z = tip_z + self.config.tip_pickup_retract_height
logger.info(f"Z轴上升到回退高度: {retract_z:.2f}mm")
z_up_success = await asyncio.to_thread(
self.xyz_controller.move_to_work_coord, tip_x, tip_y, retract_z
)
if not z_up_success:
logger.error("Z轴上升失败")
return False
# 5. 更新当前位置
self.current_position = (tip_x, tip_y, retract_z)
except Exception as move_error:
logger.error(f"枪头拾取过程中发生错误: {move_error}")
# 尝试移动到安全位置
if self.config.emergency_stop_enabled:
await self.emergency_stop()
await self.move_to_safe_position()
return False
else:
# 模拟模式
logger.info("模拟模式:执行枪头拾取动作")
await self._ros_node.sleep(1.0) # 模拟整个拾取过程的时间
self.current_position = (tip_x, tip_y, tip_z + self.config.tip_pickup_retract_height)
# 6. 标记枪头已附着
self.tip_attached = True
logger.info("吸头拾取成功")
return True
except Exception as e:
logger.error(f"拾取吸头失败: {e}")
return False
async def drop_tip(self, location: str = "trash") -> bool:
"""丢弃吸头 - 包含真正的Z轴控制"""
try:
# 硬件准备检查
if not self._check_hardware_ready():
return False
if not self.tip_attached:
logger.warning("没有吸头附着,无需丢弃")
return True
logger.info(f"开始丢弃吸头到 {location}")
# 确定丢弃位置
if location == "trash":
# 使用配置中的垃圾桶位置
drop_x, drop_y, drop_z = self.config.trash_position
else:
# 尝试从deck获取指定位置
if self.deck is None:
logger.error("工作台未初始化")
return False
drop_position = self.deck.get_position(location)
if drop_position is None:
logger.error(f"未找到丢弃位置 {location} 的信息")
return False
drop_x, drop_y, drop_z = drop_position
# 验证丢弃位置的安全性
safe_z = drop_z + self.config.safe_height
drop_height_z = drop_z + self.config.tip_drop_height
if not (
self._validate_position(drop_x, drop_y, safe_z)
and self._validate_position(drop_x, drop_y, drop_height_z)
):
logger.error("枪头丢弃位置超出安全范围")
return False
if CONTROLLERS_AVAILABLE and self.xyz_controller:
# 真实硬件控制流程
logger.info("使用真实XYZ控制器进行枪头丢弃")
try:
# 1. 移动到丢弃位置上方的安全高度
safe_z = drop_z + self.config.tip_drop_height
logger.info(f"移动到丢弃位置上方: ({drop_x:.2f}, {drop_y:.2f}, {safe_z:.2f})")
move_success = await asyncio.to_thread(
self.xyz_controller.move_to_work_coord, drop_x, drop_y, safe_z
)
if not move_success:
logger.error("移动到丢弃位置上方失败")
return False
# 2. Z轴下降到丢弃高度
logger.info(f"Z轴下降到丢弃高度: {drop_z:.2f}mm")
z_down_success = await asyncio.to_thread(
self.xyz_controller.move_to_work_coord, drop_x, drop_y, drop_z
)
if not z_down_success:
logger.error("Z轴下降到丢弃位置失败")
return False
# 3. 执行枪头弹出动作(如果有移液器控制器)
if self.pipette_controller:
try:
# 发送弹出枪头命令
await asyncio.to_thread(self.pipette_controller.eject_tip)
logger.info("执行枪头弹出命令")
except Exception as e:
logger.warning(f"枪头弹出命令失败: {e}")
# 4. 等待一小段时间确保枪头完全脱离
await self._ros_node.sleep(0.3)
# 5. Z轴上升到安全高度
logger.info(f"Z轴上升到安全高度: {safe_z:.2f}mm")
z_up_success = await asyncio.to_thread(
self.xyz_controller.move_to_work_coord, drop_x, drop_y, safe_z
)
if not z_up_success:
logger.error("Z轴上升失败")
return False
# 6. 更新当前位置
self.current_position = (drop_x, drop_y, safe_z)
except Exception as drop_error:
logger.error(f"枪头丢弃过程中发生错误: {drop_error}")
# 尝试移动到安全位置
if self.config.emergency_stop_enabled:
await self.emergency_stop()
await self.move_to_safe_position()
return False
else:
# 模拟模式
logger.info("模拟模式:执行枪头丢弃动作")
await self._ros_node.sleep(0.8) # 模拟整个丢弃过程的时间
self.current_position = (drop_x, drop_y, drop_z + self.config.tip_drop_height)
# 7. 标记枪头已脱离,清空体积
self.tip_attached = False
self.current_volume = 0.0
logger.info("吸头丢弃成功")
return True
except Exception as e:
logger.error(f"丢弃吸头失败: {e}")
return False
async def aspirate(self, volume: float, location: str) -> bool:
"""吸取液体"""
try:
if not self.is_connected:
raise LaiYuLiquidError("设备未连接")
if not self.tip_attached:
raise LaiYuLiquidError("没有吸头附着")
if volume <= 0 or volume > self.config.max_volume:
raise LaiYuLiquidError(f"体积超出范围: {volume}")
# 模拟吸取
await self._ros_node.sleep(0.3)
self.current_volume += volume
logger.debug(f"{location} 吸取 {volume} μL")
return True
except Exception as e:
logger.error(f"吸取失败: {e}")
return False
async def dispense(self, volume: float, location: str) -> bool:
"""分配液体"""
try:
if not self.is_connected:
raise LaiYuLiquidError("设备未连接")
if not self.tip_attached:
raise LaiYuLiquidError("没有吸头附着")
if volume <= 0 or volume > self.current_volume:
raise LaiYuLiquidError(f"分配体积无效: {volume}")
# 模拟分配
await self._ros_node.sleep(0.3)
self.current_volume -= volume
logger.debug(f"{location} 分配 {volume} μL")
return True
except Exception as e:
logger.error(f"分配失败: {e}")
return False
class LaiYuLiquid:
"""LaiYu_Liquid 主要接口类"""
def __init__(self, config: Optional[LaiYuLiquidConfig] = None, **kwargs):
# 如果传入了关键字参数,创建配置对象
if kwargs and config is None:
# 从kwargs中提取配置参数
config_params = {}
for key, value in kwargs.items():
if hasattr(LaiYuLiquidConfig, key):
config_params[key] = value
self.config = LaiYuLiquidConfig(**config_params)
else:
self.config = config or LaiYuLiquidConfig()
# 先创建deck然后传递给backend
self.deck = LaiYuLiquidDeck(self.config)
self.backend = LaiYuLiquidBackend(self.config, self.deck)
self.is_setup = False
@property
def current_position(self) -> Tuple[float, float, float]:
"""获取当前位置"""
return self.backend.current_position
@property
def current_volume(self) -> float:
"""获取当前体积"""
return self.backend.current_volume
@property
def is_connected(self) -> bool:
"""获取连接状态"""
return self.backend.is_connected
@property
def is_initialized(self) -> bool:
"""获取初始化状态"""
return self.backend.is_initialized
@property
def tip_attached(self) -> bool:
"""获取吸头附着状态"""
return self.backend.tip_attached
async def setup(self) -> bool:
"""设置液体处理器"""
try:
success = await self.backend.setup()
if success:
self.is_setup = True
logger.info("LaiYu_Liquid 设置完成")
return success
except Exception as e:
logger.error(f"LaiYu_Liquid 设置失败: {e}")
return False
async def stop(self):
"""停止液体处理器"""
await self.backend.stop()
self.is_setup = False
async def transfer(
self, source: str, target: str, volume: float, tip_rack: str = "tip_rack_1", tip_position: int = 0
) -> bool:
"""液体转移"""
try:
if not self.is_setup:
raise LaiYuLiquidError("设备未设置")
# 获取源和目标位置
source_pos = self.deck.get_position(source)
target_pos = self.deck.get_position(target)
tip_pos = self.deck.get_position(tip_rack)
if not all([source_pos, target_pos, tip_pos]):
raise LaiYuLiquidError("位置信息不完整")
# 执行转移步骤
steps = [
("移动到吸头架", self.backend.move_to(*tip_pos)),
("拾取吸头", self.backend.pick_up_tip(tip_rack, tip_position)),
("移动到源位置", self.backend.move_to(*source_pos)),
("吸取液体", self.backend.aspirate(volume, source)),
("移动到目标位置", self.backend.move_to(*target_pos)),
("分配液体", self.backend.dispense(volume, target)),
("丢弃吸头", self.backend.drop_tip()),
]
for step_name, step_coro in steps:
logger.debug(f"执行步骤: {step_name}")
success = await step_coro
if not success:
raise LaiYuLiquidError(f"步骤失败: {step_name}")
logger.info(f"液体转移完成: {source} -> {target}, {volume} μL")
return True
except Exception as e:
logger.error(f"液体转移失败: {e}")
return False
def add_resource(self, name: str, resource_type: str, position: Tuple[float, float, float]):
"""添加资源到工作台"""
if resource_type == "plate":
resource = Plate(name)
elif resource_type == "tip_rack":
resource = TipRack(name)
else:
resource = Resource(name)
self.deck.add_resource(name, resource, position)
def get_status(self) -> Dict[str, Any]:
"""获取设备状态"""
return {
"connected": self.backend.is_connected,
"setup": self.is_setup,
"current_position": self.backend.current_position,
"tip_attached": self.backend.tip_attached,
"current_volume": self.backend.current_volume,
"resources": self.deck.list_resources(),
}
def create_quick_setup() -> LaiYuLiquidDeck:
"""
创建快速设置的LaiYu液体处理工作站
Returns:
LaiYuLiquidDeck: 配置好的工作台实例
"""
# 创建默认配置
config = LaiYuLiquidConfig()
# 创建工作台
deck = LaiYuLiquidDeck(config)
# 导入资源创建函数
try:
from .laiyu_liquid_res import (
create_tip_rack_1000ul,
create_tip_rack_200ul,
create_96_well_plate,
create_waste_container,
)
# 添加基本资源
tip_rack_1000 = create_tip_rack_1000ul("tip_rack_1000")
tip_rack_200 = create_tip_rack_200ul("tip_rack_200")
plate_96 = create_96_well_plate("plate_96")
waste = create_waste_container("waste")
# 添加到工作台
deck.add_resource("tip_rack_1000", tip_rack_1000, (50, 50, 0))
deck.add_resource("tip_rack_200", tip_rack_200, (150, 50, 0))
deck.add_resource("plate_96", plate_96, (250, 50, 0))
deck.add_resource("waste", waste, (50, 150, 0))
except ImportError:
# 如果资源模块不可用,创建空的工作台
logger.warning("资源模块不可用,创建空的工作台")
return deck
__all__ = [
"LaiYuLiquid",
"LaiYuLiquidBackend",
"LaiYuLiquidConfig",
"LaiYuLiquidDeck",
"LaiYuLiquidContainer",
"LaiYuLiquidTipRack",
"LaiYuLiquidError",
"create_quick_setup",
"get_module_info",
]

View File

@@ -1,954 +0,0 @@
"""
LaiYu_Liquid 资源定义模块
该模块提供了 LaiYu_Liquid 工作站专用的资源定义函数,包括:
- 各种规格的枪头架
- 不同类型的板和容器
- 特殊功能位置
- 资源创建的便捷函数
所有资源都基于 deck.json 中的配置参数创建。
"""
import json
import os
from typing import Dict, List, Optional, Tuple, Any
from pathlib import Path
# PyLabRobot 资源导入
try:
from pylabrobot.resources import (
Resource, Deck, Plate, TipRack, Container, Tip,
Coordinate
)
from pylabrobot.resources.tip_rack import TipSpot
from pylabrobot.resources.well import Well as PlateWell
PYLABROBOT_AVAILABLE = True
except ImportError:
# 如果 PyLabRobot 不可用,创建模拟类
PYLABROBOT_AVAILABLE = False
class Resource:
def __init__(self, name: str):
self.name = name
class Deck(Resource):
pass
class Plate(Resource):
pass
class TipRack(Resource):
pass
class Container(Resource):
pass
class Tip(Resource):
pass
class TipSpot(Resource):
def __init__(self, name: str, **kwargs):
super().__init__(name)
# 忽略其他参数
class PlateWell(Resource):
pass
class Coordinate:
def __init__(self, x: float, y: float, z: float):
self.x = x
self.y = y
self.z = z
# 本地导入
from .laiyu_liquid_main import LaiYuLiquidDeck, LaiYuLiquidContainer, LaiYuLiquidTipRack
def load_deck_config() -> Dict[str, Any]:
"""
加载工作台配置文件
Returns:
Dict[str, Any]: 配置字典
"""
# 优先使用最新的deckconfig.json文件
config_path = Path(__file__).parent / "controllers" / "deckconfig.json"
# 如果最新配置文件不存在,回退到旧配置文件
if not config_path.exists():
config_path = Path(__file__).parent / "config" / "deck.json"
try:
with open(config_path, 'r', encoding='utf-8') as f:
return json.load(f)
except FileNotFoundError:
# 如果找不到配置文件,返回默认配置
return {
"name": "LaiYu_Liquid_Deck",
"size_x": 340.0,
"size_y": 250.0,
"size_z": 160.0
}
# 加载配置
DECK_CONFIG = load_deck_config()
class LaiYuTipRack1000(LaiYuLiquidTipRack):
"""1000μL 枪头架"""
def __init__(self, name: str):
"""
初始化1000μL枪头架
Args:
name: 枪头架名称
"""
super().__init__(
name=name,
size_x=127.76,
size_y=85.48,
size_z=30.0,
tip_count=96,
tip_volume=1000.0
)
# 创建枪头位置
self._create_tip_spots(
tip_count=96,
tip_spacing=9.0,
tip_type="1000ul"
)
def _create_tip_spots(self, tip_count: int, tip_spacing: float, tip_type: str):
"""
创建枪头位置 - 从配置文件中读取绝对坐标
Args:
tip_count: 枪头数量
tip_spacing: 枪头间距
tip_type: 枪头类型
"""
# 从配置文件中获取枪头架的孔位信息
config = DECK_CONFIG
tip_module = None
# 查找枪头架模块
for module in config.get("children", []):
if module.get("type") == "tip_rack":
tip_module = module
break
if not tip_module:
# 如果配置文件中没有找到,使用默认的相对坐标计算
rows = 8
cols = 12
for row in range(rows):
for col in range(cols):
spot_name = f"{chr(65 + row)}{col + 1:02d}"
x = col * tip_spacing + tip_spacing / 2
y = row * tip_spacing + tip_spacing / 2
# 创建枪头 - 根据PyLabRobot或模拟类使用不同参数
if PYLABROBOT_AVAILABLE:
# PyLabRobot的Tip需要特定参数
tip = Tip(
has_filter=False,
total_tip_length=95.0, # 1000ul枪头长度
maximal_volume=1000.0, # 最大体积
fitting_depth=8.0 # 安装深度
)
else:
# 模拟类只需要name
tip = Tip(name=f"tip_{spot_name}")
# 创建枪头位置
if PYLABROBOT_AVAILABLE:
# PyLabRobot的TipSpot需要特定参数
tip_spot = TipSpot(
name=spot_name,
size_x=9.0, # 枪头位置宽度
size_y=9.0, # 枪头位置深度
size_z=95.0, # 枪头位置高度
make_tip=lambda: tip # 创建枪头的函数
)
else:
# 模拟类只需要name
tip_spot = TipSpot(name=spot_name)
# 将吸头位置分配到吸头架
self.assign_child_resource(
tip_spot,
location=Coordinate(x, y, 0)
)
return
# 使用配置文件中的绝对坐标
module_position = tip_module.get("position", {"x": 0, "y": 0, "z": 0})
for well_config in tip_module.get("wells", []):
spot_name = well_config["id"]
well_pos = well_config["position"]
# 计算相对于模块的坐标(绝对坐标减去模块位置)
relative_x = well_pos["x"] - module_position["x"]
relative_y = well_pos["y"] - module_position["y"]
relative_z = well_pos["z"] - module_position["z"]
# 创建枪头 - 根据PyLabRobot或模拟类使用不同参数
if PYLABROBOT_AVAILABLE:
# PyLabRobot的Tip需要特定参数
tip = Tip(
has_filter=False,
total_tip_length=95.0, # 1000ul枪头长度
maximal_volume=1000.0, # 最大体积
fitting_depth=8.0 # 安装深度
)
else:
# 模拟类只需要name
tip = Tip(name=f"tip_{spot_name}")
# 创建枪头位置
if PYLABROBOT_AVAILABLE:
# PyLabRobot的TipSpot需要特定参数
tip_spot = TipSpot(
name=spot_name,
size_x=well_config.get("diameter", 9.0), # 使用配置中的直径
size_y=well_config.get("diameter", 9.0),
size_z=well_config.get("depth", 95.0), # 使用配置中的深度
make_tip=lambda: tip # 创建枪头的函数
)
else:
# 模拟类只需要name
tip_spot = TipSpot(name=spot_name)
# 将吸头位置分配到吸头架
self.assign_child_resource(
tip_spot,
location=Coordinate(relative_x, relative_y, relative_z)
)
# 注意在PyLabRobot中Tip不是Resource不需要分配给TipSpot
# TipSpot的make_tip函数会在需要时创建Tip
class LaiYuTipRack200(LaiYuLiquidTipRack):
"""200μL 枪头架"""
def __init__(self, name: str):
"""
初始化200μL枪头架
Args:
name: 枪头架名称
"""
super().__init__(
name=name,
size_x=127.76,
size_y=85.48,
size_z=30.0,
tip_count=96,
tip_volume=200.0
)
# 创建枪头位置
self._create_tip_spots(
tip_count=96,
tip_spacing=9.0,
tip_type="200ul"
)
def _create_tip_spots(self, tip_count: int, tip_spacing: float, tip_type: str):
"""
创建枪头位置
Args:
tip_count: 枪头数量
tip_spacing: 枪头间距
tip_type: 枪头类型
"""
rows = 8
cols = 12
for row in range(rows):
for col in range(cols):
spot_name = f"{chr(65 + row)}{col + 1:02d}"
x = col * tip_spacing + tip_spacing / 2
y = row * tip_spacing + tip_spacing / 2
# 创建枪头 - 根据PyLabRobot或模拟类使用不同参数
if PYLABROBOT_AVAILABLE:
# PyLabRobot的Tip需要特定参数
tip = Tip(
has_filter=False,
total_tip_length=72.0, # 200ul枪头长度
maximal_volume=200.0, # 最大体积
fitting_depth=8.0 # 安装深度
)
else:
# 模拟类只需要name
tip = Tip(name=f"tip_{spot_name}")
# 创建枪头位置
if PYLABROBOT_AVAILABLE:
# PyLabRobot的TipSpot需要特定参数
tip_spot = TipSpot(
name=spot_name,
size_x=9.0, # 枪头位置宽度
size_y=9.0, # 枪头位置深度
size_z=72.0, # 枪头位置高度
make_tip=lambda: tip # 创建枪头的函数
)
else:
# 模拟类只需要name
tip_spot = TipSpot(name=spot_name)
# 将吸头位置分配到吸头架
self.assign_child_resource(
tip_spot,
location=Coordinate(x, y, 0)
)
# 注意在PyLabRobot中Tip不是Resource不需要分配给TipSpot
# TipSpot的make_tip函数会在需要时创建Tip
class LaiYu96WellPlate(LaiYuLiquidContainer):
"""96孔板"""
def __init__(self, name: str, lid_height: float = 0.0):
"""
初始化96孔板
Args:
name: 板名称
lid_height: 盖子高度
"""
super().__init__(
name=name,
size_x=127.76,
size_y=85.48,
size_z=14.22,
container_type="96_well_plate",
volume=0.0,
max_volume=200.0,
lid_height=lid_height
)
# 创建孔位
self._create_wells(
well_count=96,
well_volume=200.0,
well_spacing=9.0
)
def get_size_z(self) -> float:
"""获取孔位深度"""
return 10.0 # 96孔板孔位深度
def _create_wells(self, well_count: int, well_volume: float, well_spacing: float):
"""
创建孔位 - 从配置文件中读取绝对坐标
Args:
well_count: 孔位数量
well_volume: 孔位体积
well_spacing: 孔位间距
"""
# 从配置文件中获取96孔板的孔位信息
config = DECK_CONFIG
plate_module = None
# 查找96孔板模块
for module in config.get("children", []):
if module.get("type") == "96_well_plate":
plate_module = module
break
if not plate_module:
# 如果配置文件中没有找到,使用默认的相对坐标计算
rows = 8
cols = 12
for row in range(rows):
for col in range(cols):
well_name = f"{chr(65 + row)}{col + 1:02d}"
x = col * well_spacing + well_spacing / 2
y = row * well_spacing + well_spacing / 2
# 创建孔位
well = PlateWell(
name=well_name,
size_x=well_spacing * 0.8,
size_y=well_spacing * 0.8,
size_z=self.get_size_z(),
max_volume=well_volume
)
# 添加到板
self.assign_child_resource(
well,
location=Coordinate(x, y, 0)
)
return
# 使用配置文件中的绝对坐标
module_position = plate_module.get("position", {"x": 0, "y": 0, "z": 0})
for well_config in plate_module.get("wells", []):
well_name = well_config["id"]
well_pos = well_config["position"]
# 计算相对于模块的坐标(绝对坐标减去模块位置)
relative_x = well_pos["x"] - module_position["x"]
relative_y = well_pos["y"] - module_position["y"]
relative_z = well_pos["z"] - module_position["z"]
# 创建孔位
well = PlateWell(
name=well_name,
size_x=well_config.get("diameter", 8.2) * 0.8, # 使用配置中的直径
size_y=well_config.get("diameter", 8.2) * 0.8,
size_z=well_config.get("depth", self.get_size_z()),
max_volume=well_config.get("volume", well_volume)
)
# 添加到板
self.assign_child_resource(
well,
location=Coordinate(relative_x, relative_y, relative_z)
)
class LaiYuDeepWellPlate(LaiYuLiquidContainer):
"""深孔板"""
def __init__(self, name: str, lid_height: float = 0.0):
"""
初始化深孔板
Args:
name: 板名称
lid_height: 盖子高度
"""
super().__init__(
name=name,
size_x=127.76,
size_y=85.48,
size_z=41.3,
container_type="deep_well_plate",
volume=0.0,
max_volume=2000.0,
lid_height=lid_height
)
# 创建孔位
self._create_wells(
well_count=96,
well_volume=2000.0,
well_spacing=9.0
)
def get_size_z(self) -> float:
"""获取孔位深度"""
return 35.0 # 深孔板孔位深度
def _create_wells(self, well_count: int, well_volume: float, well_spacing: float):
"""
创建孔位 - 从配置文件中读取绝对坐标
Args:
well_count: 孔位数量
well_volume: 孔位体积
well_spacing: 孔位间距
"""
# 从配置文件中获取深孔板的孔位信息
config = DECK_CONFIG
plate_module = None
# 查找深孔板模块通常是第二个96孔板模块
plate_modules = []
for module in config.get("children", []):
if module.get("type") == "96_well_plate":
plate_modules.append(module)
# 如果有多个96孔板模块选择第二个作为深孔板
if len(plate_modules) > 1:
plate_module = plate_modules[1]
elif len(plate_modules) == 1:
plate_module = plate_modules[0]
if not plate_module:
# 如果配置文件中没有找到,使用默认的相对坐标计算
rows = 8
cols = 12
for row in range(rows):
for col in range(cols):
well_name = f"{chr(65 + row)}{col + 1:02d}"
x = col * well_spacing + well_spacing / 2
y = row * well_spacing + well_spacing / 2
# 创建孔位
well = PlateWell(
name=well_name,
size_x=well_spacing * 0.8,
size_y=well_spacing * 0.8,
size_z=self.get_size_z(),
max_volume=well_volume
)
# 添加到板
self.assign_child_resource(
well,
location=Coordinate(x, y, 0)
)
return
# 使用配置文件中的绝对坐标
module_position = plate_module.get("position", {"x": 0, "y": 0, "z": 0})
for well_config in plate_module.get("wells", []):
well_name = well_config["id"]
well_pos = well_config["position"]
# 计算相对于模块的坐标(绝对坐标减去模块位置)
relative_x = well_pos["x"] - module_position["x"]
relative_y = well_pos["y"] - module_position["y"]
relative_z = well_pos["z"] - module_position["z"]
# 创建孔位
well = PlateWell(
name=well_name,
size_x=well_config.get("diameter", 8.2) * 0.8, # 使用配置中的直径
size_y=well_config.get("diameter", 8.2) * 0.8,
size_z=well_config.get("depth", self.get_size_z()),
max_volume=well_config.get("volume", well_volume)
)
# 添加到板
self.assign_child_resource(
well,
location=Coordinate(relative_x, relative_y, relative_z)
)
class LaiYuWasteContainer(Container):
"""废液容器"""
def __init__(self, name: str):
"""
初始化废液容器
Args:
name: 容器名称
"""
super().__init__(
name=name,
size_x=100.0,
size_y=100.0,
size_z=50.0,
max_volume=5000.0
)
class LaiYuWashContainer(Container):
"""清洗容器"""
def __init__(self, name: str):
"""
初始化清洗容器
Args:
name: 容器名称
"""
super().__init__(
name=name,
size_x=100.0,
size_y=100.0,
size_z=50.0,
max_volume=5000.0
)
class LaiYuReagentContainer(Container):
"""试剂容器"""
def __init__(self, name: str):
"""
初始化试剂容器
Args:
name: 容器名称
"""
super().__init__(
name=name,
size_x=50.0,
size_y=50.0,
size_z=100.0,
max_volume=2000.0
)
class LaiYu8TubeRack(LaiYuLiquidContainer):
"""8管试管架"""
def __init__(self, name: str):
"""
初始化8管试管架
Args:
name: 试管架名称
"""
super().__init__(
name=name,
size_x=151.0,
size_y=75.0,
size_z=75.0,
container_type="tube_rack",
volume=0.0,
max_volume=77000.0
)
# 创建孔位
self._create_wells(
well_count=8,
well_volume=77000.0,
well_spacing=35.0
)
def get_size_z(self) -> float:
"""获取孔位深度"""
return 117.0 # 试管深度
def _create_wells(self, well_count: int, well_volume: float, well_spacing: float):
"""
创建孔位 - 从配置文件中读取绝对坐标
Args:
well_count: 孔位数量
well_volume: 孔位体积
well_spacing: 孔位间距
"""
# 从配置文件中获取8管试管架的孔位信息
config = DECK_CONFIG
tube_module = None
# 查找8管试管架模块
for module in config.get("children", []):
if module.get("type") == "tube_rack":
tube_module = module
break
if not tube_module:
# 如果配置文件中没有找到,使用默认的相对坐标计算
rows = 2
cols = 4
for row in range(rows):
for col in range(cols):
well_name = f"{chr(65 + row)}{col + 1}"
x = col * well_spacing + well_spacing / 2
y = row * well_spacing + well_spacing / 2
# 创建孔位
well = PlateWell(
name=well_name,
size_x=29.0,
size_y=29.0,
size_z=self.get_size_z(),
max_volume=well_volume
)
# 添加到试管架
self.assign_child_resource(
well,
location=Coordinate(x, y, 0)
)
return
# 使用配置文件中的绝对坐标
module_position = tube_module.get("position", {"x": 0, "y": 0, "z": 0})
for well_config in tube_module.get("wells", []):
well_name = well_config["id"]
well_pos = well_config["position"]
# 计算相对于模块的坐标(绝对坐标减去模块位置)
relative_x = well_pos["x"] - module_position["x"]
relative_y = well_pos["y"] - module_position["y"]
relative_z = well_pos["z"] - module_position["z"]
# 创建孔位
well = PlateWell(
name=well_name,
size_x=well_config.get("diameter", 29.0),
size_y=well_config.get("diameter", 29.0),
size_z=well_config.get("depth", self.get_size_z()),
max_volume=well_config.get("volume", well_volume)
)
# 添加到试管架
self.assign_child_resource(
well,
location=Coordinate(relative_x, relative_y, relative_z)
)
class LaiYuTipDisposal(Resource):
"""枪头废料位置"""
def __init__(self, name: str):
"""
初始化枪头废料位置
Args:
name: 位置名称
"""
super().__init__(
name=name,
size_x=100.0,
size_y=100.0,
size_z=50.0
)
class LaiYuMaintenancePosition(Resource):
"""维护位置"""
def __init__(self, name: str):
"""
初始化维护位置
Args:
name: 位置名称
"""
super().__init__(
name=name,
size_x=50.0,
size_y=50.0,
size_z=100.0
)
# 资源创建函数
def create_tip_rack_1000ul(name: str = "tip_rack_1000ul") -> LaiYuTipRack1000:
"""
创建1000μL枪头架
Args:
name: 枪头架名称
Returns:
LaiYuTipRack1000: 1000μL枪头架实例
"""
return LaiYuTipRack1000(name)
def create_tip_rack_200ul(name: str = "tip_rack_200ul") -> LaiYuTipRack200:
"""
创建200μL枪头架
Args:
name: 枪头架名称
Returns:
LaiYuTipRack200: 200μL枪头架实例
"""
return LaiYuTipRack200(name)
def create_96_well_plate(name: str = "96_well_plate", lid_height: float = 0.0) -> LaiYu96WellPlate:
"""
创建96孔板
Args:
name: 板名称
lid_height: 盖子高度
Returns:
LaiYu96WellPlate: 96孔板实例
"""
return LaiYu96WellPlate(name, lid_height)
def create_deep_well_plate(name: str = "deep_well_plate", lid_height: float = 0.0) -> LaiYuDeepWellPlate:
"""
创建深孔板
Args:
name: 板名称
lid_height: 盖子高度
Returns:
LaiYuDeepWellPlate: 深孔板实例
"""
return LaiYuDeepWellPlate(name, lid_height)
def create_8_tube_rack(name: str = "8_tube_rack") -> LaiYu8TubeRack:
"""
创建8管试管架
Args:
name: 试管架名称
Returns:
LaiYu8TubeRack: 8管试管架实例
"""
return LaiYu8TubeRack(name)
def create_waste_container(name: str = "waste_container") -> LaiYuWasteContainer:
"""
创建废液容器
Args:
name: 容器名称
Returns:
LaiYuWasteContainer: 废液容器实例
"""
return LaiYuWasteContainer(name)
def create_wash_container(name: str = "wash_container") -> LaiYuWashContainer:
"""
创建清洗容器
Args:
name: 容器名称
Returns:
LaiYuWashContainer: 清洗容器实例
"""
return LaiYuWashContainer(name)
def create_reagent_container(name: str = "reagent_container") -> LaiYuReagentContainer:
"""
创建试剂容器
Args:
name: 容器名称
Returns:
LaiYuReagentContainer: 试剂容器实例
"""
return LaiYuReagentContainer(name)
def create_tip_disposal(name: str = "tip_disposal") -> LaiYuTipDisposal:
"""
创建枪头废料位置
Args:
name: 位置名称
Returns:
LaiYuTipDisposal: 枪头废料位置实例
"""
return LaiYuTipDisposal(name)
def create_maintenance_position(name: str = "maintenance_position") -> LaiYuMaintenancePosition:
"""
创建维护位置
Args:
name: 位置名称
Returns:
LaiYuMaintenancePosition: 维护位置实例
"""
return LaiYuMaintenancePosition(name)
def create_standard_deck() -> LaiYuLiquidDeck:
"""
创建标准工作台配置
Returns:
LaiYuLiquidDeck: 配置好的工作台实例
"""
# 从配置文件创建工作台
deck = LaiYuLiquidDeck(config=DECK_CONFIG)
return deck
def get_resource_by_name(deck: LaiYuLiquidDeck, name: str) -> Optional[Resource]:
"""
根据名称获取资源
Args:
deck: 工作台实例
name: 资源名称
Returns:
Optional[Resource]: 找到的资源如果不存在则返回None
"""
for child in deck.children:
if child.name == name:
return child
return None
def get_resources_by_type(deck: LaiYuLiquidDeck, resource_type: type) -> List[Resource]:
"""
根据类型获取资源列表
Args:
deck: 工作台实例
resource_type: 资源类型
Returns:
List[Resource]: 匹配类型的资源列表
"""
return [child for child in deck.children if isinstance(child, resource_type)]
def list_all_resources(deck: LaiYuLiquidDeck) -> Dict[str, List[str]]:
"""
列出所有资源
Args:
deck: 工作台实例
Returns:
Dict[str, List[str]]: 按类型分组的资源名称字典
"""
resources = {
"tip_racks": [],
"plates": [],
"containers": [],
"positions": []
}
for child in deck.children:
if isinstance(child, (LaiYuTipRack1000, LaiYuTipRack200)):
resources["tip_racks"].append(child.name)
elif isinstance(child, (LaiYu96WellPlate, LaiYuDeepWellPlate)):
resources["plates"].append(child.name)
elif isinstance(child, (LaiYuWasteContainer, LaiYuWashContainer, LaiYuReagentContainer)):
resources["containers"].append(child.name)
elif isinstance(child, (LaiYuTipDisposal, LaiYuMaintenancePosition)):
resources["positions"].append(child.name)
return resources
# 导出的类别名(向后兼容)
TipRack1000ul = LaiYuTipRack1000
TipRack200ul = LaiYuTipRack200
Plate96Well = LaiYu96WellPlate
Plate96DeepWell = LaiYuDeepWellPlate
TubeRack8 = LaiYu8TubeRack
WasteContainer = LaiYuWasteContainer
WashContainer = LaiYuWashContainer
ReagentContainer = LaiYuReagentContainer
TipDisposal = LaiYuTipDisposal
MaintenancePosition = LaiYuMaintenancePosition

View File

@@ -1,69 +0,0 @@
# 更新日志
本文档记录了 LaiYu_Liquid 模块的所有重要变更。
## [1.0.0] - 2024-01-XX
### 新增功能
- ✅ 完整的液体处理工作站集成
- ✅ RS485 通信协议支持
- ✅ SOPA 气动式移液器驱动
- ✅ XYZ 三轴步进电机控制
- ✅ PyLabRobot 兼容后端
- ✅ 标准化资源管理系统
- ✅ 96孔板、离心管架、枪头架支持
- ✅ RViz 可视化后端
- ✅ 完整的配置管理系统
- ✅ 抽象协议实现
- ✅ 生产级错误处理和日志记录
### 技术特性
- **硬件支持**: SOPA移液器 + XYZ三轴运动平台
- **通信协议**: RS485总线波特率115200
- **坐标系统**: 机械坐标与工作坐标自动转换
- **安全机制**: 限位保护、紧急停止、错误恢复
- **兼容性**: 完全兼容 PyLabRobot 框架
### 文件结构
```
LaiYu_Liquid/
├── core/
│ └── LaiYu_Liquid.py # 主模块文件
├── __init__.py # 模块初始化
├── abstract_protocol.py # 抽象协议
├── laiyu_liquid_res.py # 资源管理
├── rviz_backend.py # RViz后端
├── backend/ # 后端驱动
├── config/ # 配置文件
├── controllers/ # 控制器
├── docs/ # 技术文档
└── drivers/ # 底层驱动
```
### 已知问题
-
### 依赖要求
- Python 3.8+
- PyLabRobot
- pyserial
- asyncio
---
## 版本说明
### 版本号格式
采用语义化版本控制 (Semantic Versioning): `MAJOR.MINOR.PATCH`
- **MAJOR**: 不兼容的API变更
- **MINOR**: 向后兼容的功能新增
- **PATCH**: 向后兼容的问题修复
### 变更类型
- **新增功能**: 新的功能特性
- **变更**: 现有功能的变更
- **弃用**: 即将移除的功能
- **移除**: 已移除的功能
- **修复**: 问题修复
- **安全**: 安全相关的修复

View File

@@ -1,267 +0,0 @@
# SOPA气动式移液器RS485控制指令合集
## 1. RS485通信基本配置
### 1.1 支持的设备型号
- **仅SC-STxxx-00-13支持RS485通信**
- 其他型号主要使用CAN通信
### 1.2 通信参数
- **波特率**: 9600, 115200默认值
- **地址范围**: 1~254个设备255为广播地址
- **通信接口**: RS485差分信号
### 1.3 引脚分配10位LIF连接器
- **引脚7**: RS485+ (RS485通信正极)
- **引脚8**: RS485- (RS485通信负极)
## 2. RS485通信协议格式
### 2.1 发送数据格式
```
头码 | 地址 | 命令/数据 | 尾码 | 校验和
```
### 2.2 从机回应格式
```
头码 | 地址 | 数据固定9字节 | 尾码 | 校验和
```
### 2.3 格式详细说明
- **头码**:
- 终端调试: '/' (0x2F)
- OEM通信: '[' (0x5B)
- **地址**: 设备节点地址1~254多字节ASCII注意地址不可为476991
- **命令/数据**: ASCII格式的命令字符串
- **尾码**: 'E' (0x45)
- **校验和**: 以上数据的累加值1字节
## 3. 初始化和基本控制指令
### 3.1 初始化指令
```bash
# 初始化活塞驱动机构
HE
# 示例OEM通信
# 主机发送: 5B 32 48 45 1A
# 从机回应开始: 2F 02 06 0A 30 00 00 00 00 00 00 45 B6
# 从机回应完成: 2F 02 06 00 30 00 00 00 00 00 00 45 AC
```
### 3.2 枪头操作指令
```bash
# 顶出枪头
RE
# 枪头检测状态报告
Q28 # 返回枪头存在状态0=不存在1=存在)
```
## 4. 移液控制指令
### 4.1 位置控制指令
```bash
# 绝对位置移动(微升)
A[n]E
# 示例移动到位置0
A0E
# 相对抽吸(向上移动)
P[n]E
# 示例抽吸200微升
P200E
# 相对分配(向下移动)
D[n]E
# 示例分配200微升
D200E
```
### 4.2 速度设置指令
```bash
# 设置最高速度0.1ul/秒为单位)
s[n]E
# 示例设置最高速度为2000200ul/秒)
s2000E
# 设置启动速度
b[n]E
# 示例设置启动速度为10010ul/秒)
b100E
# 设置断流速度
c[n]E
# 示例设置断流速度为10010ul/秒)
c100E
# 设置加速度
a[n]E
# 示例设置加速度为30000
a30000E
```
## 5. 液体检测和安全控制指令
### 5.1 吸排液检测控制
```bash
# 开启吸排液检测
f1E # 开启
f0E # 关闭
# 设置空吸门限
$[n]E
# 示例设置空吸门限为4
$4E
# 设置泡沫门限
![n]E
# 示例设置泡沫门限为20
!20E
# 设置堵塞门限
%[n]E
# 示例设置堵塞门限为350
%350E
```
### 5.2 液位检测指令
```bash
# 压力式液位检测
m0E # 设置为压力探测模式
L[n]E # 执行液位检测,[n]为灵敏度(3~40)
k[n]E # 设置检测速度(100~2000)
# 电容式液位检测
m1E # 设置为电容探测模式
```
## 6. 状态查询和报告指令
### 6.1 基本状态查询
```bash
# 查询固件版本
V
# 查询设备状态
Q[n]
# 常用查询参数:
Q01 # 报告加速度
Q02 # 报告启动速度
Q03 # 报告断流速度
Q06 # 报告最大速度
Q08 # 报告节点地址
Q11 # 报告波特率
Q18 # 报告当前位置
Q28 # 报告枪头存在状态
Q29 # 报告校准系数
Q30 # 报告空吸门限
Q31 # 报告堵针门限
Q32 # 报告泡沫门限
```
## 7. 配置和校准指令
### 7.1 校准参数设置
```bash
# 设置校准系数
j[n]E
# 示例设置校准系数为1.04
j1.04E
# 设置补偿偏差
e[n]E
# 示例设置补偿偏差为2.03
e2.03E
# 设置吸头容量
C[n]E
# 示例设置1000ul吸头
C1000E
```
### 7.2 高级控制参数
```bash
# 设置回吸粘度
][n]E
# 示例设置回吸粘度为30
]30E
# 延时控制
M[n]E
# 示例延时1000毫秒
M1000E
```
## 8. 复合操作指令示例
### 8.1 标准移液操作
```bash
# 完整的200ul移液操作
a30000b200c200s2000P200E
# 解析设置加速度30000 + 启动速度200 + 断流速度200 + 最高速度2000 + 抽吸200ul + 执行
```
### 8.2 带检测的移液操作
```bash
# 带空吸检测的200ul抽吸
a30000b200c200s2000f1P200f0E
# 解析:设置参数 + 开启检测 + 抽吸200ul + 关闭检测 + 执行
```
### 8.3 液面检测操作
```bash
# 压力式液面检测
m0k200L5E
# 解析:压力模式 + 检测速度200 + 灵敏度5 + 执行检测
# 电容式液面检测
m1L3E
# 解析:电容模式 + 灵敏度3 + 执行检测
```
## 9. 错误处理
### 9.1 状态字节说明
- **00h**: 无错误
- **01h**: 上次动作未完成
- **02h**: 设备未初始化
- **03h**: 设备过载
- **04h**: 无效指令
- **05h**: 液位探测故障
- **0Dh**: 空吸
- **0Eh**: 堵针
- **10h**: 泡沫
- **11h**: 吸液超过吸头容量
### 9.2 错误查询
```bash
# 查询当前错误状态
Q # 返回状态字节和错误代码
```
## 10. 通信示例
### 10.1 基本通信流程
1. **执行命令**: 主机发送命令 → 从机确认 → 从机执行 → 从机回应完成
2. **读取数据**: 主机发送查询 → 从机确认 → 从机返回数据
### 10.2 快速指令表
| 操作 | 指令 | 说明 |
|------|------|------|
| 初始化 | `HE` | 初始化设备 |
| 退枪头 | `RE` | 顶出枪头 |
| 吸液200ul | `a30000b200c200s2000P200E` | 基本吸液 |
| 带检测吸液 | `a30000b200c200s2000f1P200f0E` | 开启空吸检测 |
| 吐液200ul | `a300000b500c500s6000D200E` | 基本分配 |
| 压力液面检测 | `m0k200L5E` | pLLD检测 |
| 电容液面检测 | `m1L3E` | cLLD检测 |
## 11. 注意事项
1. **地址限制**: RS485地址不可设为47、69、91
2. **校验和**: 终端调试时不关心校验和OEM通信需要校验
3. **ASCII格式**: 所有命令和参数都使用ASCII字符
4. **执行指令**: 大部分命令需要以'E'结尾才能执行
5. **设备支持**: 只有SC-STxxx-00-13型号支持RS485通信
6. **波特率设置**: 默认115200可设置为9600

View File

@@ -1,162 +0,0 @@
# 步进电机B系列控制指令详解
## 基本通信参数
- **通信方式**: RS485
- **协议**: Modbus
- **波特率**: 115200 (默认)
- **数据位**: 8位
- **停止位**: 1位
- **校验位**: 无
- **默认站号**: 1 (可设置1-254)
## 支持的功能码
- **03H**: 读取寄存器
- **06H**: 写入单个寄存器
- **10H**: 写入多个寄存器
## 寄存器地址表
### 状态监控寄存器 (只读)
| 地址 | 功能码 | 内容 | 说明 |
|------|--------|------|------|
| 00H | 03H | 电机状态 | 0000H-待机/到位, 0001H-运行中, 0002H-碰撞停, 0003H-正光电停, 0004H-反光电停 |
| 01H | 03H | 实际步数高位 | 当前电机位置的高16位 |
| 02H | 03H | 实际步数低位 | 当前电机位置的低16位 |
| 03H | 03H | 实际速度 | 当前转速 (rpm) |
| 05H | 03H | 电流 | 当前工作电流 (mA) |
### 控制寄存器 (读写)
| 地址 | 功能码 | 内容 | 说明 |
|------|--------|------|------|
| 04H | 03H/06H/10H | 急停指令 | 紧急停止控制 |
| 06H | 03H/06H/10H | 失能控制 | 1-使能, 0-失能 |
| 07H | 03H/06H/10H | PWM输出 | 0-1000对应0%-100%占空比 |
| 0EH | 03H/06H/10H | 单圈绝对值归零 | 归零指令 |
| 0FH | 03H/06H/10H | 归零指令 | 定点模式归零速度设置 |
### 位置模式寄存器
| 地址 | 功能码 | 内容 | 说明 |
|------|--------|------|------|
| 10H | 03H/06H/10H | 目标步数高位 | 目标位置高16位 |
| 11H | 03H/06H/10H | 目标步数低位 | 目标位置低16位 |
| 12H | 03H/06H/10H | 保留 | - |
| 13H | 03H/06H/10H | 速度 | 运行速度 (rpm) |
| 14H | 03H/06H/10H | 加速度 | 0-60000 rpm/s |
| 15H | 03H/06H/10H | 精度 | 到位精度设置 |
### 速度模式寄存器
| 地址 | 功能码 | 内容 | 说明 |
|------|--------|------|------|
| 60H | 03H/06H/10H | 保留 | - |
| 61H | 03H/06H/10H | 速度 | 正值正转,负值反转 |
| 62H | 03H/06H/10H | 加速度 | 0-60000 rpm/s |
### 设备参数寄存器
| 地址 | 功能码 | 内容 | 默认值 | 说明 |
|------|--------|------|--------|------|
| E0H | 03H/06H/10H | 设备地址 | 0001H | Modbus从站地址 |
| E1H | 03H/06H/10H | 堵转电流 | 0BB8H | 堵转检测电流阈值 |
| E2H | 03H/06H/10H | 保留 | 0258H | - |
| E3H | 03H/06H/10H | 每圈步数 | 0640H | 细分设置 |
| E4H | 03H/06H/10H | 限位开关使能 | F000H | 1-使能, 0-禁用 |
| E5H | 03H/06H/10H | 堵转逻辑 | 0000H | 00-断电, 01-对抗 |
| E6H | 03H/06H/10H | 堵转时间 | 0000H | 堵转检测时间(ms) |
| E7H | 03H/06H/10H | 默认速度 | 1388H | 上电默认速度 |
| E8H | 03H/06H/10H | 默认加速度 | EA60H | 上电默认加速度 |
| E9H | 03H/06H/10H | 默认精度 | 0064H | 上电默认精度 |
| EAH | 03H/06H/10H | 波特率高位 | 0001H | 通信波特率设置 |
| EBH | 03H/06H/10H | 波特率低位 | C200H | 115200对应01C200H |
### 版本信息寄存器 (只读)
| 地址 | 功能码 | 内容 | 说明 |
|------|--------|------|------|
| F0H | 03H | 版本号 | 固件版本信息 |
| F1H-F4H | 03H | 型号 | 产品型号信息 |
## 常用控制指令示例
### 读取电机状态
```
发送: 01 03 00 00 00 01 84 0A
接收: 01 03 02 00 01 79 84
说明: 电机状态为0001H (正在运行)
```
### 读取当前位置
```
发送: 01 03 00 01 00 02 95 CB
接收: 01 03 04 00 19 00 00 2B F4
说明: 当前位置为1638400步 (100圈)
```
### 停止电机
```
发送: 01 10 00 04 00 01 02 00 00 A7 D4
接收: 01 10 00 04 00 01 40 08
说明: 急停指令
```
### 位置模式运动
```
发送: 01 10 00 10 00 06 0C 00 19 00 00 00 00 13 88 00 00 00 00 9F FB
接收: 01 10 00 10 00 06 41 CE
说明: 以5000rpm速度运动到1638400步位置
```
### 速度模式 - 正转
```
发送: 01 10 00 60 00 04 08 00 00 13 88 00 FA 00 00 F4 77
接收: 01 10 00 60 00 04 C1 D4
说明: 以5000rpm速度正转
```
### 速度模式 - 反转
```
发送: 01 10 00 60 00 04 08 00 00 EC 78 00 FA 00 00 A0 6D
接收: 01 10 00 60 00 04 C1 D4
说明: 以5000rpm速度反转 (EC78H = -5000)
```
### 设置设备地址
```
发送: 00 06 00 E0 00 02 C9 F1
接收: 00 06 00 E0 00 02 C9 F1
说明: 将设备地址设置为2
```
## 错误码
| 状态码 | 含义 |
|--------|------|
| 0001H | 功能码错误 |
| 0002H | 地址错误 |
| 0003H | 长度错误 |
## CRC校验算法
```c
public static byte[] ModBusCRC(byte[] data, int offset, int cnt) {
int wCrc = 0x0000FFFF;
byte[] CRC = new byte[2];
for (int i = 0; i < cnt; i++) {
wCrc ^= ((data[i + offset]) & 0xFF);
for (int j = 0; j < 8; j++) {
if ((wCrc & 0x00000001) == 1) {
wCrc >>= 1;
wCrc ^= 0x0000A001;
} else {
wCrc >>= 1;
}
}
}
CRC[1] = (byte) ((wCrc & 0x0000FF00) >> 8);
CRC[0] = (byte) (wCrc & 0x000000FF);
return CRC;
}
```
## 注意事项
1. 所有16位数据采用大端序传输
2. 步数计算: 实际步数 = 高位<<16 | 低位
3. 负数使用补码表示
4. PWM输出K脚: 0%开漏, 100%接地, 其他输出1KHz PWM
5. 光电开关需使用NPN开漏型
6. 限位开关: LF正向, LB反向

Some files were not shown because too many files have changed in this diff Show More