mirror of
https://github.com/dptech-corp/Uni-Lab-OS.git
synced 2026-02-20 00:25:16 +00:00
Compare commits
14 Commits
25d46dc9d5
...
v0.10.7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9aeffebde1 | ||
|
|
172599adcf | ||
|
|
84cc3a421c | ||
|
|
e5aa4d940a | ||
|
|
4771ff2347 | ||
|
|
8bcc92a394 | ||
|
|
49354fcf39 | ||
|
|
a8973ea92b | ||
|
|
0bfb52df00 | ||
|
|
a555c59dc2 | ||
|
|
9ac0ad49cb | ||
|
|
daa46aaf50 | ||
|
|
bbd9629f98 | ||
|
|
2d560a8182 |
@@ -1,6 +1,6 @@
|
|||||||
package:
|
package:
|
||||||
name: unilabos
|
name: unilabos
|
||||||
version: 0.10.6
|
version: 0.10.7
|
||||||
|
|
||||||
source:
|
source:
|
||||||
path: ../unilabos
|
path: ../unilabos
|
||||||
@@ -31,11 +31,14 @@ requirements:
|
|||||||
- python ==3.11.11
|
- python ==3.11.11
|
||||||
- pip
|
- pip
|
||||||
- setuptools
|
- setuptools
|
||||||
|
- zstd
|
||||||
|
- zstandard
|
||||||
run:
|
run:
|
||||||
- conda-forge::python ==3.11.11
|
- conda-forge::python ==3.11.11
|
||||||
- compilers
|
- compilers
|
||||||
- cmake
|
- cmake
|
||||||
- zstd
|
- zstd
|
||||||
|
- zstandard
|
||||||
- ninja
|
- ninja
|
||||||
- if: unix
|
- if: unix
|
||||||
then:
|
then:
|
||||||
|
|||||||
376
.github/workflows/conda-pack-build.yml
vendored
Normal file
376
.github/workflows/conda-pack-build.yml
vendored
Normal 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
98
.github/workflows/deploy-docs.yml
vendored
Normal 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
|
||||||
15
CONTRIBUTORS
Normal file
15
CONTRIBUTORS
Normal 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>
|
||||||
@@ -31,7 +31,7 @@ Join the [Intelligent Organic Chemistry Synthesis Competition](https://bohrium.d
|
|||||||
|
|
||||||
Detailed documentation can be found at:
|
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
|
## Quick Start
|
||||||
|
|
||||||
@@ -55,7 +55,7 @@ pip install .
|
|||||||
|
|
||||||
3. Start Uni-Lab System:
|
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
|
## Message Format
|
||||||
|
|
||||||
|
|||||||
@@ -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 系统:
|
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)
|
||||||
|
|
||||||
## 消息格式
|
## 消息格式
|
||||||
|
|
||||||
|
|||||||
147
docs/developer_guide/add_batteryPLC.md
Normal file
147
docs/developer_guide/add_batteryPLC.md
Normal 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>
|
||||||
|
```
|
||||||
|
|
||||||
|
点击注册表编辑,进入注册表编辑页面
|
||||||
|

|
||||||
|
|
||||||
|
按照图示步骤填写自动生成注册表信息:
|
||||||
|

|
||||||
|
|
||||||
|
步骤说明:
|
||||||
|
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文件,如下图:
|
||||||
|

|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
### 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文件进行了修改,则不需要在网页端新增注册表信息。只需要运行补全注册表信息之后,上传注册表即可。
|
||||||
|
|
||||||
|
|
||||||
BIN
docs/developer_guide/image_add_batteryPLC/unilab_new_yaml.png
Normal file
BIN
docs/developer_guide/image_add_batteryPLC/unilab_new_yaml.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 428 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 310 KiB |
BIN
docs/developer_guide/image_add_batteryPLC/unilab_sys_status.png
Normal file
BIN
docs/developer_guide/image_add_batteryPLC/unilab_sys_status.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 66 KiB |
409
docs/developer_guide/materials_tutorial.md
Normal file
409
docs/developer_guide/materials_tutorial.md
Normal 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 PyLabRobot(PLR)物料格式(实验流程运行时)
|
||||||
|
|
||||||
|
- 结构特征:严格的层级树,`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]:
|
||||||
|
# ... 由扁平 dict(id->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 ↔ PyLabRobot(PLR)
|
||||||
|
|
||||||
|
高层封装:
|
||||||
|
|
||||||
|
```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 列表)。便于版本化、可视化与网络传输。
|
||||||
|
- **实验工作流执行**:使用 PyLabRobot(PLR)格式。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]:
|
||||||
|
# 批量初始化
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -33,6 +33,8 @@ developer_guide/add_device
|
|||||||
developer_guide/add_action
|
developer_guide/add_action
|
||||||
developer_guide/actions
|
developer_guide/actions
|
||||||
developer_guide/add_protocol
|
developer_guide/add_protocol
|
||||||
|
developer_guide/add_batteryPLC
|
||||||
|
developer_guide/materials_tutorial.md
|
||||||
```
|
```
|
||||||
|
|
||||||
## 接口文档
|
## 接口文档
|
||||||
|
|||||||
13
docs/requirements.txt
Normal file
13
docs/requirements.txt
Normal 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
|
||||||
197
docs/user_guide/quick_install_guide.md
Normal file
197
docs/user_guide/quick_install_guide.md
Normal 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 版本。
|
||||||
22
package.xml
22
package.xml
@@ -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>
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
package:
|
package:
|
||||||
name: ros-humble-unilabos-msgs
|
name: ros-humble-unilabos-msgs
|
||||||
version: 0.10.6
|
version: 0.10.7
|
||||||
source:
|
source:
|
||||||
path: ../../unilabos_msgs
|
path: ../../unilabos_msgs
|
||||||
target_directory: src
|
target_directory: src
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
package:
|
package:
|
||||||
name: unilabos
|
name: unilabos
|
||||||
version: "0.10.6"
|
version: "0.10.7"
|
||||||
|
|
||||||
source:
|
source:
|
||||||
path: ../..
|
path: ../..
|
||||||
|
|||||||
190
scripts/create_readme.py
Normal file
190
scripts/create_readme.py
Normal 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())
|
||||||
148
scripts/create_zip_archive.py
Normal file
148
scripts/create_zip_archive.py
Normal 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
203
scripts/install_unilab.bat
Normal 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
139
scripts/install_unilab.sh
Executable 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 ""
|
||||||
|
|
||||||
153
scripts/verify_installation.py
Normal file
153
scripts/verify_installation.py
Normal 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())
|
||||||
7
setup.py
7
setup.py
@@ -4,13 +4,14 @@ package_name = 'unilabos'
|
|||||||
|
|
||||||
setup(
|
setup(
|
||||||
name=package_name,
|
name=package_name,
|
||||||
version='0.10.6',
|
version='0.10.7',
|
||||||
packages=find_packages(),
|
packages=find_packages(),
|
||||||
include_package_data=True,
|
include_package_data=True,
|
||||||
install_requires=['setuptools'],
|
install_requires=['setuptools'],
|
||||||
zip_safe=True,
|
zip_safe=True,
|
||||||
maintainer='Junhan Chang',
|
author="The unilabos developers",
|
||||||
maintainer_email='changjh@pku.edu.cn',
|
maintainer='Junhan Chang, Xuwznln',
|
||||||
|
maintainer_email='Junhan Chang <changjh@pku.edu.cn>, Xuwznln <18435084+Xuwznln@users.noreply.github.com>',
|
||||||
description='',
|
description='',
|
||||||
license='GPL v3',
|
license='GPL v3',
|
||||||
tests_require=['pytest'],
|
tests_require=['pytest'],
|
||||||
|
|||||||
148
test/experiments/laiyu_liquid.json
Normal file
148
test/experiments/laiyu_liquid.json
Normal 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": []
|
||||||
|
}
|
||||||
@@ -19,7 +19,7 @@
|
|||||||
"host": "192.168.0.121",
|
"host": "192.168.0.121",
|
||||||
"port": 9999,
|
"port": 9999,
|
||||||
"timeout": 10.0,
|
"timeout": 10.0,
|
||||||
"axis": "Left",
|
"axis": "Right",
|
||||||
"channel_num": 1,
|
"channel_num": 1,
|
||||||
"setup": true,
|
"setup": true,
|
||||||
"debug": false,
|
"debug": false,
|
||||||
|
|||||||
394
test/experiments/test_laiyu.json
Normal file
394
test/experiments/test_laiyu.json
Normal 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": []
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
__version__ = "0.10.7"
|
||||||
|
|||||||
@@ -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>
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
307
unilabos/devices/laiyu_liquid/__init__.py
Normal file
307
unilabos/devices/laiyu_liquid/__init__.py
Normal 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()
|
||||||
9
unilabos/devices/laiyu_liquid/backend/__init__.py
Normal file
9
unilabos/devices/laiyu_liquid/backend/__init__.py
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
"""
|
||||||
|
LaiYu液体处理设备后端模块
|
||||||
|
|
||||||
|
提供设备后端接口和实现
|
||||||
|
"""
|
||||||
|
|
||||||
|
from .laiyu_backend import LaiYuLiquidBackend, create_laiyu_backend
|
||||||
|
|
||||||
|
__all__ = ['LaiYuLiquidBackend', 'create_laiyu_backend']
|
||||||
334
unilabos/devices/laiyu_liquid/backend/laiyu_backend.py
Normal file
334
unilabos/devices/laiyu_liquid/backend/laiyu_backend.py
Normal 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)
|
||||||
209
unilabos/devices/laiyu_liquid/backend/rviz_backend.py
Normal file
209
unilabos/devices/laiyu_liquid/backend/rviz_backend.py
Normal 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
|
||||||
|
|
||||||
2620
unilabos/devices/laiyu_liquid/config/deckconfig.json
Normal file
2620
unilabos/devices/laiyu_liquid/config/deckconfig.json
Normal file
File diff suppressed because it is too large
Load Diff
14
unilabos/devices/laiyu_liquid/config/deckconfig.md
Normal file
14
unilabos/devices/laiyu_liquid/config/deckconfig.md
Normal 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,可以取到新枪头了!!!!)
|
||||||
25
unilabos/devices/laiyu_liquid/controllers/__init__.py
Normal file
25
unilabos/devices/laiyu_liquid/controllers/__init__.py
Normal 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 高级控制器集合"
|
||||||
1073
unilabos/devices/laiyu_liquid/controllers/pipette_controller.py
Normal file
1073
unilabos/devices/laiyu_liquid/controllers/pipette_controller.py
Normal file
File diff suppressed because it is too large
Load Diff
1183
unilabos/devices/laiyu_liquid/controllers/xyz_controller.py
Normal file
1183
unilabos/devices/laiyu_liquid/controllers/xyz_controller.py
Normal file
File diff suppressed because it is too large
Load Diff
44
unilabos/devices/laiyu_liquid/core/__init__.py
Normal file
44
unilabos/devices/laiyu_liquid/core/__init__.py
Normal 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'
|
||||||
|
]
|
||||||
529
unilabos/devices/laiyu_liquid/core/abstract_protocol.py
Normal file
529
unilabos/devices/laiyu_liquid/core/abstract_protocol.py
Normal 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
|
||||||
881
unilabos/devices/laiyu_liquid/core/laiyu_liquid_main.py
Normal file
881
unilabos/devices/laiyu_liquid/core/laiyu_liquid_main.py
Normal 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"
|
||||||
|
]
|
||||||
954
unilabos/devices/laiyu_liquid/core/laiyu_liquid_res.py
Normal file
954
unilabos/devices/laiyu_liquid/core/laiyu_liquid_res.py
Normal 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
|
||||||
69
unilabos/devices/laiyu_liquid/docs/CHANGELOG.md
Normal file
69
unilabos/devices/laiyu_liquid/docs/CHANGELOG.md
Normal 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**: 向后兼容的问题修复
|
||||||
|
|
||||||
|
### 变更类型
|
||||||
|
- **新增功能**: 新的功能特性
|
||||||
|
- **变更**: 现有功能的变更
|
||||||
|
- **弃用**: 即将移除的功能
|
||||||
|
- **移除**: 已移除的功能
|
||||||
|
- **修复**: 问题修复
|
||||||
|
- **安全**: 安全相关的修复
|
||||||
@@ -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(注意:地址不可为47,69,91)
|
||||||
|
- **命令/数据**: 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
|
||||||
|
# 示例:设置最高速度为2000(200ul/秒)
|
||||||
|
s2000E
|
||||||
|
|
||||||
|
# 设置启动速度
|
||||||
|
b[n]E
|
||||||
|
# 示例:设置启动速度为100(10ul/秒)
|
||||||
|
b100E
|
||||||
|
|
||||||
|
# 设置断流速度
|
||||||
|
c[n]E
|
||||||
|
# 示例:设置断流速度为100(10ul/秒)
|
||||||
|
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
|
||||||
162
unilabos/devices/laiyu_liquid/docs/hardware/步进电机控制指令.md
Normal file
162
unilabos/devices/laiyu_liquid/docs/hardware/步进电机控制指令.md
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
# 步进电机B系列控制指令详解
|
||||||
|
|
||||||
|
## 基本通信参数
|
||||||
|
- **通信方式**: RS485
|
||||||
|
- **协议**: Modbus
|
||||||
|
- **波特率**: 115200 (默认)
|
||||||
|
- **数据位**: 8位
|
||||||
|
- **停止位**: 1位
|
||||||
|
- **校验位**: 无
|
||||||
|
- **默认站号**: 1 (可设置1-254)
|
||||||
|
|
||||||
|
## 支持的功能码
|
||||||
|
- **03H**: 读取寄存器
|
||||||
|
- **06H**: 写入单个寄存器
|
||||||
|
- **10H**: 写入多个寄存器
|
||||||
|
|
||||||
|
## 寄存器地址表
|
||||||
|
|
||||||
|
### 状态监控寄存器 (只读)
|
||||||
|
| 地址 | 功能码 | 内容 | 说明 |
|
||||||
|
|------|--------|------|------|
|
||||||
|
| 00H | 03H | 电机状态 | 0000H-待机/到位, 0001H-运行中, 0002H-碰撞停, 0003H-正光电停, 0004H-反光电停 |
|
||||||
|
| 01H | 03H | 实际步数高位 | 当前电机位置的高16位 |
|
||||||
|
| 02H | 03H | 实际步数低位 | 当前电机位置的低16位 |
|
||||||
|
| 03H | 03H | 实际速度 | 当前转速 (rpm) |
|
||||||
|
| 05H | 03H | 电流 | 当前工作电流 (mA) |
|
||||||
|
|
||||||
|
### 控制寄存器 (读写)
|
||||||
|
| 地址 | 功能码 | 内容 | 说明 |
|
||||||
|
|------|--------|------|------|
|
||||||
|
| 04H | 03H/06H/10H | 急停指令 | 紧急停止控制 |
|
||||||
|
| 06H | 03H/06H/10H | 失能控制 | 1-使能, 0-失能 |
|
||||||
|
| 07H | 03H/06H/10H | PWM输出 | 0-1000对应0%-100%占空比 |
|
||||||
|
| 0EH | 03H/06H/10H | 单圈绝对值归零 | 归零指令 |
|
||||||
|
| 0FH | 03H/06H/10H | 归零指令 | 定点模式归零速度设置 |
|
||||||
|
|
||||||
|
### 位置模式寄存器
|
||||||
|
| 地址 | 功能码 | 内容 | 说明 |
|
||||||
|
|------|--------|------|------|
|
||||||
|
| 10H | 03H/06H/10H | 目标步数高位 | 目标位置高16位 |
|
||||||
|
| 11H | 03H/06H/10H | 目标步数低位 | 目标位置低16位 |
|
||||||
|
| 12H | 03H/06H/10H | 保留 | - |
|
||||||
|
| 13H | 03H/06H/10H | 速度 | 运行速度 (rpm) |
|
||||||
|
| 14H | 03H/06H/10H | 加速度 | 0-60000 rpm/s |
|
||||||
|
| 15H | 03H/06H/10H | 精度 | 到位精度设置 |
|
||||||
|
|
||||||
|
### 速度模式寄存器
|
||||||
|
| 地址 | 功能码 | 内容 | 说明 |
|
||||||
|
|------|--------|------|------|
|
||||||
|
| 60H | 03H/06H/10H | 保留 | - |
|
||||||
|
| 61H | 03H/06H/10H | 速度 | 正值正转,负值反转 |
|
||||||
|
| 62H | 03H/06H/10H | 加速度 | 0-60000 rpm/s |
|
||||||
|
|
||||||
|
### 设备参数寄存器
|
||||||
|
| 地址 | 功能码 | 内容 | 默认值 | 说明 |
|
||||||
|
|------|--------|------|--------|------|
|
||||||
|
| E0H | 03H/06H/10H | 设备地址 | 0001H | Modbus从站地址 |
|
||||||
|
| E1H | 03H/06H/10H | 堵转电流 | 0BB8H | 堵转检测电流阈值 |
|
||||||
|
| E2H | 03H/06H/10H | 保留 | 0258H | - |
|
||||||
|
| E3H | 03H/06H/10H | 每圈步数 | 0640H | 细分设置 |
|
||||||
|
| E4H | 03H/06H/10H | 限位开关使能 | F000H | 1-使能, 0-禁用 |
|
||||||
|
| E5H | 03H/06H/10H | 堵转逻辑 | 0000H | 00-断电, 01-对抗 |
|
||||||
|
| E6H | 03H/06H/10H | 堵转时间 | 0000H | 堵转检测时间(ms) |
|
||||||
|
| E7H | 03H/06H/10H | 默认速度 | 1388H | 上电默认速度 |
|
||||||
|
| E8H | 03H/06H/10H | 默认加速度 | EA60H | 上电默认加速度 |
|
||||||
|
| E9H | 03H/06H/10H | 默认精度 | 0064H | 上电默认精度 |
|
||||||
|
| EAH | 03H/06H/10H | 波特率高位 | 0001H | 通信波特率设置 |
|
||||||
|
| EBH | 03H/06H/10H | 波特率低位 | C200H | 115200对应01C200H |
|
||||||
|
|
||||||
|
### 版本信息寄存器 (只读)
|
||||||
|
| 地址 | 功能码 | 内容 | 说明 |
|
||||||
|
|------|--------|------|------|
|
||||||
|
| F0H | 03H | 版本号 | 固件版本信息 |
|
||||||
|
| F1H-F4H | 03H | 型号 | 产品型号信息 |
|
||||||
|
|
||||||
|
## 常用控制指令示例
|
||||||
|
|
||||||
|
### 读取电机状态
|
||||||
|
```
|
||||||
|
发送: 01 03 00 00 00 01 84 0A
|
||||||
|
接收: 01 03 02 00 01 79 84
|
||||||
|
说明: 电机状态为0001H (正在运行)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 读取当前位置
|
||||||
|
```
|
||||||
|
发送: 01 03 00 01 00 02 95 CB
|
||||||
|
接收: 01 03 04 00 19 00 00 2B F4
|
||||||
|
说明: 当前位置为1638400步 (100圈)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 停止电机
|
||||||
|
```
|
||||||
|
发送: 01 10 00 04 00 01 02 00 00 A7 D4
|
||||||
|
接收: 01 10 00 04 00 01 40 08
|
||||||
|
说明: 急停指令
|
||||||
|
```
|
||||||
|
|
||||||
|
### 位置模式运动
|
||||||
|
```
|
||||||
|
发送: 01 10 00 10 00 06 0C 00 19 00 00 00 00 13 88 00 00 00 00 9F FB
|
||||||
|
接收: 01 10 00 10 00 06 41 CE
|
||||||
|
说明: 以5000rpm速度运动到1638400步位置
|
||||||
|
```
|
||||||
|
|
||||||
|
### 速度模式 - 正转
|
||||||
|
```
|
||||||
|
发送: 01 10 00 60 00 04 08 00 00 13 88 00 FA 00 00 F4 77
|
||||||
|
接收: 01 10 00 60 00 04 C1 D4
|
||||||
|
说明: 以5000rpm速度正转
|
||||||
|
```
|
||||||
|
|
||||||
|
### 速度模式 - 反转
|
||||||
|
```
|
||||||
|
发送: 01 10 00 60 00 04 08 00 00 EC 78 00 FA 00 00 A0 6D
|
||||||
|
接收: 01 10 00 60 00 04 C1 D4
|
||||||
|
说明: 以5000rpm速度反转 (EC78H = -5000)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 设置设备地址
|
||||||
|
```
|
||||||
|
发送: 00 06 00 E0 00 02 C9 F1
|
||||||
|
接收: 00 06 00 E0 00 02 C9 F1
|
||||||
|
说明: 将设备地址设置为2
|
||||||
|
```
|
||||||
|
|
||||||
|
## 错误码
|
||||||
|
| 状态码 | 含义 |
|
||||||
|
|--------|------|
|
||||||
|
| 0001H | 功能码错误 |
|
||||||
|
| 0002H | 地址错误 |
|
||||||
|
| 0003H | 长度错误 |
|
||||||
|
|
||||||
|
## CRC校验算法
|
||||||
|
```c
|
||||||
|
public static byte[] ModBusCRC(byte[] data, int offset, int cnt) {
|
||||||
|
int wCrc = 0x0000FFFF;
|
||||||
|
byte[] CRC = new byte[2];
|
||||||
|
for (int i = 0; i < cnt; i++) {
|
||||||
|
wCrc ^= ((data[i + offset]) & 0xFF);
|
||||||
|
for (int j = 0; j < 8; j++) {
|
||||||
|
if ((wCrc & 0x00000001) == 1) {
|
||||||
|
wCrc >>= 1;
|
||||||
|
wCrc ^= 0x0000A001;
|
||||||
|
} else {
|
||||||
|
wCrc >>= 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
CRC[1] = (byte) ((wCrc & 0x0000FF00) >> 8);
|
||||||
|
CRC[0] = (byte) (wCrc & 0x000000FF);
|
||||||
|
return CRC;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 注意事项
|
||||||
|
1. 所有16位数据采用大端序传输
|
||||||
|
2. 步数计算: 实际步数 = 高位<<16 | 低位
|
||||||
|
3. 负数使用补码表示
|
||||||
|
4. PWM输出K脚: 0%开漏, 100%接地, 其他输出1KHz PWM
|
||||||
|
5. 光电开关需使用NPN开漏型
|
||||||
|
6. 限位开关: LF正向, LB反向
|
||||||
1281
unilabos/devices/laiyu_liquid/docs/hardware/硬件连接配置指南.md
Normal file
1281
unilabos/devices/laiyu_liquid/docs/hardware/硬件连接配置指南.md
Normal file
File diff suppressed because it is too large
Load Diff
269
unilabos/devices/laiyu_liquid/docs/readme.md
Normal file
269
unilabos/devices/laiyu_liquid/docs/readme.md
Normal file
@@ -0,0 +1,269 @@
|
|||||||
|
# LaiYu_Liquid 液体处理工作站
|
||||||
|
|
||||||
|
## 概述
|
||||||
|
|
||||||
|
LaiYu_Liquid 是一个完全集成到 UniLabOS 的自动化液体处理工作站,基于 RS485 通信协议,专为精确的液体分配和转移操作而设计。本模块已完成生产环境部署准备,提供完整的硬件控制、资源管理和标准化接口。
|
||||||
|
|
||||||
|
## 系统组成
|
||||||
|
|
||||||
|
### 硬件组件
|
||||||
|
- **XYZ三轴运动平台**: 3个RS485步进电机驱动(地址:X轴=0x01, Y轴=0x02, Z轴=0x03)
|
||||||
|
- **SOPA气动式移液器**: RS485总线控制,支持精密液体处理操作
|
||||||
|
- **通信接口**: RS485转USB模块,默认波特率115200
|
||||||
|
- **机械结构**: 稳固工作台面,支持离心管架、96孔板等标准实验耗材
|
||||||
|
|
||||||
|
### 软件架构
|
||||||
|
- **驱动层**: 底层硬件通信驱动,支持RS485协议
|
||||||
|
- **控制层**: 高级控制逻辑和坐标系管理
|
||||||
|
- **抽象层**: 完全符合UniLabOS标准的液体处理接口
|
||||||
|
- **资源层**: 标准化的实验器具和耗材管理
|
||||||
|
|
||||||
|
## 🎯 生产就绪组件
|
||||||
|
|
||||||
|
### ✅ 核心驱动程序 (`drivers/`)
|
||||||
|
- **`sopa_pipette_driver.py`** - SOPA移液器完整驱动
|
||||||
|
- 支持液体吸取、分配、检测
|
||||||
|
- 完整的错误处理和状态管理
|
||||||
|
- 生产级别的通信协议实现
|
||||||
|
|
||||||
|
- **`xyz_stepper_driver.py`** - XYZ三轴步进电机驱动
|
||||||
|
- 精确的位置控制和运动规划
|
||||||
|
- 安全限位和错误检测
|
||||||
|
- 高性能运动控制算法
|
||||||
|
|
||||||
|
### ✅ 高级控制器 (`controllers/`)
|
||||||
|
- **`pipette_controller.py`** - 移液控制器
|
||||||
|
- 封装高级液体处理功能
|
||||||
|
- 支持多种液体类型和处理参数
|
||||||
|
- 智能错误恢复机制
|
||||||
|
|
||||||
|
- **`xyz_controller.py`** - XYZ运动控制器
|
||||||
|
- 坐标系管理和转换
|
||||||
|
- 运动路径优化
|
||||||
|
- 安全运动控制
|
||||||
|
|
||||||
|
### ✅ UniLabOS集成 (`core/LaiYu_Liquid.py`)
|
||||||
|
- **完整的液体处理抽象接口**
|
||||||
|
- **标准化的资源管理系统**
|
||||||
|
- **与PyLabRobot兼容的后端实现**
|
||||||
|
- **生产级别的错误处理和日志记录**
|
||||||
|
|
||||||
|
### ✅ 资源管理系统
|
||||||
|
- **`laiyu_liquid_res.py`** - 标准化资源定义
|
||||||
|
- 96孔板、离心管架、枪头架等标准器具
|
||||||
|
- 自动化的资源创建和配置函数
|
||||||
|
- 与工作台布局的完美集成
|
||||||
|
|
||||||
|
### ✅ 配置管理 (`config/`)
|
||||||
|
- **`config/deck.json`** - 工作台布局配置
|
||||||
|
- 精确的空间定义和槽位管理
|
||||||
|
- 支持多种实验器具的标准化放置
|
||||||
|
- 可扩展的配置架构
|
||||||
|
|
||||||
|
- **`__init__.py`** - 模块集成和导出
|
||||||
|
- 完整的API导出和版本管理
|
||||||
|
- 依赖检查和安装验证
|
||||||
|
- 专业的模块信息展示
|
||||||
|
|
||||||
|
<!-- ### ✅ 可视化支持
|
||||||
|
- **`rviz_backend.py`** - RViz可视化后端
|
||||||
|
- 实时运动状态可视化
|
||||||
|
- 液体处理过程监控
|
||||||
|
- 与ROS系统的无缝集成 -->
|
||||||
|
|
||||||
|
## 🚀 核心功能特性
|
||||||
|
|
||||||
|
### 液体处理能力
|
||||||
|
- **精密体积控制**: 支持1-1000μL精确分配
|
||||||
|
- **多种液体类型**: 水性、有机溶剂、粘稠液体等
|
||||||
|
- **智能检测**: 液位检测、气泡检测、堵塞检测
|
||||||
|
- **自动化流程**: 完整的吸取-转移-分配工作流
|
||||||
|
|
||||||
|
### 运动控制系统
|
||||||
|
- **三轴精密定位**: 微米级精度控制
|
||||||
|
- **路径优化**: 智能运动规划和碰撞避免
|
||||||
|
- **安全机制**: 限位保护、紧急停止、错误恢复
|
||||||
|
- **坐标系管理**: 工作坐标与机械坐标的自动转换
|
||||||
|
|
||||||
|
### 资源管理
|
||||||
|
- **标准化器具**: 支持96孔板、离心管架、枪头架等
|
||||||
|
- **状态跟踪**: 实时监控液体体积、枪头状态等
|
||||||
|
- **自动配置**: 基于JSON的灵活配置系统
|
||||||
|
- **扩展性**: 易于添加新的器具类型
|
||||||
|
|
||||||
|
## 📁 目录结构
|
||||||
|
|
||||||
|
```
|
||||||
|
LaiYu_Liquid/
|
||||||
|
├── __init__.py # 模块初始化和API导出
|
||||||
|
├── readme.md # 本文档
|
||||||
|
├── backend/ # 后端驱动模块
|
||||||
|
│ ├── __init__.py
|
||||||
|
│ └── laiyu_backend.py # PyLabRobot兼容后端
|
||||||
|
├── core/ # 核心模块
|
||||||
|
│ ├── core/
|
||||||
|
│ │ └── LaiYu_Liquid.py # 主设备类
|
||||||
|
│ ├── abstract_protocol.py # 抽象协议
|
||||||
|
│ └── laiyu_liquid_res.py # 设备资源定义
|
||||||
|
├── config/ # 配置文件目录
|
||||||
|
│ └── deck.json # 工作台布局配置
|
||||||
|
├── controllers/ # 高级控制器
|
||||||
|
│ ├── __init__.py
|
||||||
|
│ ├── pipette_controller.py # 移液控制器
|
||||||
|
│ └── xyz_controller.py # XYZ运动控制器
|
||||||
|
├── docs/ # 技术文档
|
||||||
|
│ ├── SOPA气动式移液器RS485控制指令.md
|
||||||
|
│ ├── 步进电机控制指令.md
|
||||||
|
│ └── hardware/ # 硬件相关文档
|
||||||
|
├── drivers/ # 底层驱动程序
|
||||||
|
│ ├── __init__.py
|
||||||
|
│ ├── sopa_pipette_driver.py # SOPA移液器驱动
|
||||||
|
│ └── xyz_stepper_driver.py # XYZ步进电机驱动
|
||||||
|
└── tests/ # 测试文件
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔧 快速开始
|
||||||
|
|
||||||
|
### 1. 安装和验证
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 验证模块安装
|
||||||
|
from unilabos.devices.laiyu_liquid import (
|
||||||
|
LaiYuLiquid,
|
||||||
|
LaiYuLiquidConfig,
|
||||||
|
create_quick_setup,
|
||||||
|
print_module_info
|
||||||
|
)
|
||||||
|
|
||||||
|
# 查看模块信息
|
||||||
|
print_module_info()
|
||||||
|
|
||||||
|
# 快速创建默认资源
|
||||||
|
resources = create_quick_setup()
|
||||||
|
print(f"已创建 {len(resources)} 个资源")
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 基本使用示例
|
||||||
|
|
||||||
|
```python
|
||||||
|
from unilabos.devices.LaiYu_Liquid import (
|
||||||
|
create_quick_setup,
|
||||||
|
create_96_well_plate,
|
||||||
|
create_laiyu_backend
|
||||||
|
)
|
||||||
|
|
||||||
|
# 快速创建默认资源
|
||||||
|
resources = create_quick_setup()
|
||||||
|
print(f"创建了以下资源: {list(resources.keys())}")
|
||||||
|
|
||||||
|
# 创建96孔板
|
||||||
|
plate_96 = create_96_well_plate("test_plate")
|
||||||
|
print(f"96孔板包含 {len(plate_96.children)} 个孔位")
|
||||||
|
|
||||||
|
# 创建后端实例(用于PyLabRobot集成)
|
||||||
|
backend = create_laiyu_backend("LaiYu_Device")
|
||||||
|
print(f"后端设备: {backend.name}")
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 后端驱动使用
|
||||||
|
|
||||||
|
```python
|
||||||
|
from unilabos.devices.laiyu_liquid.backend import create_laiyu_backend
|
||||||
|
|
||||||
|
# 创建后端实例
|
||||||
|
backend = create_laiyu_backend("LaiYu_Liquid_Station")
|
||||||
|
|
||||||
|
# 连接设备
|
||||||
|
await backend.connect()
|
||||||
|
|
||||||
|
# 设备归位
|
||||||
|
await backend.home_device()
|
||||||
|
|
||||||
|
# 获取设备状态
|
||||||
|
status = await backend.get_status()
|
||||||
|
print(f"设备状态: {status}")
|
||||||
|
|
||||||
|
# 断开连接
|
||||||
|
await backend.disconnect()
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. 资源管理示例
|
||||||
|
|
||||||
|
```python
|
||||||
|
from unilabos.devices.LaiYu_Liquid import (
|
||||||
|
create_centrifuge_tube_rack,
|
||||||
|
create_tip_rack,
|
||||||
|
load_deck_config
|
||||||
|
)
|
||||||
|
|
||||||
|
# 加载工作台配置
|
||||||
|
deck_config = load_deck_config()
|
||||||
|
print(f"工作台尺寸: {deck_config['size_x']}x{deck_config['size_y']}mm")
|
||||||
|
|
||||||
|
# 创建不同类型的资源
|
||||||
|
tube_rack = create_centrifuge_tube_rack("sample_rack")
|
||||||
|
tip_rack = create_tip_rack("tip_rack_200ul")
|
||||||
|
|
||||||
|
print(f"离心管架: {tube_rack.name}, 容量: {len(tube_rack.children)} 个位置")
|
||||||
|
print(f"枪头架: {tip_rack.name}, 容量: {len(tip_rack.children)} 个枪头")
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔍 技术架构
|
||||||
|
|
||||||
|
### 坐标系统
|
||||||
|
- **机械坐标**: 基于步进电机的原始坐标系统
|
||||||
|
- **工作坐标**: 用户友好的实验室坐标系统
|
||||||
|
- **自动转换**: 透明的坐标系转换和校准
|
||||||
|
|
||||||
|
### 通信协议
|
||||||
|
- **RS485总线**: 高可靠性工业通信标准
|
||||||
|
- **Modbus协议**: 标准化的设备通信协议
|
||||||
|
- **错误检测**: 完整的通信错误检测和恢复
|
||||||
|
|
||||||
|
### 安全机制
|
||||||
|
- **限位保护**: 硬件和软件双重限位保护
|
||||||
|
- **紧急停止**: 即时停止所有运动和操作
|
||||||
|
- **状态监控**: 实时设备状态监控和报警
|
||||||
|
|
||||||
|
## 🧪 验证和测试
|
||||||
|
|
||||||
|
### 功能验证
|
||||||
|
```python
|
||||||
|
# 验证模块安装
|
||||||
|
from unilabos.devices.laiyu_liquid import validate_installation
|
||||||
|
validate_installation()
|
||||||
|
|
||||||
|
# 查看模块信息
|
||||||
|
from unilabos.devices.laiyu_liquid import print_module_info
|
||||||
|
print_module_info()
|
||||||
|
```
|
||||||
|
|
||||||
|
### 硬件连接测试
|
||||||
|
```python
|
||||||
|
# 测试SOPA移液器连接
|
||||||
|
from unilabos.devices.laiyu_liquid.drivers import SOPAPipette, SOPAConfig
|
||||||
|
|
||||||
|
config = SOPAConfig(port="/dev/cu.usbserial-3130", address=4)
|
||||||
|
pipette = SOPAPipette(config)
|
||||||
|
success = pipette.connect()
|
||||||
|
print(f"SOPA连接状态: {'成功' if success else '失败'}")
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📚 维护和支持
|
||||||
|
|
||||||
|
### 日志记录
|
||||||
|
- **结构化日志**: 使用Python logging模块的专业日志记录
|
||||||
|
- **错误追踪**: 详细的错误信息和堆栈跟踪
|
||||||
|
- **性能监控**: 操作时间和性能指标记录
|
||||||
|
|
||||||
|
### 配置管理
|
||||||
|
- **JSON配置**: 灵活的JSON格式配置文件
|
||||||
|
- **参数验证**: 自动配置参数验证和错误提示
|
||||||
|
- **热重载**: 支持配置文件的动态重载
|
||||||
|
|
||||||
|
### 扩展性
|
||||||
|
- **模块化设计**: 易于扩展和定制的模块化架构
|
||||||
|
- **插件接口**: 支持第三方插件和扩展
|
||||||
|
- **API兼容**: 向后兼容的API设计
|
||||||
|
|
||||||
|
|
||||||
30
unilabos/devices/laiyu_liquid/drivers/__init__.py
Normal file
30
unilabos/devices/laiyu_liquid/drivers/__init__.py
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
"""
|
||||||
|
LaiYu_Liquid 驱动程序模块
|
||||||
|
|
||||||
|
该模块包含了LaiYu_Liquid液体处理工作站的硬件驱动程序:
|
||||||
|
- SOPA移液器驱动程序
|
||||||
|
- XYZ步进电机驱动程序
|
||||||
|
"""
|
||||||
|
|
||||||
|
# SOPA移液器驱动程序导入
|
||||||
|
from .sopa_pipette_driver import SOPAPipette, SOPAConfig, SOPAStatusCode
|
||||||
|
|
||||||
|
# XYZ步进电机驱动程序导入
|
||||||
|
from .xyz_stepper_driver import StepperMotorDriver, XYZStepperController, MotorAxis, MotorStatus
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
# SOPA移液器
|
||||||
|
"SOPAPipette",
|
||||||
|
"SOPAConfig",
|
||||||
|
"SOPAStatusCode",
|
||||||
|
|
||||||
|
# XYZ步进电机
|
||||||
|
"StepperMotorDriver",
|
||||||
|
"XYZStepperController",
|
||||||
|
"MotorAxis",
|
||||||
|
"MotorStatus",
|
||||||
|
]
|
||||||
|
|
||||||
|
__version__ = "1.0.0"
|
||||||
|
__author__ = "LaiYu_Liquid Driver Team"
|
||||||
|
__description__ = "LaiYu_Liquid 硬件驱动程序集合"
|
||||||
1079
unilabos/devices/laiyu_liquid/drivers/sopa_pipette_driver.py
Normal file
1079
unilabos/devices/laiyu_liquid/drivers/sopa_pipette_driver.py
Normal file
File diff suppressed because it is too large
Load Diff
663
unilabos/devices/laiyu_liquid/drivers/xyz_stepper_driver.py
Normal file
663
unilabos/devices/laiyu_liquid/drivers/xyz_stepper_driver.py
Normal file
@@ -0,0 +1,663 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
XYZ三轴步进电机B系列驱动程序
|
||||||
|
支持RS485通信,Modbus协议
|
||||||
|
"""
|
||||||
|
|
||||||
|
import serial
|
||||||
|
import struct
|
||||||
|
import time
|
||||||
|
import logging
|
||||||
|
from typing import Optional, Tuple, Dict, Any
|
||||||
|
from enum import Enum
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
# 配置日志
|
||||||
|
logging.basicConfig(level=logging.INFO)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class MotorAxis(Enum):
|
||||||
|
"""电机轴枚举"""
|
||||||
|
X = 1
|
||||||
|
Y = 2
|
||||||
|
Z = 3
|
||||||
|
|
||||||
|
|
||||||
|
class MotorStatus(Enum):
|
||||||
|
"""电机状态枚举"""
|
||||||
|
STANDBY = 0x0000 # 待机/到位
|
||||||
|
RUNNING = 0x0001 # 运行中
|
||||||
|
COLLISION_STOP = 0x0002 # 碰撞停
|
||||||
|
FORWARD_LIMIT_STOP = 0x0003 # 正光电停
|
||||||
|
REVERSE_LIMIT_STOP = 0x0004 # 反光电停
|
||||||
|
|
||||||
|
|
||||||
|
class ModbusFunction(Enum):
|
||||||
|
"""Modbus功能码"""
|
||||||
|
READ_HOLDING_REGISTERS = 0x03
|
||||||
|
WRITE_SINGLE_REGISTER = 0x06
|
||||||
|
WRITE_MULTIPLE_REGISTERS = 0x10
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class MotorPosition:
|
||||||
|
"""电机位置信息"""
|
||||||
|
steps: int
|
||||||
|
speed: int
|
||||||
|
current: int
|
||||||
|
status: MotorStatus
|
||||||
|
|
||||||
|
|
||||||
|
class ModbusException(Exception):
|
||||||
|
"""Modbus通信异常"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class StepperMotorDriver:
|
||||||
|
"""步进电机驱动器基类"""
|
||||||
|
|
||||||
|
# 寄存器地址常量
|
||||||
|
REG_STATUS = 0x00
|
||||||
|
REG_POSITION_HIGH = 0x01
|
||||||
|
REG_POSITION_LOW = 0x02
|
||||||
|
REG_ACTUAL_SPEED = 0x03
|
||||||
|
REG_EMERGENCY_STOP = 0x04
|
||||||
|
REG_CURRENT = 0x05
|
||||||
|
REG_ENABLE = 0x06
|
||||||
|
REG_PWM_OUTPUT = 0x07
|
||||||
|
REG_ZERO_SINGLE = 0x0E
|
||||||
|
REG_ZERO_COMMAND = 0x0F
|
||||||
|
|
||||||
|
# 位置模式寄存器
|
||||||
|
REG_TARGET_POSITION_HIGH = 0x10
|
||||||
|
REG_TARGET_POSITION_LOW = 0x11
|
||||||
|
REG_POSITION_SPEED = 0x13
|
||||||
|
REG_POSITION_ACCELERATION = 0x14
|
||||||
|
REG_POSITION_PRECISION = 0x15
|
||||||
|
|
||||||
|
# 速度模式寄存器
|
||||||
|
REG_SPEED_MODE_SPEED = 0x61
|
||||||
|
REG_SPEED_MODE_ACCELERATION = 0x62
|
||||||
|
|
||||||
|
# 设备参数寄存器
|
||||||
|
REG_DEVICE_ADDRESS = 0xE0
|
||||||
|
REG_DEFAULT_SPEED = 0xE7
|
||||||
|
REG_DEFAULT_ACCELERATION = 0xE8
|
||||||
|
|
||||||
|
def __init__(self, port: str, baudrate: int = 115200, timeout: float = 1.0):
|
||||||
|
"""
|
||||||
|
初始化步进电机驱动器
|
||||||
|
|
||||||
|
Args:
|
||||||
|
port: 串口端口名
|
||||||
|
baudrate: 波特率
|
||||||
|
timeout: 通信超时时间
|
||||||
|
"""
|
||||||
|
self.port = port
|
||||||
|
self.baudrate = baudrate
|
||||||
|
self.timeout = timeout
|
||||||
|
self.serial_conn: Optional[serial.Serial] = None
|
||||||
|
|
||||||
|
def connect(self) -> bool:
|
||||||
|
"""
|
||||||
|
建立串口连接
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
连接是否成功
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
self.serial_conn = serial.Serial(
|
||||||
|
port=self.port,
|
||||||
|
baudrate=self.baudrate,
|
||||||
|
bytesize=serial.EIGHTBITS,
|
||||||
|
parity=serial.PARITY_NONE,
|
||||||
|
stopbits=serial.STOPBITS_ONE,
|
||||||
|
timeout=self.timeout
|
||||||
|
)
|
||||||
|
logger.info(f"已连接到串口: {self.port}")
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"串口连接失败: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def disconnect(self) -> None:
|
||||||
|
"""关闭串口连接"""
|
||||||
|
if self.serial_conn and self.serial_conn.is_open:
|
||||||
|
self.serial_conn.close()
|
||||||
|
logger.info("串口连接已关闭")
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
"""上下文管理器入口"""
|
||||||
|
if self.connect():
|
||||||
|
return self
|
||||||
|
raise ModbusException("无法建立串口连接")
|
||||||
|
|
||||||
|
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||||
|
"""上下文管理器出口"""
|
||||||
|
self.disconnect()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def calculate_crc(data: bytes) -> bytes:
|
||||||
|
"""
|
||||||
|
计算Modbus CRC校验码
|
||||||
|
|
||||||
|
Args:
|
||||||
|
data: 待校验的数据
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
CRC校验码 (2字节)
|
||||||
|
"""
|
||||||
|
crc = 0xFFFF
|
||||||
|
for byte in data:
|
||||||
|
crc ^= byte
|
||||||
|
for _ in range(8):
|
||||||
|
if crc & 0x0001:
|
||||||
|
crc >>= 1
|
||||||
|
crc ^= 0xA001
|
||||||
|
else:
|
||||||
|
crc >>= 1
|
||||||
|
return struct.pack('<H', crc)
|
||||||
|
|
||||||
|
def _send_command(self, slave_addr: int, data: bytes) -> bytes:
|
||||||
|
"""
|
||||||
|
发送Modbus命令并接收响应
|
||||||
|
|
||||||
|
Args:
|
||||||
|
slave_addr: 从站地址
|
||||||
|
data: 命令数据
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
响应数据
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ModbusException: 通信异常
|
||||||
|
"""
|
||||||
|
if not self.serial_conn or not self.serial_conn.is_open:
|
||||||
|
raise ModbusException("串口未连接")
|
||||||
|
|
||||||
|
# 构建完整命令
|
||||||
|
command = bytes([slave_addr]) + data
|
||||||
|
crc = self.calculate_crc(command)
|
||||||
|
full_command = command + crc
|
||||||
|
|
||||||
|
# 清空接收缓冲区
|
||||||
|
self.serial_conn.reset_input_buffer()
|
||||||
|
|
||||||
|
# 发送命令
|
||||||
|
self.serial_conn.write(full_command)
|
||||||
|
logger.debug(f"发送命令: {' '.join(f'{b:02X}' for b in full_command)}")
|
||||||
|
|
||||||
|
# 等待响应
|
||||||
|
time.sleep(0.01) # 短暂延时
|
||||||
|
|
||||||
|
# 读取响应
|
||||||
|
response = self.serial_conn.read(256) # 最大读取256字节
|
||||||
|
if not response:
|
||||||
|
raise ModbusException("未收到响应")
|
||||||
|
|
||||||
|
logger.debug(f"接收响应: {' '.join(f'{b:02X}' for b in response)}")
|
||||||
|
|
||||||
|
# 验证CRC
|
||||||
|
if len(response) < 3:
|
||||||
|
raise ModbusException("响应数据长度不足")
|
||||||
|
|
||||||
|
data_part = response[:-2]
|
||||||
|
received_crc = response[-2:]
|
||||||
|
calculated_crc = self.calculate_crc(data_part)
|
||||||
|
|
||||||
|
if received_crc != calculated_crc:
|
||||||
|
raise ModbusException("CRC校验失败")
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
|
def read_registers(self, slave_addr: int, start_addr: int, count: int) -> list:
|
||||||
|
"""
|
||||||
|
读取保持寄存器
|
||||||
|
|
||||||
|
Args:
|
||||||
|
slave_addr: 从站地址
|
||||||
|
start_addr: 起始地址
|
||||||
|
count: 寄存器数量
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
寄存器值列表
|
||||||
|
"""
|
||||||
|
data = struct.pack('>BHH', ModbusFunction.READ_HOLDING_REGISTERS.value, start_addr, count)
|
||||||
|
response = self._send_command(slave_addr, data)
|
||||||
|
|
||||||
|
if len(response) < 5:
|
||||||
|
raise ModbusException("响应长度不足")
|
||||||
|
|
||||||
|
if response[1] != ModbusFunction.READ_HOLDING_REGISTERS.value:
|
||||||
|
raise ModbusException(f"功能码错误: {response[1]:02X}")
|
||||||
|
|
||||||
|
byte_count = response[2]
|
||||||
|
values = []
|
||||||
|
for i in range(0, byte_count, 2):
|
||||||
|
value = struct.unpack('>H', response[3+i:5+i])[0]
|
||||||
|
values.append(value)
|
||||||
|
|
||||||
|
return values
|
||||||
|
|
||||||
|
def write_single_register(self, slave_addr: int, addr: int, value: int) -> bool:
|
||||||
|
"""
|
||||||
|
写入单个寄存器
|
||||||
|
|
||||||
|
Args:
|
||||||
|
slave_addr: 从站地址
|
||||||
|
addr: 寄存器地址
|
||||||
|
value: 寄存器值
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
写入是否成功
|
||||||
|
"""
|
||||||
|
data = struct.pack('>BHH', ModbusFunction.WRITE_SINGLE_REGISTER.value, addr, value)
|
||||||
|
response = self._send_command(slave_addr, data)
|
||||||
|
|
||||||
|
return len(response) >= 8 and response[1] == ModbusFunction.WRITE_SINGLE_REGISTER.value
|
||||||
|
|
||||||
|
def write_multiple_registers(self, slave_addr: int, start_addr: int, values: list) -> bool:
|
||||||
|
"""
|
||||||
|
写入多个寄存器
|
||||||
|
|
||||||
|
Args:
|
||||||
|
slave_addr: 从站地址
|
||||||
|
start_addr: 起始地址
|
||||||
|
values: 寄存器值列表
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
写入是否成功
|
||||||
|
"""
|
||||||
|
byte_count = len(values) * 2
|
||||||
|
data = struct.pack('>BHHB', ModbusFunction.WRITE_MULTIPLE_REGISTERS.value,
|
||||||
|
start_addr, len(values), byte_count)
|
||||||
|
|
||||||
|
for value in values:
|
||||||
|
data += struct.pack('>H', value)
|
||||||
|
|
||||||
|
response = self._send_command(slave_addr, data)
|
||||||
|
|
||||||
|
return len(response) >= 8 and response[1] == ModbusFunction.WRITE_MULTIPLE_REGISTERS.value
|
||||||
|
|
||||||
|
|
||||||
|
class XYZStepperController(StepperMotorDriver):
|
||||||
|
"""XYZ三轴步进电机控制器"""
|
||||||
|
|
||||||
|
# 电机配置常量
|
||||||
|
STEPS_PER_REVOLUTION = 16384 # 每圈步数
|
||||||
|
|
||||||
|
def __init__(self, port: str, baudrate: int = 115200, timeout: float = 1.0):
|
||||||
|
"""
|
||||||
|
初始化XYZ三轴步进电机控制器
|
||||||
|
|
||||||
|
Args:
|
||||||
|
port: 串口端口名
|
||||||
|
baudrate: 波特率
|
||||||
|
timeout: 通信超时时间
|
||||||
|
"""
|
||||||
|
super().__init__(port, baudrate, timeout)
|
||||||
|
self.axis_addresses = {
|
||||||
|
MotorAxis.X: 1,
|
||||||
|
MotorAxis.Y: 2,
|
||||||
|
MotorAxis.Z: 3
|
||||||
|
}
|
||||||
|
|
||||||
|
def degrees_to_steps(self, degrees: float) -> int:
|
||||||
|
"""
|
||||||
|
将角度转换为步数
|
||||||
|
|
||||||
|
Args:
|
||||||
|
degrees: 角度值
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
对应的步数
|
||||||
|
"""
|
||||||
|
return int(degrees * self.STEPS_PER_REVOLUTION / 360.0)
|
||||||
|
|
||||||
|
def steps_to_degrees(self, steps: int) -> float:
|
||||||
|
"""
|
||||||
|
将步数转换为角度
|
||||||
|
|
||||||
|
Args:
|
||||||
|
steps: 步数
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
对应的角度值
|
||||||
|
"""
|
||||||
|
return steps * 360.0 / self.STEPS_PER_REVOLUTION
|
||||||
|
|
||||||
|
def revolutions_to_steps(self, revolutions: float) -> int:
|
||||||
|
"""
|
||||||
|
将圈数转换为步数
|
||||||
|
|
||||||
|
Args:
|
||||||
|
revolutions: 圈数
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
对应的步数
|
||||||
|
"""
|
||||||
|
return int(revolutions * self.STEPS_PER_REVOLUTION)
|
||||||
|
|
||||||
|
def steps_to_revolutions(self, steps: int) -> float:
|
||||||
|
"""
|
||||||
|
将步数转换为圈数
|
||||||
|
|
||||||
|
Args:
|
||||||
|
steps: 步数
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
对应的圈数
|
||||||
|
"""
|
||||||
|
return steps / self.STEPS_PER_REVOLUTION
|
||||||
|
|
||||||
|
def get_motor_status(self, axis: MotorAxis) -> MotorPosition:
|
||||||
|
"""
|
||||||
|
获取电机状态信息
|
||||||
|
|
||||||
|
Args:
|
||||||
|
axis: 电机轴
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
电机位置信息
|
||||||
|
"""
|
||||||
|
addr = self.axis_addresses[axis]
|
||||||
|
|
||||||
|
# 读取状态、位置、速度、电流
|
||||||
|
values = self.read_registers(addr, self.REG_STATUS, 6)
|
||||||
|
|
||||||
|
status = MotorStatus(values[0])
|
||||||
|
position_high = values[1]
|
||||||
|
position_low = values[2]
|
||||||
|
speed = values[3]
|
||||||
|
current = values[5]
|
||||||
|
|
||||||
|
# 合并32位位置
|
||||||
|
position = (position_high << 16) | position_low
|
||||||
|
# 处理有符号数
|
||||||
|
if position > 0x7FFFFFFF:
|
||||||
|
position -= 0x100000000
|
||||||
|
|
||||||
|
return MotorPosition(position, speed, current, status)
|
||||||
|
|
||||||
|
def emergency_stop(self, axis: MotorAxis) -> bool:
|
||||||
|
"""
|
||||||
|
紧急停止电机
|
||||||
|
|
||||||
|
Args:
|
||||||
|
axis: 电机轴
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
操作是否成功
|
||||||
|
"""
|
||||||
|
addr = self.axis_addresses[axis]
|
||||||
|
return self.write_single_register(addr, self.REG_EMERGENCY_STOP, 0x0000)
|
||||||
|
|
||||||
|
def enable_motor(self, axis: MotorAxis, enable: bool = True) -> bool:
|
||||||
|
"""
|
||||||
|
使能/失能电机
|
||||||
|
|
||||||
|
Args:
|
||||||
|
axis: 电机轴
|
||||||
|
enable: True为使能,False为失能
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
操作是否成功
|
||||||
|
"""
|
||||||
|
addr = self.axis_addresses[axis]
|
||||||
|
value = 0x0001 if enable else 0x0000
|
||||||
|
return self.write_single_register(addr, self.REG_ENABLE, value)
|
||||||
|
|
||||||
|
def move_to_position(self, axis: MotorAxis, position: int, speed: int = 5000,
|
||||||
|
acceleration: int = 1000, precision: int = 100) -> bool:
|
||||||
|
"""
|
||||||
|
移动到指定位置
|
||||||
|
|
||||||
|
Args:
|
||||||
|
axis: 电机轴
|
||||||
|
position: 目标位置(步数)
|
||||||
|
speed: 运行速度(rpm)
|
||||||
|
acceleration: 加速度(rpm/s)
|
||||||
|
precision: 到位精度
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
操作是否成功
|
||||||
|
"""
|
||||||
|
addr = self.axis_addresses[axis]
|
||||||
|
|
||||||
|
# 处理32位位置
|
||||||
|
if position < 0:
|
||||||
|
position += 0x100000000
|
||||||
|
|
||||||
|
position_high = (position >> 16) & 0xFFFF
|
||||||
|
position_low = position & 0xFFFF
|
||||||
|
|
||||||
|
values = [
|
||||||
|
position_high, # 目标位置高位
|
||||||
|
position_low, # 目标位置低位
|
||||||
|
0x0000, # 保留
|
||||||
|
speed, # 速度
|
||||||
|
acceleration, # 加速度
|
||||||
|
precision # 精度
|
||||||
|
]
|
||||||
|
|
||||||
|
return self.write_multiple_registers(addr, self.REG_TARGET_POSITION_HIGH, values)
|
||||||
|
|
||||||
|
def set_speed_mode(self, axis: MotorAxis, speed: int, acceleration: int = 1000) -> bool:
|
||||||
|
"""
|
||||||
|
设置速度模式运行
|
||||||
|
|
||||||
|
Args:
|
||||||
|
axis: 电机轴
|
||||||
|
speed: 运行速度(rpm),正值正转,负值反转
|
||||||
|
acceleration: 加速度(rpm/s)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
操作是否成功
|
||||||
|
"""
|
||||||
|
addr = self.axis_addresses[axis]
|
||||||
|
|
||||||
|
# 处理负数
|
||||||
|
if speed < 0:
|
||||||
|
speed = 0x10000 + speed # 补码表示
|
||||||
|
|
||||||
|
values = [0x0000, speed, acceleration, 0x0000]
|
||||||
|
|
||||||
|
return self.write_multiple_registers(addr, 0x60, values)
|
||||||
|
|
||||||
|
def home_axis(self, axis: MotorAxis) -> bool:
|
||||||
|
"""
|
||||||
|
轴归零操作
|
||||||
|
|
||||||
|
Args:
|
||||||
|
axis: 电机轴
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
操作是否成功
|
||||||
|
"""
|
||||||
|
addr = self.axis_addresses[axis]
|
||||||
|
return self.write_single_register(addr, self.REG_ZERO_SINGLE, 0x0001)
|
||||||
|
|
||||||
|
def wait_for_completion(self, axis: MotorAxis, timeout: float = 30.0) -> bool:
|
||||||
|
"""
|
||||||
|
等待电机运动完成
|
||||||
|
|
||||||
|
Args:
|
||||||
|
axis: 电机轴
|
||||||
|
timeout: 超时时间(秒)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
是否在超时前完成
|
||||||
|
"""
|
||||||
|
start_time = time.time()
|
||||||
|
|
||||||
|
while time.time() - start_time < timeout:
|
||||||
|
status = self.get_motor_status(axis)
|
||||||
|
if status.status == MotorStatus.STANDBY:
|
||||||
|
return True
|
||||||
|
time.sleep(0.1)
|
||||||
|
|
||||||
|
logger.warning(f"{axis.name}轴运动超时")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def move_xyz(self, x: Optional[int] = None, y: Optional[int] = None, z: Optional[int] = None,
|
||||||
|
speed: int = 5000, acceleration: int = 1000) -> Dict[MotorAxis, bool]:
|
||||||
|
"""
|
||||||
|
同时控制XYZ轴移动
|
||||||
|
|
||||||
|
Args:
|
||||||
|
x: X轴目标位置
|
||||||
|
y: Y轴目标位置
|
||||||
|
z: Z轴目标位置
|
||||||
|
speed: 运行速度
|
||||||
|
acceleration: 加速度
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
各轴操作结果字典
|
||||||
|
"""
|
||||||
|
results = {}
|
||||||
|
|
||||||
|
if x is not None:
|
||||||
|
results[MotorAxis.X] = self.move_to_position(MotorAxis.X, x, speed, acceleration)
|
||||||
|
|
||||||
|
if y is not None:
|
||||||
|
results[MotorAxis.Y] = self.move_to_position(MotorAxis.Y, y, speed, acceleration)
|
||||||
|
|
||||||
|
if z is not None:
|
||||||
|
results[MotorAxis.Z] = self.move_to_position(MotorAxis.Z, z, speed, acceleration)
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
def move_xyz_degrees(self, x_deg: Optional[float] = None, y_deg: Optional[float] = None,
|
||||||
|
z_deg: Optional[float] = None, speed: int = 5000,
|
||||||
|
acceleration: int = 1000) -> Dict[MotorAxis, bool]:
|
||||||
|
"""
|
||||||
|
使用角度值同时移动多个轴到指定位置
|
||||||
|
|
||||||
|
Args:
|
||||||
|
x_deg: X轴目标角度(度)
|
||||||
|
y_deg: Y轴目标角度(度)
|
||||||
|
z_deg: Z轴目标角度(度)
|
||||||
|
speed: 移动速度
|
||||||
|
acceleration: 加速度
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
各轴移动操作结果
|
||||||
|
"""
|
||||||
|
# 将角度转换为步数
|
||||||
|
x_steps = self.degrees_to_steps(x_deg) if x_deg is not None else None
|
||||||
|
y_steps = self.degrees_to_steps(y_deg) if y_deg is not None else None
|
||||||
|
z_steps = self.degrees_to_steps(z_deg) if z_deg is not None else None
|
||||||
|
|
||||||
|
return self.move_xyz(x_steps, y_steps, z_steps, speed, acceleration)
|
||||||
|
|
||||||
|
def move_xyz_revolutions(self, x_rev: Optional[float] = None, y_rev: Optional[float] = None,
|
||||||
|
z_rev: Optional[float] = None, speed: int = 5000,
|
||||||
|
acceleration: int = 1000) -> Dict[MotorAxis, bool]:
|
||||||
|
"""
|
||||||
|
使用圈数值同时移动多个轴到指定位置
|
||||||
|
|
||||||
|
Args:
|
||||||
|
x_rev: X轴目标圈数
|
||||||
|
y_rev: Y轴目标圈数
|
||||||
|
z_rev: Z轴目标圈数
|
||||||
|
speed: 移动速度
|
||||||
|
acceleration: 加速度
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
各轴移动操作结果
|
||||||
|
"""
|
||||||
|
# 将圈数转换为步数
|
||||||
|
x_steps = self.revolutions_to_steps(x_rev) if x_rev is not None else None
|
||||||
|
y_steps = self.revolutions_to_steps(y_rev) if y_rev is not None else None
|
||||||
|
z_steps = self.revolutions_to_steps(z_rev) if z_rev is not None else None
|
||||||
|
|
||||||
|
return self.move_xyz(x_steps, y_steps, z_steps, speed, acceleration)
|
||||||
|
|
||||||
|
def move_to_position_degrees(self, axis: MotorAxis, degrees: float, speed: int = 5000,
|
||||||
|
acceleration: int = 1000, precision: int = 100) -> bool:
|
||||||
|
"""
|
||||||
|
使用角度值移动单个轴到指定位置
|
||||||
|
|
||||||
|
Args:
|
||||||
|
axis: 电机轴
|
||||||
|
degrees: 目标角度(度)
|
||||||
|
speed: 移动速度
|
||||||
|
acceleration: 加速度
|
||||||
|
precision: 精度
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
移动操作是否成功
|
||||||
|
"""
|
||||||
|
steps = self.degrees_to_steps(degrees)
|
||||||
|
return self.move_to_position(axis, steps, speed, acceleration, precision)
|
||||||
|
|
||||||
|
def move_to_position_revolutions(self, axis: MotorAxis, revolutions: float, speed: int = 5000,
|
||||||
|
acceleration: int = 1000, precision: int = 100) -> bool:
|
||||||
|
"""
|
||||||
|
使用圈数值移动单个轴到指定位置
|
||||||
|
|
||||||
|
Args:
|
||||||
|
axis: 电机轴
|
||||||
|
revolutions: 目标圈数
|
||||||
|
speed: 移动速度
|
||||||
|
acceleration: 加速度
|
||||||
|
precision: 精度
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
移动操作是否成功
|
||||||
|
"""
|
||||||
|
steps = self.revolutions_to_steps(revolutions)
|
||||||
|
return self.move_to_position(axis, steps, speed, acceleration, precision)
|
||||||
|
|
||||||
|
def stop_all_axes(self) -> Dict[MotorAxis, bool]:
|
||||||
|
"""
|
||||||
|
紧急停止所有轴
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
各轴停止结果字典
|
||||||
|
"""
|
||||||
|
results = {}
|
||||||
|
for axis in MotorAxis:
|
||||||
|
results[axis] = self.emergency_stop(axis)
|
||||||
|
return results
|
||||||
|
|
||||||
|
def enable_all_axes(self, enable: bool = True) -> Dict[MotorAxis, bool]:
|
||||||
|
"""
|
||||||
|
使能/失能所有轴
|
||||||
|
|
||||||
|
Args:
|
||||||
|
enable: True为使能,False为失能
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
各轴操作结果字典
|
||||||
|
"""
|
||||||
|
results = {}
|
||||||
|
for axis in MotorAxis:
|
||||||
|
results[axis] = self.enable_motor(axis, enable)
|
||||||
|
return results
|
||||||
|
|
||||||
|
def get_all_positions(self) -> Dict[MotorAxis, MotorPosition]:
|
||||||
|
"""
|
||||||
|
获取所有轴的位置信息
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
各轴位置信息字典
|
||||||
|
"""
|
||||||
|
positions = {}
|
||||||
|
for axis in MotorAxis:
|
||||||
|
positions[axis] = self.get_motor_status(axis)
|
||||||
|
return positions
|
||||||
|
|
||||||
|
def home_all_axes(self) -> Dict[MotorAxis, bool]:
|
||||||
|
"""
|
||||||
|
所有轴归零
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
各轴归零结果字典
|
||||||
|
"""
|
||||||
|
results = {}
|
||||||
|
for axis in MotorAxis:
|
||||||
|
results[axis] = self.home_axis(axis)
|
||||||
|
return results
|
||||||
13
unilabos/devices/laiyu_liquid/tests/__init__.py
Normal file
13
unilabos/devices/laiyu_liquid/tests/__init__.py
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
LaiYu液体处理设备测试模块
|
||||||
|
|
||||||
|
该模块包含LaiYu液体处理设备的测试用例:
|
||||||
|
- test_deck_config.py: 工作台配置测试
|
||||||
|
|
||||||
|
作者: UniLab团队
|
||||||
|
版本: 2.0.0
|
||||||
|
"""
|
||||||
|
|
||||||
|
__all__ = []
|
||||||
315
unilabos/devices/laiyu_liquid/tests/test_deck_config.py
Normal file
315
unilabos/devices/laiyu_liquid/tests/test_deck_config.py
Normal file
@@ -0,0 +1,315 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
测试脚本:验证更新后的deck配置是否正常工作
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
import json
|
||||||
|
|
||||||
|
# 添加项目根目录到Python路径
|
||||||
|
project_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
sys.path.insert(0, project_root)
|
||||||
|
|
||||||
|
def test_config_loading():
|
||||||
|
"""测试配置文件加载功能"""
|
||||||
|
print("=" * 50)
|
||||||
|
print("测试配置文件加载功能")
|
||||||
|
print("=" * 50)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 直接测试配置文件加载
|
||||||
|
config_path = os.path.join(os.path.dirname(__file__), "controllers", "deckconfig.json")
|
||||||
|
fallback_path = os.path.join(os.path.dirname(__file__), "config", "deck.json")
|
||||||
|
|
||||||
|
config = None
|
||||||
|
config_source = ""
|
||||||
|
|
||||||
|
if os.path.exists(config_path):
|
||||||
|
with open(config_path, 'r', encoding='utf-8') as f:
|
||||||
|
config = json.load(f)
|
||||||
|
config_source = "config/deckconfig.json"
|
||||||
|
elif os.path.exists(fallback_path):
|
||||||
|
with open(fallback_path, 'r', encoding='utf-8') as f:
|
||||||
|
config = json.load(f)
|
||||||
|
config_source = "config/deck.json"
|
||||||
|
else:
|
||||||
|
print("❌ 配置文件不存在")
|
||||||
|
return False
|
||||||
|
|
||||||
|
print(f"✅ 配置文件加载成功: {config_source}")
|
||||||
|
print(f" - 甲板尺寸: {config.get('size_x', 'N/A')} x {config.get('size_y', 'N/A')} x {config.get('size_z', 'N/A')}")
|
||||||
|
print(f" - 子模块数量: {len(config.get('children', []))}")
|
||||||
|
|
||||||
|
# 检查各个模块是否存在
|
||||||
|
modules = config.get('children', [])
|
||||||
|
module_types = [module.get('type') for module in modules]
|
||||||
|
module_names = [module.get('name') for module in modules]
|
||||||
|
|
||||||
|
print(f" - 模块类型: {', '.join(set(filter(None, module_types)))}")
|
||||||
|
print(f" - 模块名称: {', '.join(filter(None, module_names))}")
|
||||||
|
|
||||||
|
return config
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ 配置文件加载失败: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def test_module_coordinates(config):
|
||||||
|
"""测试各模块的坐标信息"""
|
||||||
|
print("\n" + "=" * 50)
|
||||||
|
print("测试模块坐标信息")
|
||||||
|
print("=" * 50)
|
||||||
|
|
||||||
|
if not config:
|
||||||
|
print("❌ 配置为空,无法测试")
|
||||||
|
return False
|
||||||
|
|
||||||
|
modules = config.get('children', [])
|
||||||
|
|
||||||
|
for module in modules:
|
||||||
|
module_name = module.get('name', '未知模块')
|
||||||
|
module_type = module.get('type', '未知类型')
|
||||||
|
position = module.get('position', {})
|
||||||
|
size = module.get('size', {})
|
||||||
|
|
||||||
|
print(f"\n模块: {module_name} ({module_type})")
|
||||||
|
print(f" - 位置: ({position.get('x', 0)}, {position.get('y', 0)}, {position.get('z', 0)})")
|
||||||
|
print(f" - 尺寸: {size.get('x', 0)} x {size.get('y', 0)} x {size.get('z', 0)}")
|
||||||
|
|
||||||
|
# 检查孔位信息
|
||||||
|
wells = module.get('wells', [])
|
||||||
|
if wells:
|
||||||
|
print(f" - 孔位数量: {len(wells)}")
|
||||||
|
|
||||||
|
# 显示前几个和后几个孔位的坐标
|
||||||
|
sample_wells = wells[:3] + wells[-3:] if len(wells) > 6 else wells
|
||||||
|
for well in sample_wells:
|
||||||
|
well_id = well.get('id', '未知')
|
||||||
|
well_pos = well.get('position', {})
|
||||||
|
print(f" {well_id}: ({well_pos.get('x', 0)}, {well_pos.get('y', 0)}, {well_pos.get('z', 0)})")
|
||||||
|
else:
|
||||||
|
print(f" - 无孔位信息")
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
def test_coordinate_ranges(config):
|
||||||
|
"""测试坐标范围的合理性"""
|
||||||
|
print("\n" + "=" * 50)
|
||||||
|
print("测试坐标范围合理性")
|
||||||
|
print("=" * 50)
|
||||||
|
|
||||||
|
if not config:
|
||||||
|
print("❌ 配置为空,无法测试")
|
||||||
|
return False
|
||||||
|
|
||||||
|
deck_size = {
|
||||||
|
'x': config.get('size_x', 340),
|
||||||
|
'y': config.get('size_y', 250),
|
||||||
|
'z': config.get('size_z', 160)
|
||||||
|
}
|
||||||
|
|
||||||
|
print(f"甲板尺寸: {deck_size['x']} x {deck_size['y']} x {deck_size['z']}")
|
||||||
|
|
||||||
|
modules = config.get('children', [])
|
||||||
|
all_coordinates = []
|
||||||
|
|
||||||
|
for module in modules:
|
||||||
|
module_name = module.get('name', '未知模块')
|
||||||
|
wells = module.get('wells', [])
|
||||||
|
|
||||||
|
for well in wells:
|
||||||
|
well_pos = well.get('position', {})
|
||||||
|
x, y, z = well_pos.get('x', 0), well_pos.get('y', 0), well_pos.get('z', 0)
|
||||||
|
all_coordinates.append((x, y, z, f"{module_name}:{well.get('id', '未知')}"))
|
||||||
|
|
||||||
|
if not all_coordinates:
|
||||||
|
print("❌ 没有找到任何坐标信息")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# 计算坐标范围
|
||||||
|
x_coords = [coord[0] for coord in all_coordinates]
|
||||||
|
y_coords = [coord[1] for coord in all_coordinates]
|
||||||
|
z_coords = [coord[2] for coord in all_coordinates]
|
||||||
|
|
||||||
|
x_range = (min(x_coords), max(x_coords))
|
||||||
|
y_range = (min(y_coords), max(y_coords))
|
||||||
|
z_range = (min(z_coords), max(z_coords))
|
||||||
|
|
||||||
|
print(f"X坐标范围: {x_range[0]:.2f} ~ {x_range[1]:.2f}")
|
||||||
|
print(f"Y坐标范围: {y_range[0]:.2f} ~ {y_range[1]:.2f}")
|
||||||
|
print(f"Z坐标范围: {z_range[0]:.2f} ~ {z_range[1]:.2f}")
|
||||||
|
|
||||||
|
# 检查是否超出甲板范围
|
||||||
|
issues = []
|
||||||
|
if x_range[1] > deck_size['x']:
|
||||||
|
issues.append(f"X坐标超出甲板范围: {x_range[1]} > {deck_size['x']}")
|
||||||
|
if y_range[1] > deck_size['y']:
|
||||||
|
issues.append(f"Y坐标超出甲板范围: {y_range[1]} > {deck_size['y']}")
|
||||||
|
if z_range[1] > deck_size['z']:
|
||||||
|
issues.append(f"Z坐标超出甲板范围: {z_range[1]} > {deck_size['z']}")
|
||||||
|
|
||||||
|
if x_range[0] < 0:
|
||||||
|
issues.append(f"X坐标为负值: {x_range[0]}")
|
||||||
|
if y_range[0] < 0:
|
||||||
|
issues.append(f"Y坐标为负值: {y_range[0]}")
|
||||||
|
if z_range[0] < 0:
|
||||||
|
issues.append(f"Z坐标为负值: {z_range[0]}")
|
||||||
|
|
||||||
|
if issues:
|
||||||
|
print("⚠️ 发现坐标问题:")
|
||||||
|
for issue in issues:
|
||||||
|
print(f" - {issue}")
|
||||||
|
return False
|
||||||
|
else:
|
||||||
|
print("✅ 所有坐标都在合理范围内")
|
||||||
|
return True
|
||||||
|
|
||||||
|
def test_well_spacing(config):
|
||||||
|
"""测试孔位间距的一致性"""
|
||||||
|
print("\n" + "=" * 50)
|
||||||
|
print("测试孔位间距一致性")
|
||||||
|
print("=" * 50)
|
||||||
|
|
||||||
|
if not config:
|
||||||
|
print("❌ 配置为空,无法测试")
|
||||||
|
return False
|
||||||
|
|
||||||
|
modules = config.get('children', [])
|
||||||
|
|
||||||
|
for module in modules:
|
||||||
|
module_name = module.get('name', '未知模块')
|
||||||
|
module_type = module.get('type', '未知类型')
|
||||||
|
wells = module.get('wells', [])
|
||||||
|
|
||||||
|
if len(wells) < 2:
|
||||||
|
continue
|
||||||
|
|
||||||
|
print(f"\n模块: {module_name} ({module_type})")
|
||||||
|
|
||||||
|
# 计算相邻孔位的间距
|
||||||
|
spacings_x = []
|
||||||
|
spacings_y = []
|
||||||
|
|
||||||
|
# 按行列排序孔位
|
||||||
|
wells_by_row = {}
|
||||||
|
for well in wells:
|
||||||
|
well_id = well.get('id', '')
|
||||||
|
if len(well_id) >= 3: # 如A01格式
|
||||||
|
row = well_id[0]
|
||||||
|
col = int(well_id[1:])
|
||||||
|
if row not in wells_by_row:
|
||||||
|
wells_by_row[row] = {}
|
||||||
|
wells_by_row[row][col] = well
|
||||||
|
|
||||||
|
# 计算同行相邻孔位的X间距
|
||||||
|
for row, cols in wells_by_row.items():
|
||||||
|
sorted_cols = sorted(cols.keys())
|
||||||
|
for i in range(len(sorted_cols) - 1):
|
||||||
|
col1, col2 = sorted_cols[i], sorted_cols[i + 1]
|
||||||
|
if col2 == col1 + 1: # 相邻列
|
||||||
|
pos1 = cols[col1].get('position', {})
|
||||||
|
pos2 = cols[col2].get('position', {})
|
||||||
|
spacing = abs(pos2.get('x', 0) - pos1.get('x', 0))
|
||||||
|
spacings_x.append(spacing)
|
||||||
|
|
||||||
|
# 计算同列相邻孔位的Y间距
|
||||||
|
cols_by_row = {}
|
||||||
|
for well in wells:
|
||||||
|
well_id = well.get('id', '')
|
||||||
|
if len(well_id) >= 3:
|
||||||
|
row = ord(well_id[0]) - ord('A')
|
||||||
|
col = int(well_id[1:])
|
||||||
|
if col not in cols_by_row:
|
||||||
|
cols_by_row[col] = {}
|
||||||
|
cols_by_row[col][row] = well
|
||||||
|
|
||||||
|
for col, rows in cols_by_row.items():
|
||||||
|
sorted_rows = sorted(rows.keys())
|
||||||
|
for i in range(len(sorted_rows) - 1):
|
||||||
|
row1, row2 = sorted_rows[i], sorted_rows[i + 1]
|
||||||
|
if row2 == row1 + 1: # 相邻行
|
||||||
|
pos1 = rows[row1].get('position', {})
|
||||||
|
pos2 = rows[row2].get('position', {})
|
||||||
|
spacing = abs(pos2.get('y', 0) - pos1.get('y', 0))
|
||||||
|
spacings_y.append(spacing)
|
||||||
|
|
||||||
|
# 检查间距一致性
|
||||||
|
if spacings_x:
|
||||||
|
avg_x = sum(spacings_x) / len(spacings_x)
|
||||||
|
max_diff_x = max(abs(s - avg_x) for s in spacings_x)
|
||||||
|
print(f" - X方向平均间距: {avg_x:.2f}mm, 最大偏差: {max_diff_x:.2f}mm")
|
||||||
|
|
||||||
|
if spacings_y:
|
||||||
|
avg_y = sum(spacings_y) / len(spacings_y)
|
||||||
|
max_diff_y = max(abs(s - avg_y) for s in spacings_y)
|
||||||
|
print(f" - Y方向平均间距: {avg_y:.2f}mm, 最大偏差: {max_diff_y:.2f}mm")
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""主测试函数"""
|
||||||
|
print("LaiYu液体处理设备配置测试")
|
||||||
|
print("测试时间:", os.popen('date').read().strip())
|
||||||
|
|
||||||
|
# 运行所有测试
|
||||||
|
tests = [
|
||||||
|
("配置文件加载", test_config_loading),
|
||||||
|
]
|
||||||
|
|
||||||
|
config = None
|
||||||
|
results = []
|
||||||
|
|
||||||
|
for test_name, test_func in tests:
|
||||||
|
try:
|
||||||
|
if test_name == "配置文件加载":
|
||||||
|
result = test_func()
|
||||||
|
config = result if result else None
|
||||||
|
results.append((test_name, bool(result)))
|
||||||
|
else:
|
||||||
|
result = test_func(config)
|
||||||
|
results.append((test_name, result))
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ 测试 {test_name} 执行失败: {e}")
|
||||||
|
results.append((test_name, False))
|
||||||
|
|
||||||
|
# 如果配置加载成功,运行其他测试
|
||||||
|
if config:
|
||||||
|
additional_tests = [
|
||||||
|
("模块坐标信息", test_module_coordinates),
|
||||||
|
("坐标范围合理性", test_coordinate_ranges),
|
||||||
|
("孔位间距一致性", test_well_spacing)
|
||||||
|
]
|
||||||
|
|
||||||
|
for test_name, test_func in additional_tests:
|
||||||
|
try:
|
||||||
|
result = test_func(config)
|
||||||
|
results.append((test_name, result))
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ 测试 {test_name} 执行失败: {e}")
|
||||||
|
results.append((test_name, False))
|
||||||
|
|
||||||
|
# 输出测试总结
|
||||||
|
print("\n" + "=" * 50)
|
||||||
|
print("测试总结")
|
||||||
|
print("=" * 50)
|
||||||
|
|
||||||
|
passed = sum(1 for _, result in results if result)
|
||||||
|
total = len(results)
|
||||||
|
|
||||||
|
for test_name, result in results:
|
||||||
|
status = "✅ 通过" if result else "❌ 失败"
|
||||||
|
print(f" {test_name}: {status}")
|
||||||
|
|
||||||
|
print(f"\n总计: {passed}/{total} 个测试通过")
|
||||||
|
|
||||||
|
if passed == total:
|
||||||
|
print("🎉 所有测试通过!配置更新成功。")
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
print("⚠️ 部分测试失败,需要进一步检查。")
|
||||||
|
return False
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
success = main()
|
||||||
|
sys.exit(0 if success else 1)
|
||||||
@@ -923,7 +923,7 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
|
|||||||
spread: Literal["wide", "tight", "custom"] = "wide",
|
spread: Literal["wide", "tight", "custom"] = "wide",
|
||||||
is_96_well: bool = False,
|
is_96_well: bool = False,
|
||||||
mix_stage: Optional[Literal["none", "before", "after", "both"]] = "none",
|
mix_stage: Optional[Literal["none", "before", "after", "both"]] = "none",
|
||||||
mix_times: Optional[List[int]] = None,
|
mix_times: Optional[int] = None,
|
||||||
mix_vol: Optional[int] = None,
|
mix_vol: Optional[int] = None,
|
||||||
mix_rate: Optional[int] = None,
|
mix_rate: Optional[int] = None,
|
||||||
mix_liquid_height: Optional[float] = None,
|
mix_liquid_height: Optional[float] = None,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import asyncio
|
|||||||
import collections
|
import collections
|
||||||
import contextlib
|
import contextlib
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
import socket
|
import socket
|
||||||
import time
|
import time
|
||||||
from typing import Any, List, Dict, Optional, Tuple, TypedDict, Union, Sequence, Iterator, Literal
|
from typing import Any, List, Dict, Optional, Tuple, TypedDict, Union, Sequence, Iterator, Literal
|
||||||
@@ -548,7 +549,7 @@ class PRCXI9300Backend(LiquidHandlerBackend):
|
|||||||
blending_times=0,
|
blending_times=0,
|
||||||
balance_height=0,
|
balance_height=0,
|
||||||
plate_or_hole=f"H{hole_col}-8,T{PlateNo}",
|
plate_or_hole=f"H{hole_col}-8,T{PlateNo}",
|
||||||
hole_numbers="1,2,3,4,5,6,7,8",
|
hole_numbers=f"{(hole_col - 1) * 8 + hole_row}" if self._num_channels == 1 else "1,2,3,4,5",
|
||||||
)
|
)
|
||||||
self.steps_todo_list.append(step)
|
self.steps_todo_list.append(step)
|
||||||
|
|
||||||
@@ -1618,24 +1619,39 @@ if __name__ == "__main__":
|
|||||||
host="192.168.0.121",
|
host="192.168.0.121",
|
||||||
port=9999,
|
port=9999,
|
||||||
timeout=10.0,
|
timeout=10.0,
|
||||||
setup=False,
|
setup=True,
|
||||||
debug=False,
|
debug=False,
|
||||||
matrix_id="5de524d0-3f95-406c-86dd-f83626ebc7cb",
|
matrix_id="5de524d0-3f95-406c-86dd-f83626ebc7cb",
|
||||||
channel_num=1,
|
channel_num=1,
|
||||||
axis="Left",
|
axis="Right",
|
||||||
simulator=False,
|
simulator=False,
|
||||||
is_9320=True,
|
is_9320=True,
|
||||||
) # Initialize the handler with the deck and host settings
|
)
|
||||||
backend: PRCXI9300Backend = handler.backend
|
backend: PRCXI9300Backend = handler.backend
|
||||||
res = backend.api_client.get_all_materials()
|
|
||||||
handler.set_tiprack([plate8]) # Set the tip rack for the handler
|
|
||||||
asyncio.run(handler.setup()) # Initialize the handler and setup the connection
|
|
||||||
from pylabrobot.resources import set_volume_tracking
|
from pylabrobot.resources import set_volume_tracking
|
||||||
|
|
||||||
# from pylabrobot.resources import set_tip_tracking
|
|
||||||
set_volume_tracking(enabled=True)
|
set_volume_tracking(enabled=True)
|
||||||
|
# res = backend.api_client.get_all_materials()
|
||||||
|
asyncio.run(handler.setup()) # Initialize the handler and setup the connection
|
||||||
|
handler.set_tiprack([plate1, plate5]) # Set the tip rack for the handler
|
||||||
|
handler.set_liquid([plate9.get_well("H12")], ["water"], [5])
|
||||||
|
asyncio.run(handler.create_protocol(protocol_name="Test Protocol"))
|
||||||
|
asyncio.run(handler.pick_up_tips([plate5.get_item("C5")], [0]))
|
||||||
|
asyncio.run(handler.aspirate([plate9.get_item("H12")], [5], [0]))
|
||||||
|
|
||||||
|
for well in plate13.get_all_items():
|
||||||
|
# well_pos = well.name.split("_")[1] # 走一行
|
||||||
|
# if well_pos.startswith("A"):
|
||||||
|
if well.name.startswith("PlateT13"): # 走整个Plate
|
||||||
|
asyncio.run(handler.dispense([well], [0.01], [0]))
|
||||||
|
|
||||||
|
# asyncio.run(handler.dispense([plate10.get_item("H12")], [1], [0]))
|
||||||
|
# asyncio.run(handler.dispense([plate13.get_item("A1")], [1], [0]))
|
||||||
|
# asyncio.run(handler.dispense([plate14.get_item("C5")], [1], [0]))
|
||||||
|
asyncio.run(handler.mix([plate10.get_item("H12")], mix_time=3, mix_vol=5))
|
||||||
|
asyncio.run(handler.discard_tips([0]))
|
||||||
|
asyncio.run(handler.run_protocol())
|
||||||
|
time.sleep(5)
|
||||||
|
os._exit(0)
|
||||||
# 第一种情景:一个孔往多个孔加液
|
# 第一种情景:一个孔往多个孔加液
|
||||||
# plate_2_liquids = handler.set_group("water", [plate2.children[0]], [300])
|
# plate_2_liquids = handler.set_group("water", [plate2.children[0]], [300])
|
||||||
# plate5_liquids = handler.set_group("master_mix", plate5.children[:23], [100]*23)
|
# plate5_liquids = handler.set_group("master_mix", plate5.children[:23], [100]*23)
|
||||||
@@ -1652,7 +1668,7 @@ if __name__ == "__main__":
|
|||||||
# # json.dump(A, f, indent=4, ensure_ascii=False)
|
# # json.dump(A, f, indent=4, ensure_ascii=False)
|
||||||
|
|
||||||
# print(plate11.get_well(0).tracker.get_used_volume())
|
# print(plate11.get_well(0).tracker.get_used_volume())
|
||||||
asyncio.run(handler.create_protocol(protocol_name="Test Protocol")) # Initialize the backend and setup the connection
|
# Initialize the backend and setup the connection
|
||||||
asyncio.run(handler.transfer_group("water", "master_mix", 10)) # Reset tip tracking
|
asyncio.run(handler.transfer_group("water", "master_mix", 10)) # Reset tip tracking
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
304
unilabos/devices/liquid_handling/rviz_backend.py
Normal file
304
unilabos/devices/liquid_handling/rviz_backend.py
Normal file
@@ -0,0 +1,304 @@
|
|||||||
|
|
||||||
|
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 UniLiquidHandlerRvizBackend(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 , tip_length: float = 0 , total_height: float = 310):
|
||||||
|
"""Initialize a chatter box backend."""
|
||||||
|
super().__init__()
|
||||||
|
self._num_channels = num_channels
|
||||||
|
self.tip_length = tip_length
|
||||||
|
self.total_height = total_height
|
||||||
|
# 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()
|
||||||
|
|
||||||
|
print("Setting up the liquid handler.")
|
||||||
|
|
||||||
|
async def stop(self):
|
||||||
|
print("Stopping the liquid handler.")
|
||||||
|
|
||||||
|
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):
|
||||||
|
print(f"Resource {resource.name} was assigned to the liquid handler.")
|
||||||
|
|
||||||
|
async def unassigned_resource_callback(self, name: str):
|
||||||
|
print(f"Resource {name} was unassigned from the liquid handler.")
|
||||||
|
|
||||||
|
async def pick_up_tips(self, ops: List[Pickup], use_channels: List[int], **backend_kwargs):
|
||||||
|
print("Picking up tips:")
|
||||||
|
# print(ops.tip)
|
||||||
|
header = (
|
||||||
|
f"{'pip#':<{UniLiquidHandlerRvizBackend._pip_length}} "
|
||||||
|
f"{'resource':<{UniLiquidHandlerRvizBackend._resource_length}} "
|
||||||
|
f"{'offset':<{UniLiquidHandlerRvizBackend._offset_length}} "
|
||||||
|
f"{'tip type':<{UniLiquidHandlerRvizBackend._tip_type_length}} "
|
||||||
|
f"{'max volume (µL)':<{UniLiquidHandlerRvizBackend._max_volume_length}} "
|
||||||
|
f"{'fitting depth (mm)':<{UniLiquidHandlerRvizBackend._fitting_depth_length}} "
|
||||||
|
f"{'tip length (mm)':<{UniLiquidHandlerRvizBackend._tip_length_length}} "
|
||||||
|
# f"{'pickup method':<{ChatterboxBackend._pickup_method_length}} "
|
||||||
|
f"{'filter':<{UniLiquidHandlerRvizBackend._filter_length}}"
|
||||||
|
)
|
||||||
|
# print(header)
|
||||||
|
|
||||||
|
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:]:<{UniLiquidHandlerRvizBackend._resource_length}} "
|
||||||
|
f"{offset:<{UniLiquidHandlerRvizBackend._offset_length}} "
|
||||||
|
f"{op.tip.__class__.__name__:<{UniLiquidHandlerRvizBackend._tip_type_length}} "
|
||||||
|
f"{op.tip.maximal_volume:<{UniLiquidHandlerRvizBackend._max_volume_length}} "
|
||||||
|
f"{op.tip.fitting_depth:<{UniLiquidHandlerRvizBackend._fitting_depth_length}} "
|
||||||
|
f"{op.tip.total_tip_length:<{UniLiquidHandlerRvizBackend._tip_length_length}} "
|
||||||
|
# f"{str(op.tip.pickup_method)[-20:]:<{ChatterboxBackend._pickup_method_length}} "
|
||||||
|
f"{'Yes' if op.tip.has_filter else 'No':<{UniLiquidHandlerRvizBackend._filter_length}}"
|
||||||
|
)
|
||||||
|
# print(row)
|
||||||
|
# print(op.resource.get_absolute_location())
|
||||||
|
|
||||||
|
self.tip_length = ops[0].tip.total_tip_length
|
||||||
|
coordinate = ops[0].resource.get_absolute_location(x="c",y="c")
|
||||||
|
offset_xyz = ops[0].offset
|
||||||
|
x = coordinate.x + offset_xyz.x
|
||||||
|
y = coordinate.y + offset_xyz.y
|
||||||
|
z = self.total_height - (coordinate.z + self.tip_length) + offset_xyz.z
|
||||||
|
# print("moving")
|
||||||
|
self.joint_state_publisher.send_resource_action(ops[0].resource.name, x, y, z, "pick",channels=use_channels)
|
||||||
|
# goback()
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
async def drop_tips(self, ops: List[Drop], use_channels: List[int], **backend_kwargs):
|
||||||
|
print("Dropping tips:")
|
||||||
|
header = (
|
||||||
|
f"{'pip#':<{UniLiquidHandlerRvizBackend._pip_length}} "
|
||||||
|
f"{'resource':<{UniLiquidHandlerRvizBackend._resource_length}} "
|
||||||
|
f"{'offset':<{UniLiquidHandlerRvizBackend._offset_length}} "
|
||||||
|
f"{'tip type':<{UniLiquidHandlerRvizBackend._tip_type_length}} "
|
||||||
|
f"{'max volume (µL)':<{UniLiquidHandlerRvizBackend._max_volume_length}} "
|
||||||
|
f"{'fitting depth (mm)':<{UniLiquidHandlerRvizBackend._fitting_depth_length}} "
|
||||||
|
f"{'tip length (mm)':<{UniLiquidHandlerRvizBackend._tip_length_length}} "
|
||||||
|
# f"{'pickup method':<{ChatterboxBackend._pickup_method_length}} "
|
||||||
|
f"{'filter':<{UniLiquidHandlerRvizBackend._filter_length}}"
|
||||||
|
)
|
||||||
|
# print(header)
|
||||||
|
|
||||||
|
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:]:<{UniLiquidHandlerRvizBackend._resource_length}} "
|
||||||
|
f"{offset:<{UniLiquidHandlerRvizBackend._offset_length}} "
|
||||||
|
f"{op.tip.__class__.__name__:<{UniLiquidHandlerRvizBackend._tip_type_length}} "
|
||||||
|
f"{op.tip.maximal_volume:<{UniLiquidHandlerRvizBackend._max_volume_length}} "
|
||||||
|
f"{op.tip.fitting_depth:<{UniLiquidHandlerRvizBackend._fitting_depth_length}} "
|
||||||
|
f"{op.tip.total_tip_length:<{UniLiquidHandlerRvizBackend._tip_length_length}} "
|
||||||
|
# f"{str(op.tip.pickup_method)[-20:]:<{ChatterboxBackend._pickup_method_length}} "
|
||||||
|
f"{'Yes' if op.tip.has_filter else 'No':<{UniLiquidHandlerRvizBackend._filter_length}}"
|
||||||
|
)
|
||||||
|
# print(row)
|
||||||
|
|
||||||
|
coordinate = ops[0].resource.get_absolute_location(x="c",y="c")
|
||||||
|
offset_xyz = ops[0].offset
|
||||||
|
x = coordinate.x + offset_xyz.x
|
||||||
|
y = coordinate.y + offset_xyz.y
|
||||||
|
z = self.total_height - (coordinate.z + self.tip_length) + offset_xyz.z
|
||||||
|
# print(x, y, z)
|
||||||
|
# print("moving")
|
||||||
|
self.joint_state_publisher.send_resource_action(ops[0].resource.name, x, y, z, "drop_trash",channels=use_channels)
|
||||||
|
# goback()
|
||||||
|
|
||||||
|
async def aspirate(
|
||||||
|
self,
|
||||||
|
ops: List[SingleChannelAspiration],
|
||||||
|
use_channels: List[int],
|
||||||
|
**backend_kwargs,
|
||||||
|
):
|
||||||
|
print("Aspirating:")
|
||||||
|
header = (
|
||||||
|
f"{'pip#':<{UniLiquidHandlerRvizBackend._pip_length}} "
|
||||||
|
f"{'vol(ul)':<{UniLiquidHandlerRvizBackend._vol_length}} "
|
||||||
|
f"{'resource':<{UniLiquidHandlerRvizBackend._resource_length}} "
|
||||||
|
f"{'offset':<{UniLiquidHandlerRvizBackend._offset_length}} "
|
||||||
|
f"{'flow rate':<{UniLiquidHandlerRvizBackend._flow_rate_length}} "
|
||||||
|
f"{'blowout':<{UniLiquidHandlerRvizBackend._blowout_length}} "
|
||||||
|
f"{'lld_z':<{UniLiquidHandlerRvizBackend._lld_z_length}} "
|
||||||
|
# f"{'liquids':<20}" # TODO: add liquids
|
||||||
|
)
|
||||||
|
for key in backend_kwargs:
|
||||||
|
header += f"{key:<{UniLiquidHandlerRvizBackend._kwargs_length}} "[-16:]
|
||||||
|
# print(header)
|
||||||
|
|
||||||
|
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:<{UniLiquidHandlerRvizBackend._vol_length}} "
|
||||||
|
f"{o.resource.name[-20:]:<{UniLiquidHandlerRvizBackend._resource_length}} "
|
||||||
|
f"{offset:<{UniLiquidHandlerRvizBackend._offset_length}} "
|
||||||
|
f"{str(o.flow_rate):<{UniLiquidHandlerRvizBackend._flow_rate_length}} "
|
||||||
|
f"{str(o.blow_out_air_volume):<{UniLiquidHandlerRvizBackend._blowout_length}} "
|
||||||
|
f"{str(o.liquid_height):<{UniLiquidHandlerRvizBackend._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}"
|
||||||
|
# print(row)
|
||||||
|
coordinate = ops[0].resource.get_absolute_location(x="c",y="c")
|
||||||
|
offset_xyz = ops[0].offset
|
||||||
|
x = coordinate.x + offset_xyz.x
|
||||||
|
y = coordinate.y + offset_xyz.y
|
||||||
|
z = self.total_height - (coordinate.z + self.tip_length) + offset_xyz.z
|
||||||
|
# print(x, y, z)
|
||||||
|
# print("moving")
|
||||||
|
self.joint_state_publisher.send_resource_action(ops[0].resource.name, x, y, z, "",channels=use_channels)
|
||||||
|
|
||||||
|
|
||||||
|
async def dispense(
|
||||||
|
self,
|
||||||
|
ops: List[SingleChannelDispense],
|
||||||
|
use_channels: List[int],
|
||||||
|
**backend_kwargs,
|
||||||
|
):
|
||||||
|
# print("Dispensing:")
|
||||||
|
header = (
|
||||||
|
f"{'pip#':<{UniLiquidHandlerRvizBackend._pip_length}} "
|
||||||
|
f"{'vol(ul)':<{UniLiquidHandlerRvizBackend._vol_length}} "
|
||||||
|
f"{'resource':<{UniLiquidHandlerRvizBackend._resource_length}} "
|
||||||
|
f"{'offset':<{UniLiquidHandlerRvizBackend._offset_length}} "
|
||||||
|
f"{'flow rate':<{UniLiquidHandlerRvizBackend._flow_rate_length}} "
|
||||||
|
f"{'blowout':<{UniLiquidHandlerRvizBackend._blowout_length}} "
|
||||||
|
f"{'lld_z':<{UniLiquidHandlerRvizBackend._lld_z_length}} "
|
||||||
|
# f"{'liquids':<20}" # TODO: add liquids
|
||||||
|
)
|
||||||
|
for key in backend_kwargs:
|
||||||
|
header += f"{key:<{UniLiquidHandlerRvizBackend._kwargs_length}} "[-16:]
|
||||||
|
# print(header)
|
||||||
|
|
||||||
|
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:<{UniLiquidHandlerRvizBackend._vol_length}} "
|
||||||
|
f"{o.resource.name[-20:]:<{UniLiquidHandlerRvizBackend._resource_length}} "
|
||||||
|
f"{offset:<{UniLiquidHandlerRvizBackend._offset_length}} "
|
||||||
|
f"{str(o.flow_rate):<{UniLiquidHandlerRvizBackend._flow_rate_length}} "
|
||||||
|
f"{str(o.blow_out_air_volume):<{UniLiquidHandlerRvizBackend._blowout_length}} "
|
||||||
|
f"{str(o.liquid_height):<{UniLiquidHandlerRvizBackend._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:<{UniLiquidHandlerRvizBackend._kwargs_length}}"
|
||||||
|
# print(row)
|
||||||
|
coordinate = ops[0].resource.get_absolute_location(x="c",y="c")
|
||||||
|
offset_xyz = ops[0].offset
|
||||||
|
x = coordinate.x + offset_xyz.x
|
||||||
|
y = coordinate.y + offset_xyz.y
|
||||||
|
z = self.total_height - (coordinate.z + self.tip_length) + offset_xyz.z
|
||||||
|
# print(x, y, z)
|
||||||
|
# print("moving")
|
||||||
|
self.joint_state_publisher.send_resource_action(ops[0].resource.name, x, y, z, "",channels=use_channels)
|
||||||
|
|
||||||
|
async def pick_up_tips96(self, pickup: PickupTipRack, **backend_kwargs):
|
||||||
|
print(f"Picking up tips from {pickup.resource.name}.")
|
||||||
|
|
||||||
|
async def drop_tips96(self, drop: DropTipRack, **backend_kwargs):
|
||||||
|
print(f"Dropping tips to {drop.resource.name}.")
|
||||||
|
|
||||||
|
async def aspirate96(
|
||||||
|
self, aspiration: Union[MultiHeadAspirationPlate, MultiHeadAspirationContainer]
|
||||||
|
):
|
||||||
|
if isinstance(aspiration, MultiHeadAspirationPlate):
|
||||||
|
resource = aspiration.wells[0].parent
|
||||||
|
else:
|
||||||
|
resource = aspiration.container
|
||||||
|
print(f"Aspirating {aspiration.volume} from {resource}.")
|
||||||
|
|
||||||
|
async def dispense96(self, dispense: Union[MultiHeadDispensePlate, MultiHeadDispenseContainer]):
|
||||||
|
if isinstance(dispense, MultiHeadDispensePlate):
|
||||||
|
resource = dispense.wells[0].parent
|
||||||
|
else:
|
||||||
|
resource = dispense.container
|
||||||
|
print(f"Dispensing {dispense.volume} to {resource}.")
|
||||||
|
|
||||||
|
async def pick_up_resource(self, pickup: ResourcePickup):
|
||||||
|
print(f"Picking up resource: {pickup}")
|
||||||
|
|
||||||
|
async def move_picked_up_resource(self, move: ResourceMove):
|
||||||
|
print(f"Moving picked up resource: {move}")
|
||||||
|
|
||||||
|
async def drop_resource(self, drop: ResourceDrop):
|
||||||
|
print(f"Dropping resource: {drop}")
|
||||||
|
|
||||||
|
def can_pick_up_tip(self, channel_idx: int, tip: Tip) -> bool:
|
||||||
|
return True
|
||||||
|
|
||||||
@@ -61,7 +61,6 @@ class ElectrodeSheet(Resource):
|
|||||||
info=None
|
info=None
|
||||||
)
|
)
|
||||||
|
|
||||||
# TODO: 这个还要不要?给self._unilabos_state赋值的?
|
|
||||||
def load_state(self, state: Dict[str, Any]) -> None:
|
def load_state(self, state: Dict[str, Any]) -> None:
|
||||||
"""格式不变"""
|
"""格式不变"""
|
||||||
super().load_state(state)
|
super().load_state(state)
|
||||||
@@ -665,7 +664,6 @@ class BatteryPressSlot(Resource):
|
|||||||
reassign: bool = True,
|
reassign: bool = True,
|
||||||
):
|
):
|
||||||
"""放置极片"""
|
"""放置极片"""
|
||||||
# TODO: 让高京看下槽位只有一个电池时是否这么写。
|
|
||||||
if self.has_battery():
|
if self.has_battery():
|
||||||
raise ValueError(f"槽位已含有一个电池,无法再放置其他电池")
|
raise ValueError(f"槽位已含有一个电池,无法再放置其他电池")
|
||||||
super().assign_child_resource(resource, location, reassign)
|
super().assign_child_resource(resource, location, reassign)
|
||||||
@@ -674,7 +672,6 @@ class BatteryPressSlot(Resource):
|
|||||||
def get_battery_info(self, index: int) -> Battery:
|
def get_battery_info(self, index: int) -> Battery:
|
||||||
return self.children[0]
|
return self.children[0]
|
||||||
|
|
||||||
# TODO:这个移液枪架子看一下从哪继承
|
|
||||||
class TipBox64State(TypedDict):
|
class TipBox64State(TypedDict):
|
||||||
"""电池状态字典"""
|
"""电池状态字典"""
|
||||||
tip_diameter: float = 5.0
|
tip_diameter: float = 5.0
|
||||||
|
|||||||
@@ -1012,7 +1012,7 @@ class CoinCellAssemblyWorkstation(WorkstationBase):
|
|||||||
# else:
|
# else:
|
||||||
# print("子弹夹洞位0没有极片")
|
# print("子弹夹洞位0没有极片")
|
||||||
#
|
#
|
||||||
# # TODO:#把电解液从瓶中取到电池夹子中
|
# #把电解液从瓶中取到电池夹子中
|
||||||
# battery_site = deck.get_resource("battery_press_1")
|
# battery_site = deck.get_resource("battery_press_1")
|
||||||
# clip_magazine_battery = deck.get_resource("clip_magazine_battery")
|
# clip_magazine_battery = deck.get_resource("clip_magazine_battery")
|
||||||
# if battery_site.has_battery():
|
# if battery_site.has_battery():
|
||||||
|
|||||||
1922
unilabos/registry/devices/laiyu_liquid.yaml
Normal file
1922
unilabos/registry/devices/laiyu_liquid.yaml
Normal file
File diff suppressed because it is too large
Load Diff
@@ -131,7 +131,6 @@ class Registry:
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
# todo: support nested keys, switch to non ros message schema
|
|
||||||
"placeholder_keys": {
|
"placeholder_keys": {
|
||||||
"res_id": "unilabos_resources", # 将当前实验室的全部物料id作为下拉框可选择
|
"res_id": "unilabos_resources", # 将当前实验室的全部物料id作为下拉框可选择
|
||||||
"device_id": "unilabos_devices", # 将当前实验室的全部设备id作为下拉框可选择
|
"device_id": "unilabos_devices", # 将当前实验室的全部设备id作为下拉框可选择
|
||||||
|
|||||||
@@ -22,18 +22,6 @@ BIOYOND_PolymerStation_1FlaskCarrier:
|
|||||||
init_param_schema: {}
|
init_param_schema: {}
|
||||||
registry_type: resource
|
registry_type: resource
|
||||||
version: 1.0.0
|
version: 1.0.0
|
||||||
BIOYOND_PolymerStation_6VialCarrier:
|
|
||||||
category:
|
|
||||||
- bottle_carriers
|
|
||||||
class:
|
|
||||||
module: unilabos.resources.bioyond.bottle_carriers:BIOYOND_PolymerStation_6VialCarrier
|
|
||||||
type: pylabrobot
|
|
||||||
description: BIOYOND_PolymerStation_6VialCarrier
|
|
||||||
handles: []
|
|
||||||
icon: ''
|
|
||||||
init_param_schema: {}
|
|
||||||
registry_type: resource
|
|
||||||
version: 1.0.0
|
|
||||||
BIOYOND_PolymerStation_6StockCarrier:
|
BIOYOND_PolymerStation_6StockCarrier:
|
||||||
category:
|
category:
|
||||||
- bottle_carriers
|
- bottle_carriers
|
||||||
@@ -46,4 +34,15 @@ BIOYOND_PolymerStation_6StockCarrier:
|
|||||||
init_param_schema: {}
|
init_param_schema: {}
|
||||||
registry_type: resource
|
registry_type: resource
|
||||||
version: 1.0.0
|
version: 1.0.0
|
||||||
|
BIOYOND_PolymerStation_6VialCarrier:
|
||||||
|
category:
|
||||||
|
- bottle_carriers
|
||||||
|
class:
|
||||||
|
module: unilabos.resources.bioyond.bottle_carriers:BIOYOND_PolymerStation_6VialCarrier
|
||||||
|
type: pylabrobot
|
||||||
|
description: BIOYOND_PolymerStation_6VialCarrier
|
||||||
|
handles: []
|
||||||
|
icon: ''
|
||||||
|
init_param_schema: {}
|
||||||
|
registry_type: resource
|
||||||
|
version: 1.0.0
|
||||||
|
|||||||
@@ -1,24 +1,50 @@
|
|||||||
BIOYOND_PolymerStation_Solid_Stock:
|
|
||||||
class:
|
|
||||||
module: unilabos.resources.bioyond.bottles:BIOYOND_PolymerStation_Solid_Stock
|
|
||||||
type: pylabrobot
|
|
||||||
|
|
||||||
BIOYOND_PolymerStation_Solid_Vial:
|
|
||||||
class:
|
|
||||||
module: unilabos.resources.bioyond.bottles:BIOYOND_PolymerStation_Solid_Vial
|
|
||||||
type: pylabrobot
|
|
||||||
|
|
||||||
BIOYOND_PolymerStation_Liquid_Vial:
|
BIOYOND_PolymerStation_Liquid_Vial:
|
||||||
|
category:
|
||||||
|
- bottles
|
||||||
class:
|
class:
|
||||||
module: unilabos.resources.bioyond.bottles:BIOYOND_PolymerStation_Liquid_Vial
|
module: unilabos.resources.bioyond.bottles:BIOYOND_PolymerStation_Liquid_Vial
|
||||||
type: pylabrobot
|
type: pylabrobot
|
||||||
|
handles: []
|
||||||
BIOYOND_PolymerStation_Solution_Beaker:
|
icon: ''
|
||||||
class:
|
init_param_schema: {}
|
||||||
module: unilabos.resources.bioyond.bottles:BIOYOND_PolymerStation_Solution_Beaker
|
version: 1.0.0
|
||||||
type: pylabrobot
|
|
||||||
|
|
||||||
BIOYOND_PolymerStation_Reagent_Bottle:
|
BIOYOND_PolymerStation_Reagent_Bottle:
|
||||||
|
category:
|
||||||
|
- bottles
|
||||||
class:
|
class:
|
||||||
module: unilabos.resources.bioyond.bottles:BIOYOND_PolymerStation_Reagent_Bottle
|
module: unilabos.resources.bioyond.bottles:BIOYOND_PolymerStation_Reagent_Bottle
|
||||||
type: pylabrobot
|
type: pylabrobot
|
||||||
|
handles: []
|
||||||
|
icon: ''
|
||||||
|
init_param_schema: {}
|
||||||
|
version: 1.0.0
|
||||||
|
BIOYOND_PolymerStation_Solid_Stock:
|
||||||
|
category:
|
||||||
|
- bottles
|
||||||
|
class:
|
||||||
|
module: unilabos.resources.bioyond.bottles:BIOYOND_PolymerStation_Solid_Stock
|
||||||
|
type: pylabrobot
|
||||||
|
handles: []
|
||||||
|
icon: ''
|
||||||
|
init_param_schema: {}
|
||||||
|
version: 1.0.0
|
||||||
|
BIOYOND_PolymerStation_Solid_Vial:
|
||||||
|
category:
|
||||||
|
- bottles
|
||||||
|
class:
|
||||||
|
module: unilabos.resources.bioyond.bottles:BIOYOND_PolymerStation_Solid_Vial
|
||||||
|
type: pylabrobot
|
||||||
|
handles: []
|
||||||
|
icon: ''
|
||||||
|
init_param_schema: {}
|
||||||
|
version: 1.0.0
|
||||||
|
BIOYOND_PolymerStation_Solution_Beaker:
|
||||||
|
category:
|
||||||
|
- bottles
|
||||||
|
class:
|
||||||
|
module: unilabos.resources.bioyond.bottles:BIOYOND_PolymerStation_Solution_Beaker
|
||||||
|
type: pylabrobot
|
||||||
|
handles: []
|
||||||
|
icon: ''
|
||||||
|
init_param_schema: {}
|
||||||
|
version: 1.0.0
|
||||||
|
|||||||
@@ -1,3 +1,24 @@
|
|||||||
|
disposal:
|
||||||
|
category:
|
||||||
|
- disposal
|
||||||
|
- waste
|
||||||
|
- resource_container
|
||||||
|
class:
|
||||||
|
module: unilabos.resources.disposal:Disposal
|
||||||
|
type: unilabos
|
||||||
|
description: 废料处理位置,用于处理实验废料
|
||||||
|
handles:
|
||||||
|
- data_key: disposal_access
|
||||||
|
data_source: handle
|
||||||
|
data_type: fluid
|
||||||
|
handler_key: access
|
||||||
|
io_type: target
|
||||||
|
label: access
|
||||||
|
side: NORTH
|
||||||
|
icon: ''
|
||||||
|
init_param_schema: {}
|
||||||
|
registry_type: resource
|
||||||
|
version: 1.0.0
|
||||||
hplc_plate:
|
hplc_plate:
|
||||||
category:
|
category:
|
||||||
- resource_container
|
- resource_container
|
||||||
@@ -20,6 +41,55 @@ hplc_plate:
|
|||||||
type: resource
|
type: resource
|
||||||
registry_type: resource
|
registry_type: resource
|
||||||
version: 1.0.0
|
version: 1.0.0
|
||||||
|
maintenance:
|
||||||
|
category:
|
||||||
|
- maintenance
|
||||||
|
- position
|
||||||
|
- resource_container
|
||||||
|
class:
|
||||||
|
module: unilabos.resources.maintenance:Maintenance
|
||||||
|
type: unilabos
|
||||||
|
description: 维护位置,用于设备维护和校准
|
||||||
|
handles:
|
||||||
|
- data_key: maintenance_access
|
||||||
|
data_source: handle
|
||||||
|
data_type: mechanical
|
||||||
|
handler_key: access
|
||||||
|
io_type: target
|
||||||
|
label: access
|
||||||
|
side: NORTH
|
||||||
|
icon: ''
|
||||||
|
init_param_schema: {}
|
||||||
|
registry_type: resource
|
||||||
|
version: 1.0.0
|
||||||
|
plate:
|
||||||
|
category:
|
||||||
|
- plate
|
||||||
|
- labware
|
||||||
|
- resource_container
|
||||||
|
class:
|
||||||
|
module: unilabos.resources.plate:Plate
|
||||||
|
type: unilabos
|
||||||
|
description: 实验板,用于放置样品和试剂
|
||||||
|
handles:
|
||||||
|
- data_key: plate_access
|
||||||
|
data_source: handle
|
||||||
|
data_type: mechanical
|
||||||
|
handler_key: access
|
||||||
|
io_type: target
|
||||||
|
label: access
|
||||||
|
side: NORTH
|
||||||
|
- data_key: sample_wells
|
||||||
|
data_source: handle
|
||||||
|
data_type: fluid
|
||||||
|
handler_key: wells
|
||||||
|
io_type: target
|
||||||
|
label: wells
|
||||||
|
side: CENTER
|
||||||
|
icon: ''
|
||||||
|
init_param_schema: {}
|
||||||
|
registry_type: resource
|
||||||
|
version: 1.0.0
|
||||||
plate_96_high:
|
plate_96_high:
|
||||||
category:
|
category:
|
||||||
- resource_container
|
- resource_container
|
||||||
@@ -42,6 +112,34 @@ plate_96_high:
|
|||||||
type: resource
|
type: resource
|
||||||
registry_type: resource
|
registry_type: resource
|
||||||
version: 1.0.0
|
version: 1.0.0
|
||||||
|
tip_rack:
|
||||||
|
category:
|
||||||
|
- tip_rack
|
||||||
|
- labware
|
||||||
|
- resource_container
|
||||||
|
class:
|
||||||
|
module: unilabos.resources.tip_rack:TipRack
|
||||||
|
type: unilabos
|
||||||
|
description: 枪头架资源,用于存放和管理移液器枪头
|
||||||
|
handles:
|
||||||
|
- data_key: tip_access
|
||||||
|
data_source: handle
|
||||||
|
data_type: mechanical
|
||||||
|
handler_key: access
|
||||||
|
io_type: target
|
||||||
|
label: access
|
||||||
|
side: NORTH
|
||||||
|
- data_key: tip_pickup
|
||||||
|
data_source: handle
|
||||||
|
data_type: mechanical
|
||||||
|
handler_key: pickup
|
||||||
|
io_type: target
|
||||||
|
label: pickup
|
||||||
|
side: SOUTH
|
||||||
|
icon: ''
|
||||||
|
init_param_schema: {}
|
||||||
|
registry_type: resource
|
||||||
|
version: 1.0.0
|
||||||
tiprack_96_high:
|
tiprack_96_high:
|
||||||
category:
|
category:
|
||||||
- resource_container
|
- resource_container
|
||||||
|
|||||||
@@ -7,3 +7,5 @@ def register():
|
|||||||
from unilabos.devices.liquid_handling.prcxi.prcxi import PRCXI9300Container
|
from unilabos.devices.liquid_handling.prcxi.prcxi import PRCXI9300Container
|
||||||
# noinspection PyUnresolvedReferences
|
# noinspection PyUnresolvedReferences
|
||||||
from unilabos.devices.workstation.workstation_base import WorkStationContainer
|
from unilabos.devices.workstation.workstation_base import WorkStationContainer
|
||||||
|
|
||||||
|
from unilabos.devices.liquid_handling.rviz_backend import UniLiquidHandlerRvizBackend
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import copy
|
|
||||||
import json
|
import json
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
@@ -183,14 +182,22 @@ def slave(
|
|||||||
)
|
)
|
||||||
tree_response: SerialCommand_Response = rclient.call_async(request).result()
|
tree_response: SerialCommand_Response = rclient.call_async(request).result()
|
||||||
uuid_mapping = json.loads(tree_response.response)
|
uuid_mapping = json.loads(tree_response.response)
|
||||||
|
# 创建反向映射:new_uuid -> old_uuid
|
||||||
|
reverse_uuid_mapping = {new_uuid: old_uuid for old_uuid, new_uuid in uuid_mapping.items()}
|
||||||
for node in resources_config.root_nodes:
|
for node in resources_config.root_nodes:
|
||||||
if node.res_content.type == "device":
|
if node.res_content.type == "device":
|
||||||
for sub_node in node.children:
|
for sub_node in node.children:
|
||||||
# 只有二级子设备
|
# 只有二级子设备
|
||||||
if sub_node.res_content.type != "device":
|
if sub_node.res_content.type != "device":
|
||||||
device_tracker = devices_instances[node.res_content.id].resource_tracker
|
device_tracker = devices_instances[node.res_content.id].resource_tracker
|
||||||
resource_instance = device_tracker.figure_resource( # todo: 要换成uuid进行figure
|
# sub_node.res_content.uuid 已经是新UUID,需要用旧UUID去查找
|
||||||
{"name": sub_node.res_content.name})
|
old_uuid = reverse_uuid_mapping.get(sub_node.res_content.uuid)
|
||||||
|
if old_uuid:
|
||||||
|
# 找到旧UUID,使用UUID查找
|
||||||
|
resource_instance = device_tracker.figure_resource({"uuid": old_uuid})
|
||||||
|
else:
|
||||||
|
# 未找到旧UUID,使用name查找
|
||||||
|
resource_instance = device_tracker.figure_resource({"name": sub_node.res_content.name})
|
||||||
device_tracker.loop_update_uuid(resource_instance, uuid_mapping)
|
device_tracker.loop_update_uuid(resource_instance, uuid_mapping)
|
||||||
else:
|
else:
|
||||||
logger.error("Slave模式不允许新增非设备节点下的物料")
|
logger.error("Slave模式不允许新增非设备节点下的物料")
|
||||||
|
|||||||
@@ -256,9 +256,10 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
|||||||
|
|
||||||
node_name: str
|
node_name: str
|
||||||
namespace: str
|
namespace: str
|
||||||
# TODO 要删除,添加时间相关的属性,避免动态添加属性的警告
|
# 内部共享变量
|
||||||
time_spent = 0.0
|
_time_spent = 0.0
|
||||||
time_remaining = 0.0
|
_time_remaining = 0.0
|
||||||
|
# 是否创建Action
|
||||||
create_action_server = True
|
create_action_server = True
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
@@ -998,8 +999,8 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
|||||||
goal_handle.canceled()
|
goal_handle.canceled()
|
||||||
return action_type.Result()
|
return action_type.Result()
|
||||||
|
|
||||||
self.time_spent = time.time() - time_start
|
self._time_spent = time.time() - time_start
|
||||||
self.time_remaining = time_overall - self.time_spent
|
self._time_remaining = time_overall - self._time_spent
|
||||||
|
|
||||||
# 发布反馈
|
# 发布反馈
|
||||||
feedback_values = {}
|
feedback_values = {}
|
||||||
@@ -1393,7 +1394,6 @@ class ROS2DeviceNode:
|
|||||||
or driver_class.__name__ == "PRCXI9300Handler"
|
or driver_class.__name__ == "PRCXI9300Handler"
|
||||||
)
|
)
|
||||||
|
|
||||||
# TODO: 要在创建之前预先请求服务器是否有当前id的物料,放到resource_tracker中,让pylabrobot进行创建
|
|
||||||
# 创建设备类实例
|
# 创建设备类实例
|
||||||
if use_pylabrobot_creator:
|
if use_pylabrobot_creator:
|
||||||
# 先对pylabrobot的子资源进行加载,不然subclass无法认出
|
# 先对pylabrobot的子资源进行加载,不然subclass无法认出
|
||||||
|
|||||||
@@ -252,6 +252,8 @@ class HostNode(BaseROS2DeviceNode):
|
|||||||
)
|
)
|
||||||
# resources_config 通过各个设备的 resource_tracker 进行uuid更新,利用uuid_mapping
|
# resources_config 通过各个设备的 resource_tracker 进行uuid更新,利用uuid_mapping
|
||||||
# resources_config 的 root node 是
|
# resources_config 的 root node 是
|
||||||
|
# 创建反向映射:new_uuid -> old_uuid
|
||||||
|
reverse_uuid_mapping = {new_uuid: old_uuid for old_uuid, new_uuid in uuid_mapping.items()}
|
||||||
for tree in resources_config.trees:
|
for tree in resources_config.trees:
|
||||||
node = tree.root_node
|
node = tree.root_node
|
||||||
if node.res_content.type == "device":
|
if node.res_content.type == "device":
|
||||||
@@ -260,8 +262,16 @@ class HostNode(BaseROS2DeviceNode):
|
|||||||
if sub_node.res_content.type != "device":
|
if sub_node.res_content.type != "device":
|
||||||
# slave节点走c2s更新接口,拿到add自行update uuid
|
# slave节点走c2s更新接口,拿到add自行update uuid
|
||||||
device_tracker = self.devices_instances[node.res_content.id].resource_tracker
|
device_tracker = self.devices_instances[node.res_content.id].resource_tracker
|
||||||
resource_instance = device_tracker.figure_resource( # todo: 要换成uuid进行figure
|
# sub_node.res_content.uuid 已经是新UUID,需要用旧UUID去查找
|
||||||
{"name": sub_node.res_content.name})
|
old_uuid = reverse_uuid_mapping.get(sub_node.res_content.uuid)
|
||||||
|
if old_uuid:
|
||||||
|
# 找到旧UUID,使用UUID查找
|
||||||
|
resource_instance = device_tracker.figure_resource({"uuid": old_uuid})
|
||||||
|
else:
|
||||||
|
# 未找到旧UUID,使用name查找
|
||||||
|
resource_instance = device_tracker.figure_resource(
|
||||||
|
{"name": sub_node.res_content.name}
|
||||||
|
)
|
||||||
device_tracker.loop_update_uuid(resource_instance, uuid_mapping)
|
device_tracker.loop_update_uuid(resource_instance, uuid_mapping)
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
@@ -897,6 +907,7 @@ class HostNode(BaseROS2DeviceNode):
|
|||||||
uuid_list: List[str] = data["data"]
|
uuid_list: List[str] = data["data"]
|
||||||
with_children: bool = data["with_children"]
|
with_children: bool = data["with_children"]
|
||||||
from unilabos.app.web.client import http_client
|
from unilabos.app.web.client import http_client
|
||||||
|
|
||||||
resource_response = http_client.resource_tree_get(uuid_list, with_children)
|
resource_response = http_client.resource_tree_get(uuid_list, with_children)
|
||||||
response.response = json.dumps(resource_response)
|
response.response = json.dumps(resource_response)
|
||||||
|
|
||||||
@@ -920,6 +931,7 @@ class HostNode(BaseROS2DeviceNode):
|
|||||||
)
|
)
|
||||||
|
|
||||||
from unilabos.app.web.client import http_client
|
from unilabos.app.web.client import http_client
|
||||||
|
|
||||||
resource_start_time = time.time()
|
resource_start_time = time.time()
|
||||||
uuid_mapping = http_client.resource_tree_update(resource_tree_set, "", False)
|
uuid_mapping = http_client.resource_tree_update(resource_tree_set, "", False)
|
||||||
success = bool(uuid_mapping)
|
success = bool(uuid_mapping)
|
||||||
@@ -1254,7 +1266,9 @@ class HostNode(BaseROS2DeviceNode):
|
|||||||
"status": "success",
|
"status": "success",
|
||||||
}
|
}
|
||||||
|
|
||||||
def test_resource(self, resource: ResourceSlot, resources: List[ResourceSlot], device: DeviceSlot, devices: List[DeviceSlot]):
|
def test_resource(
|
||||||
|
self, resource: ResourceSlot, resources: List[ResourceSlot], device: DeviceSlot, devices: List[DeviceSlot]
|
||||||
|
):
|
||||||
return {
|
return {
|
||||||
"resources": ResourceTreeSet.from_plr_resources([resource, *resources]).dump(),
|
"resources": ResourceTreeSet.from_plr_resources([resource, *resources]).dump(),
|
||||||
"devices": [device, *devices],
|
"devices": [device, *devices],
|
||||||
@@ -1280,9 +1294,7 @@ class HostNode(BaseROS2DeviceNode):
|
|||||||
else:
|
else:
|
||||||
self.lab_logger().warning("⚠️ 收到无效的Pong响应(缺少ping_id)")
|
self.lab_logger().warning("⚠️ 收到无效的Pong响应(缺少ping_id)")
|
||||||
|
|
||||||
def notify_resource_tree_update(
|
def notify_resource_tree_update(self, device_id: str, action: str, resource_uuid_list: List[str]) -> bool:
|
||||||
self, device_id: str, action: str, resource_uuid_list: List[str]
|
|
||||||
) -> bool:
|
|
||||||
"""
|
"""
|
||||||
通知设备节点更新资源树
|
通知设备节点更新资源树
|
||||||
|
|
||||||
|
|||||||
@@ -1060,6 +1060,8 @@ class DeviceNodeResourceTracker(object):
|
|||||||
else:
|
else:
|
||||||
# 对于实例类型,需要特殊处理 uuid 字段
|
# 对于实例类型,需要特殊处理 uuid 字段
|
||||||
# 如果查找的是 unilabos_uuid,使用 getattr
|
# 如果查找的是 unilabos_uuid,使用 getattr
|
||||||
|
if identifier_key == "uuid":
|
||||||
|
identifier_key = "unilabos_uuid"
|
||||||
if hasattr(resource, identifier_key):
|
if hasattr(resource, identifier_key):
|
||||||
if getattr(resource, identifier_key) == compare_value:
|
if getattr(resource, identifier_key) == compare_value:
|
||||||
res_list.append((parent_res, resource))
|
res_list.append((parent_res, resource))
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ import argparse
|
|||||||
import importlib
|
import importlib
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
from .banner_print import print_status
|
|
||||||
|
from unilabos.utils.banner_print import print_status
|
||||||
|
|
||||||
|
|
||||||
class EnvironmentChecker:
|
class EnvironmentChecker:
|
||||||
@@ -49,17 +50,17 @@ class EnvironmentChecker:
|
|||||||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=300) # 5分钟超时
|
result = subprocess.run(cmd, capture_output=True, text=True, timeout=300) # 5分钟超时
|
||||||
|
|
||||||
if result.returncode == 0:
|
if result.returncode == 0:
|
||||||
print_status(f"✅ {package_name} 安装成功", "success")
|
print_status(f"✓ {package_name} 安装成功", "success")
|
||||||
return True
|
return True
|
||||||
else:
|
else:
|
||||||
print_status(f"❌ {package_name} 安装失败: {result.stderr}", "error")
|
print_status(f"× {package_name} 安装失败: {result.stderr}", "error")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
print_status(f"❌ {package_name} 安装超时", "error")
|
print_status(f"× {package_name} 安装超时", "error")
|
||||||
return False
|
return False
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print_status(f"❌ {package_name} 安装异常: {str(e)}", "error")
|
print_status(f"× {package_name} 安装异常: {str(e)}", "error")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def check_all_packages(self) -> bool:
|
def check_all_packages(self) -> bool:
|
||||||
@@ -77,7 +78,7 @@ class EnvironmentChecker:
|
|||||||
self.missing_packages.append((package_name, install_url))
|
self.missing_packages.append((package_name, install_url))
|
||||||
|
|
||||||
if not self.missing_packages:
|
if not self.missing_packages:
|
||||||
print_status("✅ 所有依赖包检查完成,环境正常", "success")
|
print_status("✓ 所有依赖包检查完成,环境正常", "success")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
print_status(f"发现 {len(self.missing_packages)} 个缺失的包", "warning")
|
print_status(f"发现 {len(self.missing_packages)} 个缺失的包", "warning")
|
||||||
@@ -109,7 +110,7 @@ class EnvironmentChecker:
|
|||||||
print_status(f" - {import_name} (pip install {pip_name})", "error")
|
print_status(f" - {import_name} (pip install {pip_name})", "error")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
print_status(f"✅ 成功安装 {success_count} 个包", "success")
|
print_status(f"✓ 成功安装 {success_count} 个包", "success")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def verify_installation(self) -> bool:
|
def verify_installation(self) -> bool:
|
||||||
@@ -130,7 +131,7 @@ class EnvironmentChecker:
|
|||||||
print_status(f" - {import_name}", "error")
|
print_status(f" - {import_name}", "error")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
print_status("✅ 所有包验证通过", "success")
|
print_status("✓ 所有包验证通过", "success")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ float64[] blow_out_air_volume
|
|||||||
string spread
|
string spread
|
||||||
bool is_96_well
|
bool is_96_well
|
||||||
string mix_stage
|
string mix_stage
|
||||||
int32[] mix_times
|
int32 mix_times
|
||||||
int32 mix_vol
|
int32 mix_vol
|
||||||
int32 mix_rate
|
int32 mix_rate
|
||||||
float64 mix_liquid_height
|
float64 mix_liquid_height
|
||||||
|
|||||||
@@ -2,9 +2,10 @@
|
|||||||
<?xml-model href="http://download.ros.org/schema/package_format3.xsd" schematypens="http://www.w3.org/2001/XMLSchema"?>
|
<?xml-model href="http://download.ros.org/schema/package_format3.xsd" schematypens="http://www.w3.org/2001/XMLSchema"?>
|
||||||
<package format="3">
|
<package format="3">
|
||||||
<name>unilabos_msgs</name>
|
<name>unilabos_msgs</name>
|
||||||
<version>0.10.6</version>
|
<version>0.10.7</version>
|
||||||
<description>ROS2 Messages package for unilabos devices</description>
|
<description>ROS2 Messages package for unilabos devices</description>
|
||||||
<maintainer email="changjh@pku.edu.cn">Junhan Chang</maintainer>
|
<maintainer email="changjh@pku.edu.cn">Junhan Chang</maintainer>
|
||||||
|
<maintainer email="18435084+Xuwznln@users.noreply.github.com">Xuwznln</maintainer>
|
||||||
<license>MIT</license>
|
<license>MIT</license>
|
||||||
|
|
||||||
<buildtool_depend>ament_cmake</buildtool_depend>
|
<buildtool_depend>ament_cmake</buildtool_depend>
|
||||||
|
|||||||
Reference in New Issue
Block a user