Compare commits

..

14 Commits

Author SHA1 Message Date
Xuwznln
9aeffebde1 0.10.7 Update (#101)
* Cleanup registry to be easy-understanding (#76)

* delete deprecated mock devices

* rename categories

* combine chromatographic devices

* rename rviz simulation nodes

* organic virtual devices

* parse vessel_id

* run registry completion before merge

---------

Co-authored-by: Xuwznln <18435084+Xuwznln@users.noreply.github.com>

* fix: workstation handlers and vessel_id parsing

* fix: working dir error when input config path
feat: report publish topic when error

* modify default discovery_interval to 15s

* feat: add trace log level

* feat: 添加ChinWe设备控制类,支持串口通信和电机控制功能 (#79)

* fix: drop_tips not using auto resource select

* fix: discard_tips error

* fix: discard_tips

* fix: prcxi_res

* add: prcxi res
fix: startup slow

* feat: workstation example

* fix pumps and liquid_handler handle

* feat: 优化protocol node节点运行日志

* fix all protocol_compilers and remove deprecated devices

* feat: 新增use_remote_resource参数

* fix and remove redundant info

* bugfixes on organic protocols

* fix filter protocol

* fix protocol node

* 临时兼容错误的driver写法

* fix: prcxi import error

* use call_async in all service to avoid deadlock

* fix: figure_resource

* Update recipe.yaml

* add workstation template and battery example

* feat: add sk & ak

* update workstation base

* Create workstation_architecture.md

* refactor: workstation_base 重构为仅含业务逻辑,通信和子设备管理交给 ProtocolNode

* refactor: ProtocolNode→WorkstationNode

* Add:msgs.action (#83)

* update: Workstation dev 将版本号从 0.10.3 更新为 0.10.4 (#84)

* Add:msgs.action

* update: 将版本号从 0.10.3 更新为 0.10.4

* simplify resource system

* uncompleted refactor

* example for use WorkstationBase

* feat: websocket

* feat: websocket test

* feat: workstation example

* feat: action status

* fix: station自己的方法注册错误

* fix: 还原protocol node处理方法

* fix: build

* fix: missing job_id key

* ws test version 1

* ws test version 2

* ws protocol

* 增加物料关系上传日志

* 增加物料关系上传日志

* 修正物料关系上传

* 修复工站的tracker实例追踪失效问题

* 增加handle检测,增加material edge关系上传

* 修复event loop错误

* 修复edge上报错误

* 修复async错误

* 更新schema的title字段

* 主机节点信息等支持自动刷新

* 注册表编辑器

* 修复status密集发送时,消息出错

* 增加addr参数

* fix: addr param

* fix: addr param

* 取消labid 和 强制config输入

* Add action definitions for LiquidHandlerSetGroup and LiquidHandlerTransferGroup

- Created LiquidHandlerSetGroup.action with fields for group name, wells, and volumes.
- Created LiquidHandlerTransferGroup.action with fields for source and target group names and unit volume.
- Both actions include response fields for return information and success status.

* Add LiquidHandlerSetGroup and LiquidHandlerTransferGroup actions to CMakeLists

* Add set_group and transfer_group methods to PRCXI9300Handler and update liquid_handler.yaml

* result_info改为字典类型

* 新增uat的地址替换

* runze multiple pump support

(cherry picked from commit 49354fcf39)

* remove runze multiple software obtainer

(cherry picked from commit 8bcc92a394)

* support multiple backbone

(cherry picked from commit 4771ff2347)

* Update runze pump format

* Correct runze multiple backbone

* Update runze_multiple_backbone

* Correct runze pump multiple receive method.

* Correct runze pump multiple receive method.

* 对于PRCXI9320的transfer_group,一对多和多对多

* 移除MQTT,更新launch文档,提供注册表示例文件,更新到0.10.5

* fix import error

* fix dupe upload registry

* refactor ws client

* add server timeout

* Fix: run-column with correct vessel id (#86)

* fix run_column

* Update run_column_protocol.py

(cherry picked from commit e5aa4d940a)

* resource_update use resource_add

* 新增版位推荐功能

* 重新规定了版位推荐的入参

* update registry with nested obj

* fix protocol node log_message, added create_resource return value

* fix protocol node log_message, added create_resource return value

* try fix add protocol

* fix resource_add

* 修复移液站错误的aspirate注册表

* Feature/xprbalance-zhida (#80)

* feat(devices): add Zhida GC/MS pretreatment automation workstation

* feat(devices): add mettler_toledo xpr balance

* balance

* 重新补全zhida注册表

* PRCXI9320 json

* PRCXI9320 json

* PRCXI9320 json

* fix resource download

* remove class for resource

* bump version to 0.10.6

* 更新所有注册表

* 修复protocolnode的兼容性

* 修复protocolnode的兼容性

* Update install md

* Add Defaultlayout

* 更新物料接口

* fix dict to tree/nested-dict converter

* coin_cell_station draft

* refactor: rename "station_resource" to "deck"

* add standardized BIOYOND resources: bottle_carrier, bottle

* refactor and add BIOYOND resources tests

* add BIOYOND deck assignment and pass all tests

* fix: update resource with correct structure; remove deprecated liquid_handler set_group action

* feat: 将新威电池测试系统驱动与配置文件并入 workstation_dev_YB2 (#92)

* feat: 新威电池测试系统驱动与注册文件

* feat: bring neware driver & battery.json into workstation_dev_YB2

* add bioyond studio draft

* bioyond station with communication init and resource sync

* fix bioyond station and registry

* fix: update resource with correct structure; remove deprecated liquid_handler set_group action

* frontend_docs

* create/update resources with POST/PUT for big amount/ small amount data

* create/update resources with POST/PUT for big amount/ small amount data

* refactor: add itemized_carrier instead of carrier consists of ResourceHolder

* create warehouse by factory func

* update bioyond launch json

* add child_size for itemized_carrier

* fix bioyond resource io

* Workstation templates: Resources and its CRUD, and workstation tasks (#95)

* coin_cell_station draft

* refactor: rename "station_resource" to "deck"

* add standardized BIOYOND resources: bottle_carrier, bottle

* refactor and add BIOYOND resources tests

* add BIOYOND deck assignment and pass all tests

* fix: update resource with correct structure; remove deprecated liquid_handler set_group action

* feat: 将新威电池测试系统驱动与配置文件并入 workstation_dev_YB2 (#92)

* feat: 新威电池测试系统驱动与注册文件

* feat: bring neware driver & battery.json into workstation_dev_YB2

* add bioyond studio draft

* bioyond station with communication init and resource sync

* fix bioyond station and registry

* create/update resources with POST/PUT for big amount/ small amount data

* refactor: add itemized_carrier instead of carrier consists of ResourceHolder

* create warehouse by factory func

* update bioyond launch json

* add child_size for itemized_carrier

* fix bioyond resource io

---------

Co-authored-by: h840473807 <47357934+h840473807@users.noreply.github.com>
Co-authored-by: Xie Qiming <97236197+Andy6M@users.noreply.github.com>

* 更新物料接口

* Workstation dev yb2 (#100)

* Refactor and extend reaction station action messages

* Refactor dispensing station tasks to enhance parameter clarity and add batch processing capabilities

- Updated `create_90_10_vial_feeding_task` to include detailed parameters for 90%/10% vial feeding, improving clarity and usability.
- Introduced `create_batch_90_10_vial_feeding_task` for batch processing of 90%/10% vial feeding tasks with JSON formatted input.
- Added `create_batch_diamine_solution_task` for batch preparation of diamine solution, also utilizing JSON formatted input.
- Refined `create_diamine_solution_task` to include additional parameters for better task configuration.
- Enhanced schema descriptions and default values for improved user guidance.

* 修复to_plr_resources

* add update remove

* 支持选择器注册表自动生成
支持转运物料

* 修复资源添加

* 修复transfer_resource_to_another生成

* 更新transfer_resource_to_another参数,支持spot入参

* 新增test_resource动作

* fix host_node error

* fix host_node test_resource error

* fix host_node test_resource error

* 过滤本地动作

* 移动内部action以兼容host node

* 修复同步任务报错不显示的bug

* feat: 允许返回非本节点物料,后面可以通过decoration进行区分,就不进行warning了

* update todo

* modify bioyond/plr converter, bioyond resource registry, and tests

* pass the tests

* update todo

* add conda-pack-build.yml

* add auto install script for conda-pack-build.yml

(cherry picked from commit 172599adcf)

* update conda-pack-build.yml

* update conda-pack-build.yml

* update conda-pack-build.yml

* update conda-pack-build.yml

* update conda-pack-build.yml

* Add version in __init__.py
Update conda-pack-build.yml
Add create_zip_archive.py

* Update conda-pack-build.yml

* Update conda-pack-build.yml (with mamba)

* Update conda-pack-build.yml

* Fix FileNotFoundError

* Try fix 'charmap' codec can't encode characters in position 16-23: character maps to <undefined>

* Fix unilabos msgs search error

* Fix environment_check.py

* Update recipe.yaml

* Update registry. Update uuid loop figure method. Update install docs.

* Fix nested conda pack

* Fix one-key installation path error

* Bump version to 0.10.7

* Workshop bj (#99)

* Add LaiYu Liquid device integration and tests

Introduce LaiYu Liquid device implementation, including backend, controllers, drivers, configuration, and resource files. Add hardware connection, tip pickup, and simplified test scripts, as well as experiment and registry configuration for LaiYu Liquid. Documentation and .gitignore for the device are also included.

* feat(LaiYu_Liquid): 重构设备模块结构并添加硬件文档

refactor: 重新组织LaiYu_Liquid模块目录结构
docs: 添加SOPA移液器和步进电机控制指令文档
fix: 修正设备配置中的最大体积默认值
test: 新增工作台配置测试用例
chore: 删除过时的测试脚本和配置文件

* add

* 重构: 将 LaiYu_Liquid.py 重命名为 laiyu_liquid_main.py 并更新所有导入引用

- 使用 git mv 将 LaiYu_Liquid.py 重命名为 laiyu_liquid_main.py
- 更新所有相关文件中的导入引用
- 保持代码功能不变,仅改善命名一致性
- 测试确认所有导入正常工作

* 修复: 在 core/__init__.py 中添加 LaiYuLiquidBackend 导出

- 添加 LaiYuLiquidBackend 到导入列表
- 添加 LaiYuLiquidBackend 到 __all__ 导出列表
- 确保所有主要类都可以正确导入

* 修复大小写文件夹名字

* 电池装配工站二次开发教程(带目录)上传至dev (#94)

* 电池装配工站二次开发教程

* Update intro.md

* 物料教程

* 更新物料教程,json格式注释

* Update prcxi driver & fix transfer_liquid mix_times (#90)

* Update prcxi driver & fix transfer_liquid mix_times

* fix: correct mix_times type

* Update liquid_handler registry

* test: prcxi.py

* Update registry from pr

* fix ony-key script not exist

* clean files

---------

Co-authored-by: Junhan Chang <changjh@dp.tech>
Co-authored-by: ZiWei <131428629+ZiWei09@users.noreply.github.com>
Co-authored-by: Guangxin Zhang <guangxin.zhang.bio@gmail.com>
Co-authored-by: Xie Qiming <97236197+Andy6M@users.noreply.github.com>
Co-authored-by: h840473807 <47357934+h840473807@users.noreply.github.com>
Co-authored-by: LccLink <1951855008@qq.com>
Co-authored-by: lixinyu1011 <61094742+lixinyu1011@users.noreply.github.com>
Co-authored-by: shiyubo0410 <shiyubo@dp.tech>
2025-10-12 23:34:26 +08:00
Xuwznln
172599adcf add auto install script for conda-pack-build.yml 2025-10-12 18:52:23 +08:00
Xuwznln
84cc3a421c add conda-pack-build.yml 2025-10-12 17:29:34 +08:00
Junhan Chang
e5aa4d940a Fix: run-column with correct vessel id (#86)
* fix run_column

* Update run_column_protocol.py
2025-09-15 12:42:05 +08:00
Xuwznln
4771ff2347 support multiple backbone 2025-09-13 23:14:06 +08:00
Xuwznln
8bcc92a394 remove runze multiple software obtainer 2025-09-13 23:01:19 +08:00
Xuwznln
49354fcf39 runze multiple pump support 2025-09-13 22:56:21 +08:00
Xuwznln
a8973ea92b 新增direct_end参数 2025-09-12 18:07:46 +08:00
Junhan Chang
0bfb52df00 Squash merge from dev
Update recipe.yaml

fix: figure_resource

use call_async in all service to avoid deadlock

fix: prcxi import error

临时兼容错误的driver写法

fix protocol node

fix filter protocol

bugfixes on organic protocols

fix and remove redundant info

feat: 新增use_remote_resource参数

fix all protocol_compilers and remove deprecated devices

feat: 优化protocol node节点运行日志

fix pumps and liquid_handler handle

feat: workstation example

add: prcxi res
fix: startup slow

fix: prcxi_res

fix: discard_tips

fix: discard_tips error

fix: drop_tips not using auto resource select

feat: 添加ChinWe设备控制类,支持串口通信和电机控制功能 (#79)

feat: add trace log level

modify default discovery_interval to 15s

fix: working dir error when input config path
feat: report publish topic when error

fix: workstation handlers and vessel_id parsing

Cleanup registry to be easy-understanding (#76)

* delete deprecated mock devices

* rename categories

* combine chromatographic devices

* rename rviz simulation nodes

* organic virtual devices

* parse vessel_id

* run registry completion before merge

---------

Co-authored-by: Xuwznln <18435084+Xuwznln@users.noreply.github.com>
2025-09-10 21:41:50 +08:00
Xuwznln
a555c59dc2 更新schema的title字段 2025-09-07 11:02:21 +08:00
Xuwznln
9ac0ad49cb feat: modify doc link 2025-09-04 11:10:02 +08:00
Xuwznln
daa46aaf50 feat: online docs 2025-09-04 11:04:58 +08:00
Xuwznln
bbd9629f98 bump version to 0.10.4 2025-08-28 20:01:04 +08:00
Xuwznln
2d560a8182 fix: build error 2025-08-28 19:57:54 +08:00
192 changed files with 124565 additions and 14833 deletions

View File

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

376
.github/workflows/conda-pack-build.yml vendored Normal file
View File

@@ -0,0 +1,376 @@
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 /C CALL {0}' || 'bash -el {0}' }}
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-activate-base: true
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 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 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 ros-humble-unilabos-msgs
for /f "tokens=2" %%i in ('conda list 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 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 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 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...
pip uninstall unilabos -y || echo unilabos not installed via pip
echo Installing unilabos from source (branch: ${{ github.event.inputs.branch }})...
pip install .
echo Verifying installation...
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..."
pip uninstall unilabos -y || echo "unilabos not installed via pip"
echo "Installing unilabos from source (branch: ${{ github.event.inputs.branch }})..."
pip install .
echo "Verifying installation..."
pip show unilabos
- name: Display environment info (Windows)
if: steps.should_build.outputs.should_build == 'true' && matrix.platform == 'win-64'
run: |
echo === Environment Information ===
conda env list
echo.
echo === Installed Packages ===
conda list | findstr /C:"unilabos" /C:"ros-humble-unilabos-msgs" || conda list
echo.
echo === Python Packages ===
pip list | findstr unilabos || 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 ==="
conda env list
echo ""
echo "=== Installed Packages ==="
conda list | grep -E "(unilabos|ros-humble-unilabos-msgs)" || conda list
echo ""
echo "=== Python Packages ==="
pip list | grep unilabos || pip list
- name: Verify environment integrity (Windows)
if: steps.should_build.outputs.should_build == 'true' && matrix.platform == 'win-64'
run: |
echo Verifying Python version...
python -c "import sys; print(f'Python version: {sys.version}')"
echo Verifying unilabos import...
python -c "import unilabos; print(f'UniLabOS version: {unilabos.__version__}')" || echo Warning: Could not import unilabos
echo Checking critical packages...
python -c "import rclpy; print('ROS2 rclpy: OK')"
echo Running comprehensive verification script...
python scripts\verify_installation.py || 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..."
python -c "import sys; print(f'Python version: {sys.version}')"
echo "Verifying unilabos import..."
python -c "import unilabos; print(f'UniLabOS version: {unilabos.__version__}')" || echo "Warning: Could not import unilabos"
echo "Checking critical packages..."
python -c "import rclpy; print('ROS2 rclpy: OK')"
echo "Running comprehensive verification script..."
python scripts/verify_installation.py || 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...
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..."
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 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/
# 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: Finalize Windows distribution package
if: steps.should_build.outputs.should_build == 'true' && matrix.platform == 'win-64'
run: |
echo ==========================================
echo Windows distribution package ready
echo.
echo Package will be uploaded as artifact
echo GitHub Actions will automatically create ZIP
echo.
echo Contents:
dir /b dist-package
echo.
echo Users will download a ZIP containing:
echo - install_unilab.bat
echo - unilab-env-${{ matrix.platform }}.tar.gz
echo - verify_installation.py
echo - README.txt
echo ==========================================
- name: Create Unix/Linux TAR.GZ archive
if: steps.should_build.outputs.should_build == 'true' && matrix.platform != 'win-64'
shell: bash
run: |
echo "=========================================="
echo "Creating Unix/Linux TAR.GZ archive..."
echo "Archive: unilab-pack-${{ matrix.platform }}.tar.gz"
echo "Contents: install_unilab.sh + unilab-env-${{ matrix.platform }}.tar.gz + extras"
tar -czf unilab-pack-${{ matrix.platform }}.tar.gz -C dist-package .
echo "=========================================="
echo ""
echo "Final package created:"
ls -lh unilab-pack-*
echo ""
echo "Users can now:"
echo " 1. Download unilab-pack-${{ matrix.platform }}.tar.gz"
echo " 2. Extract it: tar -xzf unilab-pack-${{ matrix.platform }}.tar.gz"
echo " 3. Run: bash install_unilab.sh"
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 "Package size (tar.gz):"
ls -lh unilab-pack-*.tar.gz
echo ""
echo "Artifact name: unilab-pack-${{ matrix.platform }}-${{ github.event.inputs.branch }}"
echo ""
echo "After download:"
echo " - Windows/macOS: Extract ZIP, then: tar -xzf unilab-pack-${{ matrix.platform }}.tar.gz"
echo " - Linux: Extract ZIP (or download tar.gz directly), run install_unilab.sh"
echo "=========================================="

98
.github/workflows/deploy-docs.yml vendored Normal file
View File

@@ -0,0 +1,98 @@
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 }}
- name: Setup Python environment
uses: actions/setup-python@v5
with:
python-version: '3.10'
- name: Install system dependencies
run: |
sudo apt-get update
sudo apt-get install -y pandoc
- name: Install Python dependencies
run: |
python -m pip install --upgrade pip
# Install package in development mode to get version info
pip install -e .
# Install documentation dependencies
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
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,6 +2,7 @@ configs/
temp/
output/
unilabos_data/
pyrightconfig.json
## Python
# Byte-compiled / optimized / DLL files

15
CONTRIBUTORS Normal file
View File

@@ -0,0 +1,15 @@
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://readthedocs.dp.tech/Uni-Lab/v0.8.0/)
- [Online Documentation](https://dptech-corp.github.io/Uni-Lab-OS/)
## Quick Start
@@ -55,7 +55,7 @@ pip install .
3. Start Uni-Lab System:
Please refer to [Documentation - Boot Examples](https://readthedocs.dp.tech/Uni-Lab/v0.8.0/boot_examples/index.html)
Please refer to [Documentation - Boot Examples](https://dptech-corp.github.io/Uni-Lab-OS/boot_examples/index.html)
## Message Format

View File

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

View File

@@ -19,7 +19,7 @@ Uni-Lab 的组态图当前支持 node-link json 和 graphml 格式,其中包
对用户来说,“直接操作设备执行单个指令”不是个真实需求,真正的需求是**“执行对实验有意义的单个完整动作”——加入某种液体多少量;萃取分液;洗涤仪器等等。就像实验步骤文字书写的那样。**
而这些对实验有意义的单个完整动作,**一般需要多个设备的协同**,还依赖于他们的**物理连接关系(管道相连;机械臂可转运)**。
于是 Uni-Lab 实现了抽象的“工作站”,即注册表中的 `workstation` 设备(`ProtocolNode`类)来处理编译、规划操作。以泵骨架组成的自动有机实验室为例,设备管道连接关系如下:
于是 Uni-Lab 实现了抽象的“工作站”,即注册表中的 `workstation` 设备(`WorkstationNode`类)来处理编译、规划操作。以泵骨架组成的自动有机实验室为例,设备管道连接关系如下:
![topology](image/02-topology-and-chemputer-compile/topology.png)

View File

@@ -127,16 +127,16 @@ add_action_files(
```bash
mamba remove --force ros-humble-unilabos-msgs
mamba config set safety_checks disabled # 如果没有提升版本号会触发md5与网络上md5不一致是正常现象因此通过本指令关闭md5检查
mamba install xxx.conda2 --offline
mamba install xxx.conda --offline
```
## 常见问题
**Q: 构建失败怎么办?**
**Q: 构建失败怎么办?**
A: 检查 Actions 日志中的错误信息,通常是语法错误或依赖问题。修复后重新推送代码即可自动触发新的构建。
**Q: 如何测试特定平台?**
**Q: 如何测试特定平台?**
A: 在手动触发构建时,在平台选择中只填写你需要的平台,如 `linux-64` 或 `win-64`。
**Q: 构建包在哪里下载?**
**Q: 构建包在哪里下载?**
A: 在 Actions 页面的构建结果中,查找 "Artifacts" 部分,每个平台都有对应的构建包可供下载。

View File

@@ -0,0 +1,147 @@
# 电池装配工站接入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.

After

Width:  |  Height:  |  Size: 428 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 310 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

View File

@@ -0,0 +1,409 @@
# 物料教程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

@@ -0,0 +1,378 @@
# 工作站基础架构设计文档
## 1. 整体架构图
```mermaid
graph TB
subgraph "工作站基础架构"
WB[WorkstationBase]
WB --> |继承| RPN[ROS2WorkstationNode]
WB --> |组合| WCB[WorkstationCommunicationBase]
WB --> |组合| MMB[MaterialManagementBase]
WB --> |组合| WHS[WorkstationHTTPService]
end
subgraph "通信层实现"
WCB --> |实现| PLC[PLCCommunication]
WCB --> |实现| SER[SerialCommunication]
WCB --> |实现| ETH[EthernetCommunication]
end
subgraph "物料管理实现"
MMB --> |实现| PLR[PyLabRobotMaterialManager]
MMB --> |实现| BIO[BioyondMaterialManager]
MMB --> |实现| SIM[SimpleMaterialManager]
end
subgraph "HTTP服务"
WHS --> |处理| LIMS[LIMS协议报送]
WHS --> |处理| MAT[物料变更报送]
WHS --> |处理| ERR[错误处理报送]
end
subgraph "具体工作站实现"
WB --> |继承| WS1[PLCWorkstation]
WB --> |继承| WS2[ReportingWorkstation]
WB --> |继承| WS3[HybridWorkstation]
end
subgraph "外部系统"
EXT1[PLC设备] --> |通信| PLC
EXT2[外部工作站] --> |HTTP报送| WHS
EXT3[LIMS系统] --> |HTTP报送| WHS
EXT4[Bioyond物料系统] --> |查询| BIO
end
```
## 2. 类关系图
```mermaid
classDiagram
class WorkstationBase {
<<abstract>>
+device_id: str
+communication: WorkstationCommunicationBase
+material_management: MaterialManagementBase
+http_service: WorkstationHTTPService
+workflow_status: WorkflowStatus
+supported_workflows: Dict
+_create_communication_module()*
+_create_material_management_module()*
+_register_supported_workflows()*
+process_step_finish_report()
+process_sample_finish_report()
+process_order_finish_report()
+process_material_change_report()
+handle_external_error()
+start_workflow()
+stop_workflow()
+get_workflow_status()
+get_device_status()
}
class ROS2WorkstationNode {
+sub_devices: Dict
+protocol_names: List
+execute_single_action()
+create_ros_action_server()
+initialize_device()
}
class WorkstationCommunicationBase {
<<abstract>>
+config: CommunicationConfig
+is_connected: bool
+connect()
+disconnect()
+start_workflow()*
+stop_workflow()*
+get_device_status()*
+write_register()
+read_register()
}
class MaterialManagementBase {
<<abstract>>
+device_id: str
+deck_config: Dict
+resource_tracker: DeviceNodeResourceTracker
+plr_deck: Deck
+find_materials_by_type()
+update_material_location()
+convert_to_unilab_format()
+_create_resource_by_type()*
}
class WorkstationHTTPService {
+workstation_instance: WorkstationBase
+host: str
+port: int
+start()
+stop()
+_handle_step_finish_report()
+_handle_material_change_report()
}
class PLCWorkstation {
+plc_config: Dict
+modbus_client: ModbusTCPClient
+_create_communication_module()
+_create_material_management_module()
+_register_supported_workflows()
}
class ReportingWorkstation {
+report_handlers: Dict
+_create_communication_module()
+_create_material_management_module()
+_register_supported_workflows()
}
WorkstationBase --|> ROS2WorkstationNode
WorkstationBase *-- WorkstationCommunicationBase
WorkstationBase *-- MaterialManagementBase
WorkstationBase *-- WorkstationHTTPService
PLCWorkstation --|> WorkstationBase
ReportingWorkstation --|> WorkstationBase
WorkstationCommunicationBase <|-- PLCCommunication
WorkstationCommunicationBase <|-- DummyCommunication
MaterialManagementBase <|-- PyLabRobotMaterialManager
MaterialManagementBase <|-- SimpleMaterialManager
```
## 3. 工作站启动时序图
```mermaid
sequenceDiagram
participant APP as Application
participant WS as WorkstationBase
participant COMM as CommunicationModule
participant MAT as MaterialManager
participant HTTP as HTTPService
participant ROS as ROS2WorkstationNode
APP->>WS: 创建工作站实例
WS->>ROS: 初始化ROS2WorkstationNode
ROS->>ROS: 初始化子设备
ROS->>ROS: 设置硬件接口代理
WS->>COMM: _create_communication_module()
COMM->>COMM: 初始化通信配置
COMM->>COMM: 建立PLC/串口连接
COMM-->>WS: 返回通信模块实例
WS->>MAT: _create_material_management_module()
MAT->>MAT: 创建PyLabRobot Deck
MAT->>MAT: 初始化物料资源
MAT->>MAT: 注册到ResourceTracker
MAT-->>WS: 返回物料管理实例
WS->>WS: _register_supported_workflows()
WS->>WS: _create_workstation_services()
WS->>HTTP: _start_http_service()
HTTP->>HTTP: 创建HTTP服务器
HTTP->>HTTP: 启动监听线程
HTTP-->>WS: HTTP服务启动完成
WS-->>APP: 工作站初始化完成
```
## 4. 工作流执行时序图
```mermaid
sequenceDiagram
participant EXT as ExternalSystem
participant WS as WorkstationBase
participant COMM as CommunicationModule
participant MAT as MaterialManager
participant ROS as ROS2WorkstationNode
participant DEV as SubDevice
EXT->>WS: start_workflow(type, params)
WS->>WS: 验证工作流类型
WS->>COMM: start_workflow(type, params)
COMM->>COMM: 发送启动命令到PLC
COMM-->>WS: 启动成功
WS->>WS: 更新workflow_status = RUNNING
loop 工作流步骤执行
WS->>ROS: execute_single_action(device_id, action, params)
ROS->>DEV: 发送ROS Action请求
DEV->>DEV: 执行设备动作
DEV-->>ROS: 返回执行结果
ROS-->>WS: 返回动作结果
WS->>MAT: update_material_location(material_id, location)
MAT->>MAT: 更新PyLabRobot资源状态
MAT-->>WS: 更新完成
end
WS->>COMM: get_workflow_status()
COMM->>COMM: 查询PLC状态寄存器
COMM-->>WS: 返回状态信息
WS->>WS: 更新workflow_status = COMPLETED
WS-->>EXT: 工作流执行完成
```
## 5. HTTP报送处理时序图
```mermaid
sequenceDiagram
participant EXT as ExternalWorkstation
participant HTTP as HTTPService
participant WS as WorkstationBase
participant MAT as MaterialManager
participant DB as DataStorage
EXT->>HTTP: POST /report/step_finish
HTTP->>HTTP: 解析请求数据
HTTP->>HTTP: 验证LIMS协议字段
HTTP->>WS: process_step_finish_report(request)
WS->>WS: 增加接收计数
WS->>WS: 记录步骤完成事件
WS->>MAT: 更新相关物料状态
MAT->>MAT: 更新PyLabRobot资源
MAT-->>WS: 更新完成
WS->>DB: 保存报送记录
DB-->>WS: 保存完成
WS-->>HTTP: 返回处理结果
HTTP->>HTTP: 构造HTTP响应
HTTP-->>EXT: 200 OK + acknowledgment_id
Note over EXT,DB: 类似处理sample_finish, order_finish, material_change等报送
```
## 6. 错误处理时序图
```mermaid
sequenceDiagram
participant DEV as Device
participant WS as WorkstationBase
participant COMM as CommunicationModule
participant HTTP as HTTPService
participant EXT as ExternalSystem
DEV->>WS: 设备错误事件
WS->>WS: handle_external_error(error_data)
WS->>WS: 记录错误历史
alt 关键错误
WS->>COMM: emergency_stop()
COMM->>COMM: 发送紧急停止命令
WS->>WS: 更新workflow_status = ERROR
else 普通错误
WS->>WS: 标记动作失败
WS->>WS: 触发重试逻辑
end
WS->>HTTP: 记录错误报送
HTTP->>EXT: 主动通知错误状态
WS-->>DEV: 错误处理完成
```
## 7. 典型工作站实现示例
### 7.1 PLC工作站实现
```python
class PLCWorkstation(WorkstationBase):
def _create_communication_module(self):
return PLCCommunication(self.communication_config)
def _create_material_management_module(self):
return PyLabRobotMaterialManager(
self.device_id,
self.deck_config,
self.resource_tracker
)
def _register_supported_workflows(self):
self.supported_workflows = {
"battery_assembly": WorkflowInfo(...),
"quality_check": WorkflowInfo(...)
}
```
### 7.2 报送接收工作站实现
```python
class ReportingWorkstation(WorkstationBase):
def _create_communication_module(self):
return DummyCommunication(self.communication_config)
def _create_material_management_module(self):
return SimpleMaterialManager(
self.device_id,
self.deck_config,
self.resource_tracker
)
def _register_supported_workflows(self):
self.supported_workflows = {
"data_collection": WorkflowInfo(...),
"report_processing": WorkflowInfo(...)
}
```
## 8. 核心接口说明
### 8.1 必须实现的抽象方法
- `_create_communication_module()`: 创建通信模块
- `_create_material_management_module()`: 创建物料管理模块
- `_register_supported_workflows()`: 注册支持的工作流
### 8.2 可重写的报送处理方法
- `process_step_finish_report()`: 步骤完成处理
- `process_sample_finish_report()`: 样本完成处理
- `process_order_finish_report()`: 订单完成处理
- `process_material_change_report()`: 物料变更处理
- `handle_external_error()`: 错误处理
### 8.3 工作流控制接口
- `start_workflow()`: 启动工作流
- `stop_workflow()`: 停止工作流
- `get_workflow_status()`: 获取状态
## 9. 配置参数说明
```python
workstation_config = {
"communication_config": {
"protocol": "modbus_tcp",
"host": "192.168.1.100",
"port": 502
},
"deck_config": {
"size_x": 1000.0,
"size_y": 1000.0,
"size_z": 500.0
},
"http_service_config": {
"enabled": True,
"host": "127.0.0.1",
"port": 8081
},
"communication_interfaces": {
"logical_device_1": CommunicationInterface(...)
}
}
```
这个架构设计支持:
1. **灵活的通信方式**: 通过CommunicationBase支持PLC、串口、以太网等
2. **多样的物料管理**: 支持PyLabRobot、Bioyond、简单物料系统
3. **统一的HTTP报送**: 基于LIMS协议的标准化报送接口
4. **完整的工作流控制**: 支持动态和静态工作流
5. **强大的错误处理**: 多层次的错误处理和恢复机制

View File

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

13
docs/requirements.txt Normal file
View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 526 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 327 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 275 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 186 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 581 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

View File

@@ -14,13 +14,30 @@ mamba create -n unilab uni-lab::unilabos -c robostack-staging -c conda-forge
```shell
# 配置好conda环境后克隆仓库
git clone https://github.com/dptech-corp/Uni-Lab-OS.git
git clone https://github.com/dptech-corp/Uni-Lab-OS.git -b dev
cd Uni-Lab-OS
# 安装 Uni-Lab-OS
pip install .
pip install -e .
```
3. **启动 Uni-Lab 系统**
3. **安装开发版 ros-humble-unilabos-msgs**
**卸载老版本:**
```shell
conda activate unilab
conda remove --force ros-humble-unilabos-msgs
```
有时相同的安装包版本会由于dev构建得到的md5不一样触发安全检查可输入 `config set safety_checks disabled` 来关闭安全检查。
**安装新版本:**
访问 https://github.com/dptech-corp/Uni-Lab-OS/actions/workflows/multi-platform-build.yml 选择最新的构建,下载对应平台的压缩包(仅解压一次,得到.conda文件使用如下指令
```shell
conda activate base
conda install ros-humble-unilabos-msgs-<version>-<platform>.conda --offline -n <环境名>
```
4. **启动 Uni-Lab 系统**
请参见{doc}`启动样例 <../boot_examples/index>`或{doc}`启动指南 <launch>`了解详细的启动方法。

View File

@@ -245,3 +245,78 @@ 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

@@ -0,0 +1,197 @@
# 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,22 +0,0 @@
<?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.5
version: 0.10.7
source:
path: ../../unilabos_msgs
target_directory: src

View File

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

190
scripts/create_readme.py Normal file
View File

@@ -0,0 +1,190 @@
#!/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

@@ -0,0 +1,148 @@
#!/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()

203
scripts/install_unilab.bat Normal file
View File

@@ -0,0 +1,203 @@
@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

139
scripts/install_unilab.sh Executable file
View File

@@ -0,0 +1,139 @@
#!/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

@@ -0,0 +1,153 @@
#!/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
Or in the conda environment:
conda activate unilab
python verify_installation.py
"""
import sys
import os
# 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")
sys.stderr.reconfigure(encoding="utf-8", errors="replace")
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) -> 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."""
print("=" * 60)
print("UniLabOS Installation Verification")
print("=" * 60)
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 without auto-install (verification only)
# Set show_details=False to suppress detailed Chinese output that may cause encoding issues
env_check_passed = check_environment(auto_install=False, 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")
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

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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,60 @@
{
"nodes": [
{
"id": "dispensing_station_bioyond",
"name": "dispensing_station_bioyond",
"children": [
"Bioyond_Dispensing_Deck"
],
"parent": null,
"type": "device",
"class": "dispensing_station.bioyond",
"config": {
"config": {
"api_key": "DE9BDDA0",
"api_host": "http://192.168.1.200:44388"
},
"deck": {
"data": {
"_resource_child_name": "Bioyond_Dispensing_Deck",
"_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": {}
},
{
"id": "Bioyond_Dispensing_Deck",
"name": "Bioyond_Dispensing_Deck",
"sample_id": null,
"children": [],
"parent": "dispensing_station_bioyond",
"type": "deck",
"class": "BIOYOND_PolymerPreparationStation_Deck",
"position": {
"x": 0,
"y": 0,
"z": 0
},
"config": {
"type": "BIOYOND_PolymerPreparationStation_Deck",
"setup": true,
"rotation": {
"x": 0,
"y": 0,
"z": 0,
"type": "Rotation"
}
},
"data": {}
}
]
}

View File

@@ -0,0 +1,148 @@
{
"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": true,
"setup": false,
"debug": true,
"simulator": true,
"matrix_id": "71593"

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,69 @@
{
"nodes": [
{
"id": "reaction_station_bioyond",
"name": "reaction_station_bioyond",
"parent": null,
"children": [
"Bioyond_Deck"
],
"type": "device",
"class": "reaction_station.bioyond",
"config": {
"bioyond_config": {
"api_key": "DE9BDDA0",
"api_host": "http://192.168.1.200:44402",
"workflow_mappings": {
"reactor_taken_out": "3a16081e-4788-ca37-eff4-ceed8d7019d1",
"reactor_taken_in": "3a160df6-76b3-0957-9eb0-cb496d5721c6",
"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)": "3a160824-0665-01ed-285a-51ef817a9046",
"Liquid_feeding_beaker": "3a16087e-124f-8ddb-8ec1-c2dff09ca784",
"Drip_back": "3a162cf9-6aac-565a-ddd7-682ba1796a4a"
},
"material_type_mappings": {
"烧杯": "BIOYOND_PolymerStation_1FlaskCarrier",
"试剂瓶": "BIOYOND_PolymerStation_1BottleCarrier",
"样品板": "BIOYOND_PolymerStation_6VialCarrier"
}
},
"deck": {
"data": {
"_resource_child_name": "Bioyond_Deck",
"_resource_type": "unilabos.resources.bioyond.decks:BIOYOND_PolymerReactionStation_Deck"
}
},
"protocol_type": []
},
"data": {}
},
{
"id": "Bioyond_Deck",
"name": "Bioyond_Deck",
"sample_id": null,
"children": [
],
"parent": "reaction_station_bioyond",
"type": "deck",
"class": "BIOYOND_PolymerReactionStation_Deck",
"position": {
"x": 0,
"y": 0,
"z": 0
},
"config": {
"type": "BIOYOND_PolymerReactionStation_Deck",
"setup": true,
"rotation": {
"x": 0,
"y": 0,
"z": 0,
"type": "Rotation"
}
},
"data": {}
}
]
}

View File

@@ -0,0 +1,69 @@
{
"nodes": [
{
"id": "reaction_station_bioyond",
"name": "reaction_station_bioyond",
"parent": null,
"children": [
"Bioyond_Deck"
],
"type": "device",
"class": "workstation.bioyond",
"config": {
"bioyond_config": {
"api_key": "DE9BDDA0",
"api_host": "http://192.168.1.200:44388",
"workflow_mappings": {
"reactor_taken_out": "3a16081e-4788-ca37-eff4-ceed8d7019d1",
"reactor_taken_in": "3a160df6-76b3-0957-9eb0-cb496d5721c6",
"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)": "3a160824-0665-01ed-285a-51ef817a9046",
"Liquid_feeding_beaker": "3a16087e-124f-8ddb-8ec1-c2dff09ca784",
"Drip_back": "3a162cf9-6aac-565a-ddd7-682ba1796a4a"
},
"material_type_mappings": {
"烧杯": "BIOYOND_PolymerStation_1FlaskCarrier",
"试剂瓶": "BIOYOND_PolymerStation_1BottleCarrier",
"样品板": "BIOYOND_PolymerStation_6VialCarrier"
}
},
"deck": {
"data": {
"_resource_child_name": "Bioyond_Deck",
"_resource_type": "unilabos.resources.bioyond.decks:BIOYOND_PolymerReactionStation_Deck"
}
},
"protocol_type": []
},
"data": {}
},
{
"id": "Bioyond_Deck",
"name": "Bioyond_Deck",
"sample_id": null,
"children": [
],
"parent": "reaction_station_bioyond",
"type": "deck",
"class": "BIOYOND_PolymerReactionStation_Deck",
"position": {
"x": 0,
"y": 0,
"z": 0
},
"config": {
"type": "BIOYOND_PolymerReactionStation_Deck",
"setup": true,
"rotation": {
"x": 0,
"y": 0,
"z": 0,
"type": "Rotation"
}
},
"data": {}
}
]
}

View File

@@ -0,0 +1,394 @@
{
"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

@@ -0,0 +1,181 @@
[
{
"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

@@ -0,0 +1,216 @@
[
{
"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

@@ -0,0 +1,193 @@
[
{
"id": "3a1c67a9-aed7-b94d-9e24-bfdf10c8baa9",
"typeName": "烧杯",
"code": "0006-00160",
"barCode": "",
"name": "ODA",
"quantity": 120000.00000000000000000000000,
"lockQuantity": 695374.00000000000000000000000,
"unit": "微升",
"status": 1,
"isUse": false,
"locations": [
{
"id": "3a14aa17-0d49-11d7-a6e1-f236b3e5e5a3",
"whid": "3a14aa17-0d49-dce4-486e-4b5c85c8b366",
"whName": "堆栈1",
"code": "0001-0001",
"x": 1,
"y": 1,
"z": 1,
"quantity": 0
}
],
"detail": []
},
{
"id": "3a1c67a9-aed9-1ade-5fe1-cc04b24b171c",
"typeName": "烧杯",
"code": "0006-00161",
"barCode": "",
"name": "MPDA",
"quantity": 120000.00000000000000000000000,
"lockQuantity": 681618.00000000000000000000000,
"unit": "",
"status": 1,
"isUse": false,
"locations": [
{
"id": "3a14aa17-0d49-4bc5-8836-517b75473f5f",
"whid": "3a14aa17-0d49-dce4-486e-4b5c85c8b366",
"whName": "堆栈1",
"code": "0001-0002",
"x": 1,
"y": 2,
"z": 1,
"quantity": 0
}
],
"detail": []
},
{
"id": "3a1c67a9-aed9-2864-6783-2cee4e701ba6",
"typeName": "试剂瓶",
"code": "0004-00041",
"barCode": "",
"name": "NMP",
"quantity": 300000.00000000000000000000000,
"lockQuantity": 380000.00000000000000000000000,
"unit": "微升",
"status": 1,
"isUse": false,
"locations": [
{
"id": "3a14aa3b-9fab-adac-7b9c-e1ee446b51d5",
"whid": "3a14aa3b-9fab-9d8e-d1a7-828f01f51f0c",
"whName": "站内试剂存放堆栈",
"code": "0003-0001",
"x": 1,
"y": 1,
"z": 1,
"quantity": 0
}
],
"detail": []
},
{
"id": "3a1c67a9-aed9-32c7-5809-3ba1b8db1aa1",
"typeName": "试剂瓶",
"code": "0004-00042",
"barCode": "",
"name": "PGME",
"quantity": 300000.00000000000000000000000,
"lockQuantity": 337892.00000000000000000000000,
"unit": "",
"status": 1,
"isUse": false,
"locations": [
{
"id": "3a14aa3b-9fab-ca72-febc-b7c304476c78",
"whid": "3a14aa3b-9fab-9d8e-d1a7-828f01f51f0c",
"whName": "站内试剂存放堆栈",
"code": "0003-0002",
"x": 1,
"y": 2,
"z": 1,
"quantity": 0
}
],
"detail": []
},
{
"id": "3a1c68c8-0574-d748-725e-97a2e549f085",
"typeName": "样品板",
"code": "0001-00004",
"barCode": "",
"name": "0917",
"quantity": 1.0000000000000000000000000000,
"lockQuantity": 4.0000000000000000000000000000,
"unit": "块",
"status": 1,
"isUse": false,
"locations": [
{
"id": "3a14aa17-0d49-f49c-6b66-b27f185a3b32",
"whid": "3a14aa17-0d49-dce4-486e-4b5c85c8b366",
"whName": "堆栈1",
"code": "0001-0009",
"x": 2,
"y": 1,
"z": 1,
"quantity": 0
}
],
"detail": [
{
"id": "3a1c68c8-0574-69a1-9858-4637e0193451",
"detailMaterialId": "3a1c68c8-0574-3630-bd42-bbf3623c5208",
"code": null,
"name": "SIDA",
"quantity": "300000",
"lockQuantity": "4",
"unit": "微升",
"x": 1,
"y": 2,
"z": 1,
"associateId": null
},
{
"id": "3a1c68c8-0574-8d51-3191-a31f5be421e5",
"detailMaterialId": "3a1c68c8-0574-3b20-9ad7-90755f123d53",
"code": null,
"name": "BTDA-2",
"quantity": "300000",
"lockQuantity": "4",
"unit": "微升",
"x": 2,
"y": 2,
"z": 1,
"associateId": null
},
{
"id": "3a1c68c8-0574-da80-735b-53ae2197a360",
"detailMaterialId": "3a1c68c8-0574-f2e4-33b3-90d813567939",
"code": null,
"name": "BTDA-DD",
"quantity": "300000",
"lockQuantity": "28",
"unit": "微升",
"x": 1,
"y": 1,
"z": 1,
"associateId": null
},
{
"id": "3a1c68c8-0574-e717-1b1b-99891f875455",
"detailMaterialId": "3a1c68c8-0574-a0ef-e636-68cdc98960e2",
"code": null,
"name": "BTDA-3",
"quantity": "300000",
"lockQuantity": "4",
"unit": "微升",
"x": 2,
"y": 3,
"z": 1,
"associateId": null
},
{
"id": "3a1c68c8-0574-e9bd-6cca-5e261b4f89cb",
"detailMaterialId": "3a1c68c8-0574-9d11-5115-283e8e5510b1",
"code": null,
"name": "BTDA-1",
"quantity": "300000",
"lockQuantity": "4",
"unit": "微升",
"x": 2,
"y": 1,
"z": 1,
"associateId": null
}
]
}
]

View File

@@ -0,0 +1,48 @@
import pytest
from unilabos.resources.bioyond.bottle_carriers import BIOYOND_Electrolyte_6VialCarrier, BIOYOND_Electrolyte_1BottleCarrier
from unilabos.resources.bioyond.bottles import BIOYOND_PolymerStation_Solid_Vial, BIOYOND_PolymerStation_Solution_Beaker, BIOYOND_PolymerStation_Reagent_Bottle
def test_bottle_carrier() -> "BottleCarrier":
print("创建载架...")
# 创建6瓶载架
bottle_carrier = BIOYOND_Electrolyte_6VialCarrier("powder_carrier_01")
print(f"6瓶载架: {bottle_carrier.name}, 位置数: {len(bottle_carrier.sites)}")
# 创建1烧杯载架
beaker_carrier = BIOYOND_Electrolyte_1BottleCarrier("solution_carrier_01")
print(f"1烧杯载架: {beaker_carrier.name}, 位置数: {len(beaker_carrier.sites)}")
# 创建瓶子和烧杯
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")
print(f"溶液烧杯: {solution_beaker.name} - {solution_beaker.diameter}mm x {solution_beaker.height}mm, {solution_beaker.max_volume}μL")
print(f"试剂瓶: {reagent_bottle.name} - {reagent_bottle.diameter}mm x {reagent_bottle.height}mm, {reagent_bottle.max_volume}μL")
# 测试放置容器
print(f"\n测试放置容器...")
# 通过载架的索引操作来放置容器
# bottle_carrier[0] = powder_bottle # 放置粉末瓶到第一个位置
print(f"粉末瓶已放置到6瓶载架的位置 0")
# beaker_carrier[0] = solution_beaker # 放置烧杯到第一个位置
print(f"溶液烧杯已放置到1烧杯载架的位置 0")
# 验证放置结果
print(f"\n验证放置结果:")
bottle_at_0 = bottle_carrier[0].resource
beaker_at_0 = beaker_carrier[0].resource
if bottle_at_0:
print(f"位置 0 的瓶子: {bottle_at_0.name}")
if beaker_at_0:
print(f"位置 0 的烧杯: {beaker_at_0.name}")
print("\n载架设置完成!")

View File

@@ -0,0 +1,76 @@
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
from unilabos.resources.bioyond.decks import BIOYOND_PolymerReactionStation_Deck
lab_registry.setup()
type_mapping = {
"烧杯": "BIOYOND_PolymerStation_1FlaskCarrier",
"试剂瓶": "BIOYOND_PolymerStation_1BottleCarrier",
"样品板": "BIOYOND_PolymerStation_6StockCarrier",
"分装板": "BIOYOND_PolymerStation_6VialCarrier",
"样品瓶": "BIOYOND_PolymerStation_Solid_Stock",
"90%分装小瓶": "BIOYOND_PolymerStation_Solid_Vial",
"10%分装小瓶": "BIOYOND_PolymerStation_Liquid_Vial",
}
type_uuid_mapping = {
"烧杯": "",
"试剂瓶": "",
"样品板": "",
"分装板": "3a14196e-5dfe-6e21-0c79-fe2036d052c4",
"样品瓶": "3a14196a-cf7d-8aea-48d8-b9662c7dba94",
"90%分装小瓶": "3a14196c-cdcf-088d-dc7d-5cf38f0ad9ea",
"10%分装小瓶": "3a14196c-76be-2279-4e22-7310d69aed68",
}
@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_bioyond_to_plr(materials_fixture, request) -> list[dict]:
materials = request.getfixturevalue(materials_fixture)
deck = BIOYOND_PolymerReactionStation_Deck("test_deck")
output = resource_bioyond_to_plr(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

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

View File

@@ -1,14 +1,15 @@
import threading
from unilabos.ros.nodes.resource_tracker import ResourceTreeSet
from unilabos.utils import logger
# 根据选择的 backend 启动相应的功能
def start_backend(
backend: str,
devices_config: dict = {},
resources_config: list = [],
resources_edge_config: list = [],
devices_config: ResourceTreeSet,
resources_config: ResourceTreeSet,
resources_edge_config: list[dict] = [],
graph=None,
controllers_config: dict = {},
bridges=[],

View File

@@ -6,10 +6,12 @@ import signal
import sys
import threading
import time
from copy import deepcopy
from typing import Dict, Any, List
import networkx as nx
import yaml
from unilabos.ros.nodes.resource_tracker import ResourceTreeSet, ResourceDict
# 首先添加项目根目录到路径
current_dir = os.path.dirname(os.path.abspath(__file__))
@@ -43,7 +45,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
@@ -225,6 +227,15 @@ 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
@@ -236,13 +247,6 @@ 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)
@@ -257,8 +261,6 @@ 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
@@ -278,8 +280,11 @@ 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"
@@ -287,57 +292,60 @@ def main():
os._exit(1)
else:
print_status("联网获取设备加载文件成功", "info")
graph, data = read_node_link_json(request_startup_json)
graph, resource_tree_set, resource_links = read_node_link_json(request_startup_json)
else:
file_path = args_dict["graph"]
if file_path.endswith(".json"):
graph, data = read_node_link_json(file_path)
graph, resource_tree_set, resource_links = read_node_link_json(file_path)
else:
graph, data = read_graphml(file_path)
graph, resource_tree_set, resource_links = read_graphml(file_path)
import unilabos.resources.graphio as graph_res
graph_res.physical_setup_graph = graph
resource_edge_info = modify_to_backend_format(data["links"])
resource_edge_info = modify_to_backend_format(resource_links)
materials = lab_registry.obtain_registry_resource_info()
materials.extend(lab_registry.obtain_registry_device_info())
materials = {k["id"]: k for k in materials}
nodes = {k["id"]: k for k in data["nodes"]}
# 从 ResourceTreeSet 中获取节点信息
nodes = {node.res_content.id: node.res_content for node in resource_tree_set.all_nodes}
edge_info = len(resource_edge_info)
for ind, i in enumerate(resource_edge_info[::-1]):
source_node = nodes[i["source"]]
target_node = nodes[i["target"]]
source_node: ResourceDict = nodes[i["source"]]
target_node: ResourceDict = nodes[i["target"]]
source_handle = i["sourceHandle"]
target_handle = i["targetHandle"]
source_handler_keys = [
h["handler_key"] for h in materials[source_node["class"]]["handles"] if h["io_type"] == "source"
h["handler_key"] for h in materials[source_node.klass]["handles"] if h["io_type"] == "source"
]
target_handler_keys = [
h["handler_key"] for h in materials[target_node["class"]]["handles"] if h["io_type"] == "target"
h["handler_key"] for h in materials[target_node.klass]["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
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
# 如果从远端获取了物料信息,则与本地物料进行同步
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")
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")
# 使用 ResourceTreeSet 代替 list
args_dict["resources_config"] = resource_tree_set
args_dict["devices_config"] = resource_tree_set
args_dict["graph"] = graph_res.physical_setup_graph
if BasicConfig.upload_registry:
# 设备注册到服务端 - 需要 ak 和 sk
@@ -351,9 +359,7 @@ 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"))
@@ -383,13 +389,16 @@ def main():
# 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, args_dict["resources_config"], enable_rviz=enable_rviz
devices_and_resources,
[n.res_content for n in args_dict["resources_config"].all_nodes], # type: ignore # FIXME
enable_rviz=enable_rviz,
)
args_dict["resources_mesh_config"] = resource_visualization.resource_model
start_backend(**args_dict)

View File

@@ -1,11 +1,6 @@
import argparse
import json
import time
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

View File

@@ -9,6 +9,7 @@ import os
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
@@ -25,6 +26,7 @@ class HTTPClient:
remote_addr: 远程服务器地址,如果不提供则从配置中获取
auth: 授权信息
"""
self.initialized = False
self.remote_addr = remote_addr or HTTPConfig.remote_addr
if auth is not None:
self.auth = auth
@@ -45,7 +47,7 @@ class HTTPClient:
Response: API响应对象
"""
response = requests.post(
f"{self.remote_addr}/lab/material/edge",
f"{self.remote_addr}/edge/material/edge",
json={
"edges": resources,
},
@@ -60,22 +62,108 @@ 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}
"""
# 从序列化数据中提取所有节点的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=100,
)
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=100,
)
# 处理响应构建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}
"""
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,
)
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:
"""
添加资源
Args:
resources: 要添加的资源列表
database_process_later: 后台处理资源
Returns:
Response: API响应对象
"""
response = requests.post(
f"{self.remote_addr}/lab/material",
json={"nodes": resources},
headers={"Authorization": f"Lab {self.auth}"},
timeout=100,
)
if not self.initialized:
self.initialized = True
info(f"首次添加资源,当前远程地址: {self.remote_addr}")
response = requests.post(
f"{self.remote_addr}/lab/material",
json={"nodes": resources},
headers={"Authorization": f"Lab {self.auth}"},
timeout=100,
)
else:
response = requests.put(
f"{self.remote_addr}/lab/material",
json={"nodes": resources},
headers={"Authorization": f"Lab {self.auth}"},
timeout=100,
)
if response.status_code == 200:
res = response.json()
if "code" in res and res["code"] != 0:
@@ -131,14 +219,29 @@ class HTTPClient:
Returns:
Response: API响应对象
"""
return self.resource_add(resources)
response = requests.patch(
f"{self.remote_addr}/lab/resource/batch_update/?edge_format=1",
json=resources,
headers={"Authorization": f"Lab {self.auth}"},
timeout=100,
)
return response
if not self.initialized:
self.initialized = True
info(f"首次添加资源,当前远程地址: {self.remote_addr}")
response = requests.post(
f"{self.remote_addr}/lab/material",
json={"nodes": resources},
headers={"Authorization": f"Lab {self.auth}"},
timeout=100,
)
else:
response = requests.put(
f"{self.remote_addr}/lab/material",
json={"nodes": resources},
headers={"Authorization": f"Lab {self.auth}"},
timeout=100,
)
if response.status_code == 200:
res = response.json()
if "code" in res and res["code"] != 0:
logger.error(f"添加物料失败: {response.text}")
if response.status_code != 200:
logger.error(f"添加物料失败: {response.text}")
return response.json()
def upload_file(self, file_path: str, scene: str = "models") -> requests.Response:
"""
@@ -195,7 +298,7 @@ class HTTPClient:
Response: API响应对象
"""
response = requests.get(
f"{self.remote_addr}/lab/resource/graph_info/",
f"{self.remote_addr}/edge/material/download",
headers={"Authorization": f"Lab {self.auth}"},
timeout=(3, 30),
)

View File

@@ -19,9 +19,12 @@ import websockets
import ssl as ssl_module
from queue import Queue, Empty
from dataclasses import dataclass, field
from typing import Optional, Dict, Any, Callable, List, Set
from typing import Optional, Dict, Any, List
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
@@ -96,6 +99,14 @@ 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的任务队列"""
@@ -543,7 +554,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}")
@@ -556,8 +567,12 @@ 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 == "":
return
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")
else:
logger.debug(f"[MessageProcessor] Unknown message type: {message_type}")
@@ -574,6 +589,7 @@ 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", "")
@@ -760,6 +776,92 @@ 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
):
@@ -1008,6 +1110,8 @@ 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)
@@ -1093,7 +1197,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

@@ -155,7 +155,7 @@ def generate_add_protocol(
"device_id": stirrer_id,
"action_name": "start_stir",
"action_kwargs": {
"vessel": vessel_id, # 🔧 使用 vessel_id
"vessel": {"id": vessel_id}, # 🔧 使用 vessel_id
"stir_speed": stir_speed,
"purpose": f"准备添加固体 {reagent}"
}
@@ -232,7 +232,7 @@ def generate_add_protocol(
"device_id": stirrer_id,
"action_name": "start_stir",
"action_kwargs": {
"vessel": vessel_id, # 🔧 使用 vessel_id
"vessel": {"id": vessel_id}, # 🔧 使用 vessel_id
"stir_speed": stir_speed,
"purpose": f"准备添加液体 {reagent}"
}

View File

@@ -325,7 +325,7 @@ def generate_adjust_ph_protocol(
"device_id": stirrer_id,
"action_name": "start_stir",
"action_kwargs": {
"vessel": vessel_id,
"vessel": {"id": vessel_id},
"stir_speed": stir_speed,
"purpose": f"pH调节: 启动搅拌,准备添加 {reagent}"
}

View File

@@ -156,7 +156,7 @@ def generate_centrifuge_protocol(
"device_id": centrifuge_id,
"action_name": "centrifuge",
"action_kwargs": {
"vessel": centrifuge_vessel,
"vessel": {"id": centrifuge_vessel},
"speed": speed,
"time": time,
"temp": temp

View File

@@ -143,7 +143,7 @@ def generate_clean_vessel_protocol(
"device_id": heatchill_id,
"action_name": "heat_chill_start",
"action_kwargs": {
"vessel": vessel_id, # 🔧 使用 vessel_id
"vessel": {"id": vessel_id}, # 🔧 使用 vessel_id
"temp": temp,
"purpose": f"cleaning with {solvent}"
}
@@ -295,7 +295,7 @@ def generate_clean_vessel_protocol(
"device_id": heatchill_id,
"action_name": "heat_chill_stop",
"action_kwargs": {
"vessel": vessel_id # 🔧 使用 vessel_id
"vessel": {"id": vessel_id}, # 🔧 使用 vessel_id
}
}
action_sequence.append(heatchill_stop_action)

View File

@@ -563,7 +563,7 @@ def generate_dissolve_protocol(
"device_id": heatchill_id,
"action_name": "heat_chill_start",
"action_kwargs": {
"vessel": vessel_id,
"vessel": {"id": vessel_id},
"temp": final_temp,
"purpose": f"溶解准备 - {event}" if event else "溶解准备"
}
@@ -587,7 +587,7 @@ def generate_dissolve_protocol(
"device_id": stirrer_id,
"action_name": "start_stir",
"action_kwargs": {
"vessel": vessel_id,
"vessel": {"id": vessel_id},
"stir_speed": stir_speed,
"purpose": f"溶解搅拌 - {event}" if event else "溶解搅拌"
}
@@ -612,7 +612,7 @@ def generate_dissolve_protocol(
# 固体加样
add_kwargs = {
"vessel": vessel_id,
"vessel": {"id": vessel_id},
"reagent": reagent or amount or "solid reagent",
"purpose": f"溶解固体试剂 - {event}" if event else "溶解固体试剂",
"event": event
@@ -758,7 +758,7 @@ def generate_dissolve_protocol(
"device_id": heatchill_id,
"action_name": "heat_chill",
"action_kwargs": {
"vessel": vessel_id,
"vessel": {"id": vessel_id},
"temp": final_temp,
"time": final_time,
"stir": True,
@@ -776,7 +776,7 @@ def generate_dissolve_protocol(
"device_id": stirrer_id,
"action_name": "stir",
"action_kwargs": {
"vessel": vessel_id,
"vessel": {"id": vessel_id},
"stir_time": final_time,
"stir_speed": stir_speed,
"settling_time": 0,
@@ -802,7 +802,7 @@ def generate_dissolve_protocol(
"device_id": heatchill_id,
"action_name": "heat_chill_stop",
"action_kwargs": {
"vessel": vessel_id
"vessel": {"id": vessel_id},
}
}
action_sequence.append(stop_action)

View File

@@ -167,7 +167,7 @@ def generate_dry_protocol(
"device_id": heater_id,
"action_name": "heat_chill_start",
"action_kwargs": {
"vessel": vessel_id, # 🔧 使用 vessel_id
"vessel": {"id": vessel_id}, # 🔧 使用 vessel_id
"temp": dry_temp,
"purpose": f"干燥 {compound or '化合物'}"
}
@@ -191,7 +191,7 @@ def generate_dry_protocol(
"device_id": heater_id,
"action_name": "heat_chill",
"action_kwargs": {
"vessel": vessel_id, # 🔧 使用 vessel_id
"vessel": {"id": vessel_id}, # 🔧 使用 vessel_id
"temp": dry_temp,
"time": simulation_time,
"purpose": f"干燥 {compound or '化合物'},保持温度 {dry_temp}°C"
@@ -251,7 +251,7 @@ def generate_dry_protocol(
"device_id": heater_id,
"action_name": "heat_chill_stop",
"action_kwargs": {
"vessel": vessel_id, # 🔧 使用 vessel_id
"vessel": {"id": vessel_id}, # 🔧 使用 vessel_id
"purpose": f"干燥完成,停止加热"
}
})

View File

@@ -452,7 +452,7 @@ def generate_evacuateandrefill_protocol(
"device_id": stirrer_id,
"action_name": "start_stir",
"action_kwargs": {
"vessel": vessel_id, # 🔧 使用 vessel_id
"vessel": {"id": vessel_id}, # 🔧 使用 vessel_id
"stir_speed": STIR_SPEED,
"purpose": "抽真空充气前预搅拌"
}
@@ -685,7 +685,7 @@ def generate_evacuateandrefill_protocol(
action_sequence.append({
"device_id": stirrer_id,
"action_name": "stop_stir",
"action_kwargs": {"vessel": vessel_id} # 🔧 使用 vessel_id
"action_kwargs": {"vessel": {"id": vessel_id},} # 🔧 使用 vessel_id
})
else:
action_sequence.append(create_action_log("跳过搅拌器停止", "⏭️"))

View File

@@ -329,7 +329,7 @@ def generate_evaporate_protocol(
"device_id": rotavap_device,
"action_name": "evaporate",
"action_kwargs": {
"vessel": target_vessel,
"vessel": {"id": target_vessel},
"pressure": float(pressure),
"temp": float(temp),
"time": float(final_time), # 🔧 强制转换为float类型

View File

@@ -220,7 +220,7 @@ def generate_heat_chill_protocol(
"device_id": heatchill_id,
"action_name": "heat_chill",
"action_kwargs": {
"vessel": vessel,
"vessel": {"id": vessel},
"temp": float(final_temp),
"time": float(final_time),
"stir": bool(stir),
@@ -287,7 +287,8 @@ def generate_heat_chill_start_protocol(
"action_name": "heat_chill_start",
"action_kwargs": {
"temp": temp,
"purpose": purpose or f"开始加热到 {temp}°C"
"purpose": purpose or f"开始加热到 {temp}°C",
"vessel": {"id": vessel_id},
}
}]

View File

@@ -265,7 +265,7 @@ def generate_separate_protocol(
"device_id": stirrer_device,
"action_name": "start_stir",
"action_kwargs": {
"vessel": final_vessel_id, # 🔧 使用 final_vessel_id
"vessel": {"id": final_vessel_id}, # 🔧 使用 final_vessel_id
"stir_speed": stir_speed,
"purpose": f"分离混合 - {purpose}"
}

View File

@@ -234,7 +234,7 @@ def generate_stir_protocol(
"action_name": "stir",
"action_kwargs": {
# 🔧 关键修复传递vessel_id字符串而不是完整的Resource对象
"vessel": vessel_id, # 传递字符串ID不是Resource对象
"vessel": {"id": vessel_id}, # 传递字符串ID不是Resource对象
"time": str(time),
"event": event,
"time_spec": time_spec,
@@ -323,7 +323,7 @@ def generate_start_stir_protocol(
"action_name": "start_stir",
"action_kwargs": {
# 🔧 关键修复传递vessel_id字符串而不是完整的Resource对象
"vessel": vessel_id, # 传递字符串ID不是Resource对象
"vessel": {"id": vessel_id}, # 传递字符串ID不是Resource对象
"stir_speed": stir_speed,
"purpose": purpose or f"启动搅拌 {stir_speed} RPM"
}
@@ -383,7 +383,7 @@ def generate_stop_stir_protocol(
"action_name": "stop_stir",
"action_kwargs": {
# 🔧 关键修复传递vessel_id字符串而不是完整的Resource对象
"vessel": vessel_id # 传递字符串ID不是Resource对象
"vessel": {"id": vessel_id}, # 传递字符串ID不是Resource对象
}
}]

View File

@@ -361,7 +361,7 @@ def generate_wash_solid_protocol(
"device_id": "stirrer_1",
"action_name": "stir",
"action_kwargs": {
"vessel": vessel_id, # 🔧 使用 vessel_id
"vessel": {"id": vessel_id}, # 🔧 使用 vessel_id
"time": str(time),
"stir_time": final_time,
"stir_speed": stir_speed,
@@ -377,7 +377,7 @@ def generate_wash_solid_protocol(
"device_id": "filter_1",
"action_name": "filter",
"action_kwargs": {
"vessel": vessel_id, # 🔧 使用 vessel_id
"vessel": {"id": vessel_id}, # 🔧 使用 vessel_id
"filtrate_vessel": actual_filtrate_vessel,
"temp": temp,
"volume": final_volume

View File

@@ -0,0 +1,454 @@
"""
纽扣电池组装工作站
Coin Cell Assembly Workstation
继承工作站基类,实现纽扣电池特定功能
"""
from typing import Dict, Any, List, Optional, Union
from unilabos.ros.nodes.resource_tracker import DeviceNodeResourceTracker
from unilabos.device_comms.workstation_base import WorkstationBase, WorkflowInfo
from unilabos.device_comms.workstation_communication import (
WorkstationCommunicationBase, CommunicationConfig, CommunicationProtocol, CoinCellCommunication
)
from unilabos.device_comms.workstation_material_management import (
MaterialManagementBase, CoinCellMaterialManagement
)
from unilabos.utils.log import logger
class CoinCellAssemblyWorkstation(WorkstationBase):
"""纽扣电池组装工作站
基于工作站基类,实现纽扣电池制造的特定功能:
1. 纽扣电池特定的通信协议
2. 纽扣电池物料管理(料板、极片、电池等)
3. 电池制造工作流
4. 质量检查工作流
"""
def __init__(
self,
device_id: str,
children: Dict[str, Dict[str, Any]],
protocol_type: Union[str, List[str]] = "BatteryManufacturingProtocol",
resource_tracker: Optional[DeviceNodeResourceTracker] = None,
modbus_config: Optional[Dict[str, Any]] = None,
deck_config: Optional[Dict[str, Any]] = None,
csv_path: str = "./coin_cell_assembly.csv",
*args,
**kwargs,
):
# 设置通信配置
modbus_config = modbus_config or {"host": "127.0.0.1", "port": 5021}
self.communication_config = CommunicationConfig(
protocol=CommunicationProtocol.MODBUS_TCP,
host=modbus_config["host"],
port=modbus_config["port"],
timeout=modbus_config.get("timeout", 5.0),
retry_count=modbus_config.get("retry_count", 3)
)
# 设置台面配置
self.deck_config = deck_config or {
"size_x": 1620.0,
"size_y": 1270.0,
"size_z": 500.0
}
# CSV地址映射文件路径
self.csv_path = csv_path
# 创建资源跟踪器(如果没有提供)
if resource_tracker is None:
from unilabos.ros.nodes.resource_tracker import DeviceNodeResourceTracker
resource_tracker = DeviceNodeResourceTracker()
# 初始化基类
super().__init__(
device_id=device_id,
children=children,
protocol_type=protocol_type,
resource_tracker=resource_tracker,
communication_config=self.communication_config,
deck_config=self.deck_config,
*args,
**kwargs
)
logger.info(f"纽扣电池组装工作站 {device_id} 初始化完成")
def _create_communication_module(self) -> WorkstationCommunicationBase:
"""创建纽扣电池通信模块"""
return CoinCellCommunication(
communication_config=self.communication_config,
csv_path=self.csv_path
)
def _create_material_management_module(self) -> MaterialManagementBase:
"""创建纽扣电池物料管理模块"""
return CoinCellMaterialManagement(
device_id=self.device_id,
deck_config=self.deck_config,
resource_tracker=self.resource_tracker,
children_config=self.children
)
def _register_supported_workflows(self):
"""注册纽扣电池工作流"""
# 电池制造工作流
self.supported_workflows["battery_manufacturing"] = WorkflowInfo(
name="battery_manufacturing",
description="纽扣电池制造工作流",
estimated_duration=300.0, # 5分钟
required_materials=["cathode_sheet", "anode_sheet", "separator", "electrolyte"],
output_product="coin_cell_battery",
parameters_schema={
"type": "object",
"properties": {
"electrolyte_num": {
"type": "integer",
"description": "电解液瓶数",
"minimum": 1,
"maximum": 32
},
"electrolyte_volume": {
"type": "number",
"description": "电解液体积 (μL)",
"minimum": 0.1,
"maximum": 100.0
},
"assembly_pressure": {
"type": "number",
"description": "组装压力 (N)",
"minimum": 100.0,
"maximum": 5000.0
},
"cathode_material": {
"type": "string",
"description": "正极材料类型",
"enum": ["LiFePO4", "LiCoO2", "NCM", "LMO"]
},
"anode_material": {
"type": "string",
"description": "负极材料类型",
"enum": ["Graphite", "LTO", "Silicon"]
}
},
"required": ["electrolyte_num", "electrolyte_volume", "assembly_pressure"]
}
)
# 质量检查工作流
self.supported_workflows["quality_inspection"] = WorkflowInfo(
name="quality_inspection",
description="产品质量检查工作流",
estimated_duration=60.0, # 1分钟
required_materials=["finished_battery"],
output_product="quality_report",
parameters_schema={
"type": "object",
"properties": {
"test_voltage": {
"type": "boolean",
"description": "是否测试电压",
"default": True
},
"test_capacity": {
"type": "boolean",
"description": "是否测试容量",
"default": False
},
"voltage_threshold": {
"type": "number",
"description": "电压阈值 (V)",
"minimum": 2.0,
"maximum": 4.5,
"default": 3.0
}
}
}
)
# 设备初始化工作流
self.supported_workflows["device_initialization"] = WorkflowInfo(
name="device_initialization",
description="设备初始化工作流",
estimated_duration=30.0, # 30秒
required_materials=[],
output_product="ready_status",
parameters_schema={
"type": "object",
"properties": {
"auto_mode": {
"type": "boolean",
"description": "是否启用自动模式",
"default": True
}
}
}
)
# ============ 纽扣电池特定方法 ============
def get_electrode_sheet_inventory(self) -> Dict[str, int]:
"""获取极片库存统计"""
try:
sheets = self.material_management.find_electrode_sheets()
inventory = {}
for sheet in sheets:
material_type = getattr(sheet, 'material_type', 'unknown')
inventory[material_type] = inventory.get(material_type, 0) + 1
return inventory
except Exception as e:
logger.error(f"获取极片库存失败: {e}")
return {}
def get_battery_production_statistics(self) -> Dict[str, Any]:
"""获取电池生产统计"""
try:
production_data = self.communication.get_production_data()
# 添加物料统计
electrode_inventory = self.get_electrode_sheet_inventory()
battery_count = len(self.material_management.find_batteries())
return {
**production_data,
"electrode_inventory": electrode_inventory,
"finished_battery_count": battery_count,
"material_plates": len(self.material_management.find_material_plates()),
"press_slots": len(self.material_management.find_press_slots())
}
except Exception as e:
logger.error(f"获取生产统计失败: {e}")
return {"error": str(e)}
def create_new_battery(self, battery_spec: Dict[str, Any]) -> Optional[str]:
"""创建新电池资源"""
try:
from unilabos.device_comms.button_battery_station import Battery
import uuid
battery_id = f"battery_{uuid.uuid4().hex[:8]}"
battery = Battery(
name=battery_id,
diameter=battery_spec.get("diameter", 20.0),
height=battery_spec.get("height", 3.2),
max_volume=battery_spec.get("max_volume", 100.0),
barcode=battery_spec.get("barcode", "")
)
# 添加到物料管理系统
self.material_management.plr_resources[battery_id] = battery
self.material_management.resource_tracker.add_resource(battery)
logger.info(f"创建新电池资源: {battery_id}")
return battery_id
except Exception as e:
logger.error(f"创建电池资源失败: {e}")
return None
def find_available_press_slot(self) -> Optional[str]:
"""查找可用的压制槽"""
try:
press_slots = self.material_management.find_press_slots()
for slot in press_slots:
if hasattr(slot, 'has_battery') and not slot.has_battery():
return slot.name
return None
except Exception as e:
logger.error(f"查找可用压制槽失败: {e}")
return None
def get_glove_box_environment(self) -> Dict[str, Any]:
"""获取手套箱环境数据"""
try:
device_status = self.communication.get_device_status()
environment = device_status.get("environment", {})
return {
"pressure": environment.get("glove_box_pressure", 0.0),
"o2_content": environment.get("o2_content", 0.0),
"water_content": environment.get("water_content", 0.0),
"is_safe": (
environment.get("o2_content", 0.0) < 10.0 and # 氧气含量 < 10ppm
environment.get("water_content", 0.0) < 1.0 # 水分含量 < 1ppm
)
}
except Exception as e:
logger.error(f"获取手套箱环境失败: {e}")
return {"error": str(e)}
def start_data_export(self, file_path: str) -> bool:
"""开始生产数据导出"""
try:
return self.communication.start_data_export(file_path, export_interval=5.0)
except Exception as e:
logger.error(f"启动数据导出失败: {e}")
return False
def stop_data_export(self) -> bool:
"""停止生产数据导出"""
try:
return self.communication.stop_data_export()
except Exception as e:
logger.error(f"停止数据导出失败: {e}")
return False
# ============ 重写基类方法以支持纽扣电池特定功能 ============
def start_workflow(self, workflow_type: str, parameters: Dict[str, Any] = None) -> bool:
"""启动工作流(重写以支持纽扣电池特定预处理)"""
try:
# 进行纽扣电池特定的预检查
if workflow_type == "battery_manufacturing":
# 检查手套箱环境
env = self.get_glove_box_environment()
if not env.get("is_safe", False):
logger.error("手套箱环境不安全,无法启动电池制造工作流")
return False
# 检查是否有可用的压制槽
available_slot = self.find_available_press_slot()
if not available_slot:
logger.error("没有可用的压制槽,无法启动电池制造工作流")
return False
# 检查极片库存
electrode_inventory = self.get_electrode_sheet_inventory()
if not electrode_inventory.get("cathode", 0) > 0 or not electrode_inventory.get("anode", 0) > 0:
logger.error("极片库存不足,无法启动电池制造工作流")
return False
# 调用基类方法
return super().start_workflow(workflow_type, parameters)
except Exception as e:
logger.error(f"启动纽扣电池工作流失败: {e}")
return False
# ============ 纽扣电池特定状态属性 ============
@property
def electrode_sheet_count(self) -> int:
"""极片总数"""
try:
return len(self.material_management.find_electrode_sheets())
except:
return 0
@property
def battery_count(self) -> int:
"""电池总数"""
try:
return len(self.material_management.find_batteries())
except:
return 0
@property
def available_press_slots(self) -> int:
"""可用压制槽数"""
try:
press_slots = self.material_management.find_press_slots()
available = 0
for slot in press_slots:
if hasattr(slot, 'has_battery') and not slot.has_battery():
available += 1
return available
except:
return 0
@property
def environment_status(self) -> Dict[str, Any]:
"""环境状态"""
return self.get_glove_box_environment()
# ============ 工厂函数 ============
def create_coin_cell_workstation(
device_id: str,
config_file: str,
modbus_host: str = "127.0.0.1",
modbus_port: int = 5021,
csv_path: str = "./coin_cell_assembly.csv"
) -> CoinCellAssemblyWorkstation:
"""工厂函数:创建纽扣电池组装工作站
Args:
device_id: 设备ID
config_file: 配置文件路径JSON格式
modbus_host: Modbus主机地址
modbus_port: Modbus端口
csv_path: 地址映射CSV文件路径
Returns:
CoinCellAssemblyWorkstation: 工作站实例
"""
import json
try:
# 加载配置文件
with open(config_file, 'r', encoding='utf-8') as f:
config = json.load(f)
# 提取配置
children = config.get("children", {})
deck_config = config.get("deck_config", {})
# 创建工作站
workstation = CoinCellAssemblyWorkstation(
device_id=device_id,
children=children,
modbus_config={
"host": modbus_host,
"port": modbus_port
},
deck_config=deck_config,
csv_path=csv_path
)
logger.info(f"纽扣电池工作站创建成功: {device_id}")
return workstation
except Exception as e:
logger.error(f"创建纽扣电池工作站失败: {e}")
raise
if __name__ == "__main__":
# 示例用法
workstation = create_coin_cell_workstation(
device_id="coin_cell_station_01",
config_file="./button_battery_workstation.json",
modbus_host="127.0.0.1",
modbus_port=5021
)
# 启动电池制造工作流
success = workstation.start_workflow(
"battery_manufacturing",
{
"electrolyte_num": 16,
"electrolyte_volume": 50.0,
"assembly_pressure": 2000.0,
"cathode_material": "LiFePO4",
"anode_material": "Graphite"
}
)
if success:
print("电池制造工作流启动成功")
else:
print("电池制造工作流启动失败")

View File

@@ -8,8 +8,8 @@ from pymodbus.client import ModbusSerialClient, ModbusTcpClient
from pymodbus.framer import FramerType
from typing import TypedDict
from unilabos.device_comms.modbus_plc.node.modbus import DeviceType, HoldRegister, Coil, InputRegister, DiscreteInputs, DataType, WorderOrder
from unilabos.device_comms.modbus_plc.node.modbus import Base as ModbusNodeBase
from unilabos.device_comms.modbus_plc.modbus import DeviceType, HoldRegister, Coil, InputRegister, DiscreteInputs, DataType, WorderOrder
from unilabos.device_comms.modbus_plc.modbus import Base as ModbusNodeBase
from unilabos.device_comms.universal_driver import UniversalDriver
from unilabos.utils.log import logger
import pandas as pd

View File

@@ -1,6 +1,6 @@
import time
from pymodbus.client import ModbusTcpClient
from unilabos.device_comms.modbus_plc.node.modbus import Coil, HoldRegister
from unilabos.device_comms.modbus_plc.modbus import Coil, HoldRegister
from pymodbus.payload import BinaryPayloadDecoder
from pymodbus.constants import Endian

View File

@@ -1,6 +1,6 @@
# coding=utf-8
from pymodbus.client import ModbusTcpClient
from unilabos.device_comms.modbus_plc.node.modbus import Coil
from unilabos.device_comms.modbus_plc.modbus import Coil
import time

View File

@@ -1,7 +1,7 @@
import time
from typing import Callable
from unilabos.device_comms.modbus_plc.client import TCPClient, ModbusWorkflow, WorkflowAction, load_csv
from unilabos.device_comms.modbus_plc.node.modbus import Base as ModbusNodeBase
from unilabos.device_comms.modbus_plc.modbus import Base as ModbusNodeBase
############ 第一种写法 ##############

View File

@@ -0,0 +1,150 @@
<?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

@@ -0,0 +1,29 @@
{
"nodes": [
{
"id": "NEWARE_BATTERY_TEST_SYSTEM",
"name": "Neware Battery Test System",
"parent": null,
"type": "device",
"class": "neware_battery_test_system",
"position": {
"x": 620.6111111111111,
"y": 171,
"z": 0
},
"config": {
"ip": "127.0.0.1",
"port": 502,
"machine_id": 1,
"devtype": "27",
"timeout": 20,
"size_x": 500.0,
"size_y": 500.0,
"size_z": 2000.0
},
"data": {},
"children": []
}
],
"links": []
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,307 @@
"""
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

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

View File

@@ -0,0 +1,334 @@
"""
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

@@ -0,0 +1,209 @@
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

@@ -0,0 +1,14 @@
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

@@ -0,0 +1,25 @@
"""
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

@@ -0,0 +1,44 @@
#!/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

@@ -0,0 +1,529 @@
"""
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

@@ -0,0 +1,881 @@
"""
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
# 基础导入
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 硬件通信后端"""
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 _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 asyncio.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 asyncio.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 asyncio.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 asyncio.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 asyncio.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 asyncio.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 asyncio.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

@@ -0,0 +1,954 @@
"""
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

@@ -0,0 +1,69 @@
# 更新日志
本文档记录了 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

@@ -0,0 +1,267 @@
# 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

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