mirror of
https://github.com/deepmodeling/Uni-Lab-OS
synced 2026-04-01 14:33:10 +00:00
Compare commits
2 Commits
prcix9320
...
e0eb9ea4ba
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e0eb9ea4ba | ||
|
|
b063e19f0e |
@@ -1,62 +0,0 @@
|
||||
# unilabos: Production package (depends on unilabos-env + pip unilabos)
|
||||
# For production deployment
|
||||
|
||||
package:
|
||||
name: unilabos
|
||||
version: 0.10.19
|
||||
|
||||
source:
|
||||
path: ../../unilabos
|
||||
target_directory: unilabos
|
||||
|
||||
build:
|
||||
python:
|
||||
entry_points:
|
||||
- unilab = unilabos.app.main:main
|
||||
script:
|
||||
- set PIP_NO_INDEX=
|
||||
- if: win
|
||||
then:
|
||||
- copy %RECIPE_DIR%\..\..\MANIFEST.in %SRC_DIR%
|
||||
- copy %RECIPE_DIR%\..\..\setup.cfg %SRC_DIR%
|
||||
- copy %RECIPE_DIR%\..\..\setup.py %SRC_DIR%
|
||||
- pip install %SRC_DIR%
|
||||
- if: unix
|
||||
then:
|
||||
- cp $RECIPE_DIR/../../MANIFEST.in $SRC_DIR
|
||||
- cp $RECIPE_DIR/../../setup.cfg $SRC_DIR
|
||||
- cp $RECIPE_DIR/../../setup.py $SRC_DIR
|
||||
- pip install $SRC_DIR
|
||||
|
||||
requirements:
|
||||
host:
|
||||
- python ==3.11.14
|
||||
- pip
|
||||
- setuptools
|
||||
- zstd
|
||||
- zstandard
|
||||
run:
|
||||
- zstd
|
||||
- zstandard
|
||||
- networkx
|
||||
- typing_extensions
|
||||
- websockets
|
||||
- pint
|
||||
- fastapi
|
||||
- jinja2
|
||||
- requests
|
||||
- uvicorn
|
||||
- if: not osx
|
||||
then:
|
||||
- opcua
|
||||
- pyserial
|
||||
- pandas
|
||||
- pymodbus
|
||||
- matplotlib
|
||||
- pylibftdi
|
||||
- uni-lab::unilabos-env ==0.10.19
|
||||
|
||||
about:
|
||||
repository: https://github.com/deepmodeling/Uni-Lab-OS
|
||||
license: GPL-3.0-only
|
||||
description: "UniLabOS - Production package with minimal ROS2 dependencies"
|
||||
@@ -1,39 +0,0 @@
|
||||
# unilabos-env: conda environment dependencies (ROS2 + conda packages)
|
||||
|
||||
package:
|
||||
name: unilabos-env
|
||||
version: 0.10.19
|
||||
|
||||
build:
|
||||
noarch: generic
|
||||
|
||||
requirements:
|
||||
run:
|
||||
# Python
|
||||
- zstd
|
||||
- zstandard
|
||||
- conda-forge::python ==3.11.14
|
||||
- conda-forge::opencv
|
||||
# ROS2 dependencies (from ci-check.yml)
|
||||
- robostack-staging::ros-humble-ros-core
|
||||
- robostack-staging::ros-humble-action-msgs
|
||||
- robostack-staging::ros-humble-std-msgs
|
||||
- robostack-staging::ros-humble-geometry-msgs
|
||||
- robostack-staging::ros-humble-control-msgs
|
||||
- robostack-staging::ros-humble-nav2-msgs
|
||||
- robostack-staging::ros-humble-cv-bridge
|
||||
- robostack-staging::ros-humble-vision-opencv
|
||||
- robostack-staging::ros-humble-tf-transformations
|
||||
- robostack-staging::ros-humble-moveit-msgs
|
||||
- robostack-staging::ros-humble-tf2-ros
|
||||
- robostack-staging::ros-humble-tf2-ros-py
|
||||
- conda-forge::transforms3d
|
||||
- conda-forge::uv
|
||||
|
||||
# UniLabOS custom messages
|
||||
- uni-lab::ros-humble-unilabos-msgs
|
||||
|
||||
about:
|
||||
repository: https://github.com/deepmodeling/Uni-Lab-OS
|
||||
license: GPL-3.0-only
|
||||
description: "UniLabOS Environment - ROS2 and conda dependencies"
|
||||
@@ -1,42 +0,0 @@
|
||||
# unilabos-full: Full package with all features
|
||||
# Depends on unilabos + complete ROS2 desktop + dev tools
|
||||
|
||||
package:
|
||||
name: unilabos-full
|
||||
version: 0.10.19
|
||||
|
||||
build:
|
||||
noarch: generic
|
||||
|
||||
requirements:
|
||||
run:
|
||||
# Base unilabos package (includes unilabos-env)
|
||||
- uni-lab::unilabos ==0.10.19
|
||||
# Documentation tools
|
||||
- sphinx
|
||||
- sphinx_rtd_theme
|
||||
# Web UI
|
||||
- gradio
|
||||
- flask
|
||||
# Interactive development
|
||||
- ipython
|
||||
- jupyter
|
||||
- jupyros
|
||||
- colcon-common-extensions
|
||||
# ROS2 full desktop (includes rviz2, gazebo, etc.)
|
||||
- robostack-staging::ros-humble-desktop-full
|
||||
# Navigation and motion control
|
||||
- ros-humble-navigation2
|
||||
- ros-humble-ros2-control
|
||||
- ros-humble-robot-state-publisher
|
||||
- ros-humble-joint-state-publisher
|
||||
# MoveIt motion planning
|
||||
- ros-humble-moveit
|
||||
- ros-humble-moveit-servo
|
||||
# Simulation
|
||||
- ros-humble-simulation
|
||||
|
||||
about:
|
||||
repository: https://github.com/deepmodeling/Uni-Lab-OS
|
||||
license: GPL-3.0-only
|
||||
description: "UniLabOS Full - Complete package with ROS2 Desktop, MoveIt, Navigation2, Gazebo, Jupyter"
|
||||
91
.conda/recipe.yaml
Normal file
91
.conda/recipe.yaml
Normal file
@@ -0,0 +1,91 @@
|
||||
package:
|
||||
name: unilabos
|
||||
version: 0.10.15
|
||||
|
||||
source:
|
||||
path: ../unilabos
|
||||
target_directory: unilabos
|
||||
|
||||
build:
|
||||
python:
|
||||
entry_points:
|
||||
- unilab = unilabos.app.main:main
|
||||
script:
|
||||
- set PIP_NO_INDEX=
|
||||
- if: win
|
||||
then:
|
||||
- copy %RECIPE_DIR%\..\MANIFEST.in %SRC_DIR%
|
||||
- copy %RECIPE_DIR%\..\setup.cfg %SRC_DIR%
|
||||
- copy %RECIPE_DIR%\..\setup.py %SRC_DIR%
|
||||
- call %PYTHON% -m pip install %SRC_DIR%
|
||||
- if: unix
|
||||
then:
|
||||
- cp $RECIPE_DIR/../MANIFEST.in $SRC_DIR
|
||||
- cp $RECIPE_DIR/../setup.cfg $SRC_DIR
|
||||
- cp $RECIPE_DIR/../setup.py $SRC_DIR
|
||||
- $PYTHON -m pip install $SRC_DIR
|
||||
|
||||
requirements:
|
||||
host:
|
||||
- python ==3.11.11
|
||||
- pip
|
||||
- setuptools
|
||||
- zstd
|
||||
- zstandard
|
||||
run:
|
||||
- conda-forge::python ==3.11.11
|
||||
- compilers
|
||||
- cmake
|
||||
- zstd
|
||||
- zstandard
|
||||
- ninja
|
||||
- if: unix
|
||||
then:
|
||||
- make
|
||||
- sphinx
|
||||
- sphinx_rtd_theme
|
||||
- numpy
|
||||
- scipy
|
||||
- pandas
|
||||
- networkx
|
||||
- matplotlib
|
||||
- pint
|
||||
- pyserial
|
||||
- pyusb
|
||||
- pylibftdi
|
||||
- pymodbus
|
||||
- python-can
|
||||
- pyvisa
|
||||
- opencv
|
||||
- pydantic
|
||||
- fastapi
|
||||
- uvicorn
|
||||
- gradio
|
||||
- flask
|
||||
- websockets
|
||||
- ipython
|
||||
- jupyter
|
||||
- jupyros
|
||||
- colcon-common-extensions
|
||||
- robostack-staging::ros-humble-desktop-full
|
||||
- robostack-staging::ros-humble-control-msgs
|
||||
- robostack-staging::ros-humble-sensor-msgs
|
||||
- robostack-staging::ros-humble-trajectory-msgs
|
||||
- ros-humble-navigation2
|
||||
- ros-humble-ros2-control
|
||||
- ros-humble-robot-state-publisher
|
||||
- ros-humble-joint-state-publisher
|
||||
- ros-humble-rosbridge-server
|
||||
- ros-humble-cv-bridge
|
||||
- ros-humble-tf2
|
||||
- ros-humble-moveit
|
||||
- ros-humble-moveit-servo
|
||||
- ros-humble-simulation
|
||||
- ros-humble-tf-transformations
|
||||
- transforms3d
|
||||
- uni-lab::ros-humble-unilabos-msgs
|
||||
|
||||
about:
|
||||
repository: https://github.com/deepmodeling/Uni-Lab-OS
|
||||
license: GPL-3.0-only
|
||||
description: "Uni-Lab-OS"
|
||||
67
.github/workflows/ci-check.yml
vendored
67
.github/workflows/ci-check.yml
vendored
@@ -1,67 +0,0 @@
|
||||
name: CI Check
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, dev]
|
||||
pull_request:
|
||||
branches: [main, dev]
|
||||
|
||||
jobs:
|
||||
registry-check:
|
||||
runs-on: windows-latest
|
||||
|
||||
env:
|
||||
# Fix Unicode encoding issue on Windows runner (cp1252 -> utf-8)
|
||||
PYTHONIOENCODING: utf-8
|
||||
PYTHONUTF8: 1
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: cmd
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Miniforge
|
||||
uses: conda-incubator/setup-miniconda@v3
|
||||
with:
|
||||
miniforge-version: latest
|
||||
use-mamba: true
|
||||
channels: robostack-staging,conda-forge,uni-lab
|
||||
channel-priority: flexible
|
||||
activate-environment: check-env
|
||||
auto-update-conda: false
|
||||
show-channel-urls: true
|
||||
|
||||
- name: Install ROS dependencies, uv and unilabos-msgs
|
||||
run: |
|
||||
echo Installing ROS dependencies...
|
||||
mamba install -n check-env conda-forge::uv conda-forge::opencv robostack-staging::ros-humble-ros-core robostack-staging::ros-humble-action-msgs robostack-staging::ros-humble-std-msgs robostack-staging::ros-humble-geometry-msgs robostack-staging::ros-humble-control-msgs robostack-staging::ros-humble-nav2-msgs uni-lab::ros-humble-unilabos-msgs robostack-staging::ros-humble-cv-bridge robostack-staging::ros-humble-vision-opencv robostack-staging::ros-humble-tf-transformations robostack-staging::ros-humble-moveit-msgs robostack-staging::ros-humble-tf2-ros robostack-staging::ros-humble-tf2-ros-py conda-forge::transforms3d -c robostack-staging -c conda-forge -c uni-lab -y
|
||||
|
||||
- name: Install pip dependencies and unilabos
|
||||
run: |
|
||||
call conda activate check-env
|
||||
echo Installing pip dependencies...
|
||||
uv pip install -r unilabos/utils/requirements.txt
|
||||
uv pip install pywinauto git+https://github.com/Xuwznln/pylabrobot.git
|
||||
uv pip uninstall enum34 || echo enum34 not installed, skipping
|
||||
uv pip install .
|
||||
|
||||
- name: Run check mode (AST registry validation)
|
||||
run: |
|
||||
call conda activate check-env
|
||||
echo Running check mode...
|
||||
python -m unilabos --check_mode --skip_env_check
|
||||
|
||||
- name: Check for uncommitted changes
|
||||
shell: bash
|
||||
run: |
|
||||
if ! git diff --exit-code; then
|
||||
echo "::error::检测到文件变化!请先在本地运行 'python -m unilabos --complete_registry' 并提交变更"
|
||||
echo "变化的文件:"
|
||||
git diff --name-only
|
||||
exit 1
|
||||
fi
|
||||
echo "检查通过:无文件变化"
|
||||
39
.github/workflows/conda-pack-build.yml
vendored
39
.github/workflows/conda-pack-build.yml
vendored
@@ -13,11 +13,6 @@ on:
|
||||
required: false
|
||||
default: 'win-64'
|
||||
type: string
|
||||
build_full:
|
||||
description: '是否构建完整版 unilabos-full (默认构建轻量版 unilabos)'
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
|
||||
jobs:
|
||||
build-conda-pack:
|
||||
@@ -62,7 +57,7 @@ jobs:
|
||||
echo "should_build=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v4
|
||||
if: steps.should_build.outputs.should_build == 'true'
|
||||
with:
|
||||
ref: ${{ github.event.inputs.branch }}
|
||||
@@ -74,7 +69,7 @@ jobs:
|
||||
with:
|
||||
miniforge-version: latest
|
||||
use-mamba: true
|
||||
python-version: '3.11.14'
|
||||
python-version: '3.11.11'
|
||||
channels: conda-forge,robostack-staging,uni-lab,defaults
|
||||
channel-priority: flexible
|
||||
activate-environment: unilab
|
||||
@@ -86,14 +81,7 @@ jobs:
|
||||
run: |
|
||||
echo Installing unilabos and dependencies to unilab environment...
|
||||
echo Using mamba for faster and more reliable dependency resolution...
|
||||
echo Build full: ${{ github.event.inputs.build_full }}
|
||||
if "${{ github.event.inputs.build_full }}"=="true" (
|
||||
echo Installing unilabos-full ^(complete package^)...
|
||||
mamba install -n unilab uni-lab::unilabos-full conda-pack -c uni-lab -c robostack-staging -c conda-forge -y
|
||||
) else (
|
||||
echo Installing unilabos ^(minimal package^)...
|
||||
mamba install -n unilab uni-lab::unilabos conda-pack -c uni-lab -c robostack-staging -c conda-forge -y
|
||||
)
|
||||
|
||||
- name: Install conda-pack, unilabos and dependencies (Unix)
|
||||
if: steps.should_build.outputs.should_build == 'true' && matrix.platform != 'win-64'
|
||||
@@ -101,14 +89,7 @@ jobs:
|
||||
run: |
|
||||
echo "Installing unilabos and dependencies to unilab environment..."
|
||||
echo "Using mamba for faster and more reliable dependency resolution..."
|
||||
echo "Build full: ${{ github.event.inputs.build_full }}"
|
||||
if [[ "${{ github.event.inputs.build_full }}" == "true" ]]; then
|
||||
echo "Installing unilabos-full (complete package)..."
|
||||
mamba install -n unilab uni-lab::unilabos-full conda-pack -c uni-lab -c robostack-staging -c conda-forge -y
|
||||
else
|
||||
echo "Installing unilabos (minimal package)..."
|
||||
mamba install -n unilab uni-lab::unilabos conda-pack -c uni-lab -c robostack-staging -c conda-forge -y
|
||||
fi
|
||||
|
||||
- name: Get latest ros-humble-unilabos-msgs version (Windows)
|
||||
if: steps.should_build.outputs.should_build == 'true' && matrix.platform == 'win-64'
|
||||
@@ -312,7 +293,7 @@ jobs:
|
||||
|
||||
- name: Upload distribution package
|
||||
if: steps.should_build.outputs.should_build == 'true'
|
||||
uses: actions/upload-artifact@v6
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: unilab-pack-${{ matrix.platform }}-${{ github.event.inputs.branch }}
|
||||
path: dist-package/
|
||||
@@ -327,12 +308,7 @@ jobs:
|
||||
echo ==========================================
|
||||
echo Platform: ${{ matrix.platform }}
|
||||
echo Branch: ${{ github.event.inputs.branch }}
|
||||
echo Python version: 3.11.14
|
||||
if "${{ github.event.inputs.build_full }}"=="true" (
|
||||
echo Package: unilabos-full ^(complete^)
|
||||
) else (
|
||||
echo Package: unilabos ^(minimal^)
|
||||
)
|
||||
echo Python version: 3.11.11
|
||||
echo.
|
||||
echo Distribution package contents:
|
||||
dir dist-package
|
||||
@@ -352,12 +328,7 @@ jobs:
|
||||
echo "=========================================="
|
||||
echo "Platform: ${{ matrix.platform }}"
|
||||
echo "Branch: ${{ github.event.inputs.branch }}"
|
||||
echo "Python version: 3.11.14"
|
||||
if [[ "${{ github.event.inputs.build_full }}" == "true" ]]; then
|
||||
echo "Package: unilabos-full (complete)"
|
||||
else
|
||||
echo "Package: unilabos (minimal)"
|
||||
fi
|
||||
echo "Python version: 3.11.11"
|
||||
echo ""
|
||||
echo "Distribution package contents:"
|
||||
ls -lh dist-package/
|
||||
|
||||
37
.github/workflows/deploy-docs.yml
vendored
37
.github/workflows/deploy-docs.yml
vendored
@@ -1,12 +1,10 @@
|
||||
name: Deploy Docs
|
||||
|
||||
on:
|
||||
# 在 CI Check 成功后自动触发(仅 main 分支)
|
||||
workflow_run:
|
||||
workflows: ["CI Check"]
|
||||
types: [completed]
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
# 手动触发
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
branch:
|
||||
@@ -35,19 +33,12 @@ concurrency:
|
||||
jobs:
|
||||
# Build documentation
|
||||
build:
|
||||
# 只在以下情况运行:
|
||||
# 1. workflow_run 触发且 CI Check 成功
|
||||
# 2. 手动触发
|
||||
if: |
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
(github.event_name == 'workflow_run' && github.event.workflow_run.conclusion == 'success')
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
# workflow_run 时使用触发工作流的分支,手动触发时使用输入的分支
|
||||
ref: ${{ github.event.workflow_run.head_branch || github.event.inputs.branch || github.ref }}
|
||||
ref: ${{ github.event.inputs.branch || github.ref }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Miniforge (with mamba)
|
||||
@@ -55,7 +46,7 @@ jobs:
|
||||
with:
|
||||
miniforge-version: latest
|
||||
use-mamba: true
|
||||
python-version: '3.11.14'
|
||||
python-version: '3.11.11'
|
||||
channels: conda-forge,robostack-staging,uni-lab,defaults
|
||||
channel-priority: flexible
|
||||
activate-environment: unilab
|
||||
@@ -84,10 +75,8 @@ jobs:
|
||||
|
||||
- name: Setup Pages
|
||||
id: pages
|
||||
uses: actions/configure-pages@v5
|
||||
if: |
|
||||
github.event.workflow_run.head_branch == 'main' ||
|
||||
(github.event_name == 'workflow_dispatch' && github.event.inputs.deploy_to_pages == 'true')
|
||||
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: |
|
||||
@@ -105,18 +94,14 @@ jobs:
|
||||
test -f docs/_build/html/index.html && echo "✓ index.html exists" || echo "✗ index.html missing"
|
||||
|
||||
- name: Upload build artifacts
|
||||
uses: actions/upload-pages-artifact@v4
|
||||
if: |
|
||||
github.event.workflow_run.head_branch == 'main' ||
|
||||
(github.event_name == 'workflow_dispatch' && github.event.inputs.deploy_to_pages == 'true')
|
||||
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.event.workflow_run.head_branch == 'main' ||
|
||||
(github.event_name == 'workflow_dispatch' && github.event.inputs.deploy_to_pages == 'true')
|
||||
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 }}
|
||||
|
||||
46
.github/workflows/multi-platform-build.yml
vendored
46
.github/workflows/multi-platform-build.yml
vendored
@@ -1,16 +1,11 @@
|
||||
name: Multi-Platform Conda Build
|
||||
|
||||
on:
|
||||
# 在 CI Check 工作流完成后触发(仅限 main/dev 分支)
|
||||
workflow_run:
|
||||
workflows: ["CI Check"]
|
||||
types:
|
||||
- completed
|
||||
branches: [main, dev]
|
||||
# 支持 tag 推送(不依赖 CI Check)
|
||||
push:
|
||||
branches: [main, dev]
|
||||
tags: ['v*']
|
||||
# 手动触发
|
||||
pull_request:
|
||||
branches: [main, dev]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
platforms:
|
||||
@@ -22,37 +17,9 @@ on:
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
skip_ci_check:
|
||||
description: '跳过等待 CI Check (手动触发时可选)'
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
|
||||
jobs:
|
||||
# 等待 CI Check 完成的 job (仅用于 workflow_run 触发)
|
||||
wait-for-ci:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'workflow_run'
|
||||
outputs:
|
||||
should_continue: ${{ steps.check.outputs.should_continue }}
|
||||
steps:
|
||||
- name: Check CI status
|
||||
id: check
|
||||
run: |
|
||||
if [[ "${{ github.event.workflow_run.conclusion }}" == "success" ]]; then
|
||||
echo "should_continue=true" >> $GITHUB_OUTPUT
|
||||
echo "CI Check passed, proceeding with build"
|
||||
else
|
||||
echo "should_continue=false" >> $GITHUB_OUTPUT
|
||||
echo "CI Check did not succeed (status: ${{ github.event.workflow_run.conclusion }}), skipping build"
|
||||
fi
|
||||
|
||||
build:
|
||||
needs: [wait-for-ci]
|
||||
# 运行条件:workflow_run 触发且 CI 成功,或者其他触发方式
|
||||
if: |
|
||||
always() &&
|
||||
(needs.wait-for-ci.result == 'skipped' || needs.wait-for-ci.outputs.should_continue == 'true')
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -77,10 +44,8 @@ jobs:
|
||||
shell: bash -l {0}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
# 如果是 workflow_run 触发,使用触发 CI Check 的 commit
|
||||
ref: ${{ github.event.workflow_run.head_sha || github.ref }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Check if platform should be built
|
||||
@@ -104,6 +69,7 @@ jobs:
|
||||
channels: conda-forge,robostack-staging,defaults
|
||||
channel-priority: strict
|
||||
activate-environment: build-env
|
||||
auto-activate-base: false
|
||||
auto-update-conda: false
|
||||
show-channel-urls: true
|
||||
|
||||
@@ -149,7 +115,7 @@ jobs:
|
||||
|
||||
- name: Upload conda package artifacts
|
||||
if: steps.should_build.outputs.should_build == 'true'
|
||||
uses: actions/upload-artifact@v6
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: conda-package-${{ matrix.platform }}
|
||||
path: conda-packages-temp
|
||||
|
||||
113
.github/workflows/unilabos-conda-build.yml
vendored
113
.github/workflows/unilabos-conda-build.yml
vendored
@@ -1,62 +1,25 @@
|
||||
name: UniLabOS Conda Build
|
||||
|
||||
on:
|
||||
# 在 CI Check 成功后自动触发
|
||||
workflow_run:
|
||||
workflows: ["CI Check"]
|
||||
types: [completed]
|
||||
branches: [main, dev]
|
||||
# 标签推送时直接触发(发布版本)
|
||||
push:
|
||||
branches: [main, dev]
|
||||
tags: ['v*']
|
||||
# 手动触发
|
||||
pull_request:
|
||||
branches: [main, dev]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
platforms:
|
||||
description: '选择构建平台 (逗号分隔): linux-64, osx-64, osx-arm64, win-64'
|
||||
required: false
|
||||
default: 'linux-64'
|
||||
build_full:
|
||||
description: '是否构建 unilabos-full 完整包 (默认只构建 unilabos 基础包)'
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
upload_to_anaconda:
|
||||
description: '是否上传到Anaconda.org'
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
skip_ci_check:
|
||||
description: '跳过等待 CI Check (手动触发时可选)'
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
|
||||
jobs:
|
||||
# 等待 CI Check 完成的 job (仅用于 workflow_run 触发)
|
||||
wait-for-ci:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'workflow_run'
|
||||
outputs:
|
||||
should_continue: ${{ steps.check.outputs.should_continue }}
|
||||
steps:
|
||||
- name: Check CI status
|
||||
id: check
|
||||
run: |
|
||||
if [[ "${{ github.event.workflow_run.conclusion }}" == "success" ]]; then
|
||||
echo "should_continue=true" >> $GITHUB_OUTPUT
|
||||
echo "CI Check passed, proceeding with build"
|
||||
else
|
||||
echo "should_continue=false" >> $GITHUB_OUTPUT
|
||||
echo "CI Check did not succeed (status: ${{ github.event.workflow_run.conclusion }}), skipping build"
|
||||
fi
|
||||
|
||||
build:
|
||||
needs: [wait-for-ci]
|
||||
# 运行条件:workflow_run 触发且 CI 成功,或者其他触发方式
|
||||
if: |
|
||||
always() &&
|
||||
(needs.wait-for-ci.result == 'skipped' || needs.wait-for-ci.outputs.should_continue == 'true')
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -77,10 +40,8 @@ jobs:
|
||||
shell: bash -l {0}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
# 如果是 workflow_run 触发,使用触发 CI Check 的 commit
|
||||
ref: ${{ github.event.workflow_run.head_sha || github.ref }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Check if platform should be built
|
||||
@@ -104,6 +65,7 @@ jobs:
|
||||
channels: conda-forge,robostack-staging,uni-lab,defaults
|
||||
channel-priority: strict
|
||||
activate-environment: build-env
|
||||
auto-activate-base: false
|
||||
auto-update-conda: false
|
||||
show-channel-urls: true
|
||||
|
||||
@@ -119,61 +81,12 @@ jobs:
|
||||
conda list | grep -E "(rattler-build|anaconda-client)"
|
||||
echo "Platform: ${{ matrix.platform }}"
|
||||
echo "OS: ${{ matrix.os }}"
|
||||
echo "Build full package: ${{ github.event.inputs.build_full || 'false' }}"
|
||||
echo "Building packages:"
|
||||
echo " - unilabos-env (environment dependencies)"
|
||||
echo " - unilabos (with pip package)"
|
||||
if [[ "${{ github.event.inputs.build_full }}" == "true" ]]; then
|
||||
echo " - unilabos-full (complete package)"
|
||||
fi
|
||||
echo "Building UniLabOS package"
|
||||
|
||||
- name: Build unilabos-env (conda environment only, noarch)
|
||||
- name: Build conda package
|
||||
if: steps.should_build.outputs.should_build == 'true'
|
||||
run: |
|
||||
echo "Building unilabos-env (conda environment dependencies)..."
|
||||
rattler-build build -r .conda/environment/recipe.yaml -c uni-lab -c robostack-staging -c conda-forge
|
||||
|
||||
- name: Upload unilabos-env to Anaconda.org (if enabled)
|
||||
if: steps.should_build.outputs.should_build == 'true' && github.event.inputs.upload_to_anaconda == 'true'
|
||||
run: |
|
||||
echo "Uploading unilabos-env to uni-lab organization..."
|
||||
for package in $(find ./output -name "unilabos-env*.conda"); do
|
||||
anaconda -t ${{ secrets.ANACONDA_API_TOKEN }} upload --user uni-lab --force "$package"
|
||||
done
|
||||
|
||||
- name: Build unilabos (with pip package)
|
||||
if: steps.should_build.outputs.should_build == 'true'
|
||||
run: |
|
||||
echo "Building unilabos package..."
|
||||
# 如果已上传到 Anaconda,从 uni-lab channel 获取 unilabos-env;否则从本地 output 获取
|
||||
rattler-build build -r .conda/base/recipe.yaml -c uni-lab -c robostack-staging -c conda-forge --channel ./output
|
||||
|
||||
- name: Upload unilabos to Anaconda.org (if enabled)
|
||||
if: steps.should_build.outputs.should_build == 'true' && github.event.inputs.upload_to_anaconda == 'true'
|
||||
run: |
|
||||
echo "Uploading unilabos to uni-lab organization..."
|
||||
for package in $(find ./output -name "unilabos-0*.conda" -o -name "unilabos-[0-9]*.conda"); do
|
||||
anaconda -t ${{ secrets.ANACONDA_API_TOKEN }} upload --user uni-lab --force "$package"
|
||||
done
|
||||
|
||||
- name: Build unilabos-full - Only when explicitly requested
|
||||
if: |
|
||||
steps.should_build.outputs.should_build == 'true' &&
|
||||
github.event.inputs.build_full == 'true'
|
||||
run: |
|
||||
echo "Building unilabos-full package on ${{ matrix.platform }}..."
|
||||
rattler-build build -r .conda/full/recipe.yaml -c uni-lab -c robostack-staging -c conda-forge --channel ./output
|
||||
|
||||
- name: Upload unilabos-full to Anaconda.org (if enabled)
|
||||
if: |
|
||||
steps.should_build.outputs.should_build == 'true' &&
|
||||
github.event.inputs.build_full == 'true' &&
|
||||
github.event.inputs.upload_to_anaconda == 'true'
|
||||
run: |
|
||||
echo "Uploading unilabos-full to uni-lab organization..."
|
||||
for package in $(find ./output -name "unilabos-full*.conda"); do
|
||||
anaconda -t ${{ secrets.ANACONDA_API_TOKEN }} upload --user uni-lab --force "$package"
|
||||
done
|
||||
rattler-build build -r .conda/recipe.yaml -c uni-lab -c robostack-staging -c conda-forge
|
||||
|
||||
- name: List built packages
|
||||
if: steps.should_build.outputs.should_build == 'true'
|
||||
@@ -195,9 +108,17 @@ jobs:
|
||||
|
||||
- name: Upload conda package artifacts
|
||||
if: steps.should_build.outputs.should_build == 'true'
|
||||
uses: actions/upload-artifact@v6
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: conda-package-unilabos-${{ matrix.platform }}
|
||||
path: conda-packages-temp
|
||||
if-no-files-found: warn
|
||||
retention-days: 30
|
||||
|
||||
- name: Upload to Anaconda.org (uni-lab organization)
|
||||
if: github.event.inputs.upload_to_anaconda == 'true'
|
||||
run: |
|
||||
for package in $(find ./output -name "*.conda"); do
|
||||
echo "Uploading $package to uni-lab organization..."
|
||||
anaconda -t ${{ secrets.ANACONDA_API_TOKEN }} upload --user uni-lab --force "$package"
|
||||
done
|
||||
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -4,8 +4,6 @@ temp/
|
||||
output/
|
||||
unilabos_data/
|
||||
pyrightconfig.json
|
||||
.cursorignore
|
||||
device_package*/
|
||||
## Python
|
||||
|
||||
# Byte-compiled / optimized / DLL files
|
||||
@@ -252,5 +250,3 @@ ros-humble-unilabos-msgs-0.9.13-h6403a04_5.tar.bz2
|
||||
test_config.py
|
||||
|
||||
|
||||
/.claude
|
||||
/.cursor
|
||||
|
||||
87
AGENTS.md
87
AGENTS.md
@@ -1,87 +0,0 @@
|
||||
# AGENTS.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
Also follow the monorepo-level rules in `../AGENTS.md`.
|
||||
|
||||
## Build & Development
|
||||
|
||||
```bash
|
||||
# Install in editable mode (requires mamba env with python 3.11)
|
||||
pip install -e .
|
||||
uv pip install -r unilabos/utils/requirements.txt
|
||||
|
||||
# Run with a device graph
|
||||
unilab --graph <graph.json> --config <config.py> --backend ros
|
||||
unilab --graph <graph.json> --config <config.py> --backend simple # no ROS2 needed
|
||||
|
||||
# Common CLI flags
|
||||
unilab --app_bridges websocket fastapi # communication bridges
|
||||
unilab --test_mode # simulate hardware, no real execution
|
||||
unilab --check_mode # CI validation of registry imports
|
||||
unilab --skip_env_check # skip auto-install of dependencies
|
||||
unilab --visual rviz|web|disable # visualization mode
|
||||
unilab --is_slave # run as slave node
|
||||
|
||||
# Workflow upload subcommand
|
||||
unilab workflow_upload -f <workflow.json> -n <name> --tags tag1 tag2
|
||||
|
||||
# Tests
|
||||
pytest tests/ # all tests
|
||||
pytest tests/resources/test_resourcetreeset.py # single test file
|
||||
pytest tests/resources/test_resourcetreeset.py::TestClassName::test_method # single test
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
### Startup Flow
|
||||
|
||||
`unilab` CLI → `unilabos/app/main.py:main()` → loads config → builds registry → reads device graph (JSON/GraphML) → starts backend thread (ROS2/simple) → starts FastAPI web server + WebSocket client.
|
||||
|
||||
### Core Layers
|
||||
|
||||
**Registry** (`unilabos/registry/`): Singleton `Registry` class discovers and catalogs all device types, resource types, and communication devices from YAML definitions. Device types live in `registry/devices/*.yaml`, resources in `registry/resources/`, comms in `registry/device_comms/`. The registry resolves class paths to actual Python classes via `utils/import_manager.py`.
|
||||
|
||||
**Resource Tracking** (`unilabos/resources/resource_tracker.py`): Pydantic-based `ResourceDict` → `ResourceDictInstance` → `ResourceTreeSet` hierarchy. `ResourceTreeSet` is the canonical in-memory representation of all devices and resources, used throughout the system. Graph I/O is in `resources/graphio.py` (reads JSON/GraphML device topology files into `nx.Graph` + `ResourceTreeSet`).
|
||||
|
||||
**Device Drivers** (`unilabos/devices/`): 30+ hardware drivers organized by device type (liquid_handling, hplc, balance, arm, etc.). Each driver is a Python class that gets wrapped by `ros/device_node_wrapper.py:ros2_device_node()` to become a ROS2 node with publishers, subscribers, and action servers.
|
||||
|
||||
**ROS2 Layer** (`unilabos/ros/`): `device_node_wrapper.py` dynamically wraps any device class into `ROS2DeviceNode` (defined in `ros/nodes/base_device_node.py`). Preset node types in `ros/nodes/presets/` include `host_node`, `controller_node`, `workstation`, `serial_node`, `camera`. Messages use custom `unilabos_msgs` (pre-built, distributed via releases).
|
||||
|
||||
**Protocol Compilation** (`unilabos/compile/`): 20+ protocol compilers (add, centrifuge, dissolve, filter, heatchill, stir, pump, etc.) that transform YAML protocol definitions into executable sequences.
|
||||
|
||||
**Communication** (`unilabos/device_comms/`): Hardware communication adapters — OPC-UA client, Modbus PLC, RPC, and a universal driver. `app/communication.py` provides a factory pattern for WebSocket client connections to the cloud.
|
||||
|
||||
**Web/API** (`unilabos/app/web/`): FastAPI server with REST API (`api.py`), Jinja2 template pages (`pages.py`), and HTTP client for cloud communication (`client.py`). Runs on port 8002 by default.
|
||||
|
||||
### Configuration System
|
||||
|
||||
- **Config classes** in `unilabos/config/config.py`: `BasicConfig`, `WSConfig`, `HTTPConfig`, `ROSConfig` — all class-level attributes, loaded from Python config files
|
||||
- Config files are `.py` files with matching class names (see `config/example_config.py`)
|
||||
- Environment variables override with prefix `UNILABOS_` (e.g., `UNILABOS_BASICCONFIG_PORT=9000`)
|
||||
- Device topology defined in graph files (JSON with node-link format, or GraphML)
|
||||
|
||||
### Key Data Flow
|
||||
|
||||
1. Graph file → `graphio.read_node_link_json()` → `(nx.Graph, ResourceTreeSet, resource_links)`
|
||||
2. `ResourceTreeSet` + `Registry` → `initialize_device.initialize_device_from_dict()` → `ROS2DeviceNode` instances
|
||||
3. Device nodes communicate via ROS2 topics/actions or direct Python calls (simple backend)
|
||||
4. Cloud sync via WebSocket (`app/ws_client.py`) and HTTP (`app/web/client.py`)
|
||||
|
||||
### Test Data
|
||||
|
||||
Example device graphs and experiment configs are in `unilabos/test/experiments/` (not `tests/`). Registry test fixtures in `unilabos/test/registry/`.
|
||||
|
||||
## Code Conventions
|
||||
|
||||
- Code comments and log messages in simplified Chinese
|
||||
- Python 3.11+, type hints expected
|
||||
- Pydantic models for data validation (`resource_tracker.py`)
|
||||
- Singleton pattern via `@singleton` decorator (`utils/decorator.py`)
|
||||
- Dynamic class loading via `utils/import_manager.py` — device classes resolved at runtime from registry YAML paths
|
||||
- CLI argument dashes auto-converted to underscores for consistency
|
||||
|
||||
## Licensing
|
||||
|
||||
- Framework code: GPL-3.0
|
||||
- Device drivers (`unilabos/devices/`): DP Technology Proprietary License — do not redistribute
|
||||
124
CLAUDE.md
124
CLAUDE.md
@@ -1,124 +0,0 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Build & Development
|
||||
|
||||
```bash
|
||||
# Install (requires mamba env with python 3.11)
|
||||
mamba create -n unilab python=3.11.14
|
||||
mamba activate unilab
|
||||
mamba install uni-lab::unilabos-env -c robostack-staging -c conda-forge
|
||||
pip install -e .
|
||||
uv pip install -r unilabos/utils/requirements.txt
|
||||
|
||||
# Run with a device graph
|
||||
unilab --graph <graph.json> --config <config.py> --backend ros
|
||||
unilab --graph <graph.json> --config <config.py> --backend simple # no ROS2 needed
|
||||
|
||||
# Common CLI flags
|
||||
unilab --app_bridges websocket fastapi # communication bridges
|
||||
unilab --test_mode # simulate hardware, no real execution
|
||||
unilab --check_mode # CI validation of registry imports (AST-based)
|
||||
unilab --skip_env_check # skip auto-install of dependencies
|
||||
unilab --visual rviz|web|disable # visualization mode
|
||||
unilab --is_slave # run as slave node
|
||||
unilab --restart_mode # auto-restart on config changes (supervisor/child process)
|
||||
unilab --external_devices_only # only load external device packages
|
||||
unilab --extra_resource # load extra lab_ prefixed labware resources
|
||||
|
||||
# Workflow upload subcommand
|
||||
unilab workflow_upload -f <workflow.json> -n <name> --tags tag1 tag2
|
||||
|
||||
# Labware Manager (standalone web UI for PRCXI labware CRUD, port 8010)
|
||||
python -m unilabos.labware_manager
|
||||
|
||||
# Tests
|
||||
pytest tests/ # all tests
|
||||
pytest tests/resources/test_resourcetreeset.py # single test file
|
||||
pytest tests/resources/test_resourcetreeset.py::TestClassName::test_method # single test
|
||||
|
||||
# CI check (matches .github/workflows/ci-check.yml)
|
||||
python -m unilabos --check_mode --skip_env_check
|
||||
|
||||
# If registry YAML/Python files changed, regenerate before committing:
|
||||
python -m unilabos --complete_registry
|
||||
|
||||
# Documentation build
|
||||
cd docs && python -m sphinx -b html . _build/html -v
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
### Startup Flow
|
||||
|
||||
`unilab` CLI (entry point in `setup.py`) → `unilabos/app/main.py:main()` → loads config → builds registry → reads device graph (JSON/GraphML) → starts backend thread (ROS2/simple) → starts FastAPI web server + WebSocket client.
|
||||
|
||||
### Core Layers
|
||||
|
||||
**Registry** (`unilabos/registry/`): Singleton `Registry` class discovers and catalogs all device types, resource types, and communication devices. Two registration mechanisms:
|
||||
1. **YAML definitions** in `registry/devices/*.yaml` and `registry/resources/` (backward-compatible)
|
||||
2. **Python decorators** (`@device`, `@action`, `@resource` in `registry/decorators.py`) — preferred for new code
|
||||
|
||||
AST scanning (`ast_registry_scanner.py`) discovers decorated classes without importing them, so `--check_mode` works without hardware dependencies. Class paths resolved to Python classes at runtime via `utils/import_manager.py`.
|
||||
|
||||
Decorator usage pattern:
|
||||
```python
|
||||
from unilabos.registry.decorators import device, action, resource
|
||||
from unilabos.registry.decorators import InputHandle, OutputHandle, HardwareInterface
|
||||
|
||||
@device(id="my_device.v1", category=["category_name"], handles=[...])
|
||||
class MyDevice:
|
||||
@action(action_type=SomeActionType)
|
||||
def do_something(self): ...
|
||||
```
|
||||
|
||||
**Resource Tracking** (`unilabos/resources/resource_tracker.py`): Pydantic-based `ResourceDict` → `ResourceDictInstance` → `ResourceTreeSet` hierarchy. `ResourceTreeSet` is the canonical in-memory representation of all devices and resources. Graph I/O in `resources/graphio.py` reads JSON/GraphML device topology files into `nx.Graph` + `ResourceTreeSet`.
|
||||
|
||||
**Device Drivers** (`unilabos/devices/`): 30+ hardware drivers organized by category (liquid_handling, hplc, balance, arm, etc.). Each driver class gets wrapped by `ros/device_node_wrapper.py:ros2_device_node()` into a `ROS2DeviceNode` (defined in `ros/nodes/base_device_node.py`) with publishers, subscribers, and action servers.
|
||||
|
||||
**ROS2 Layer** (`unilabos/ros/`): Preset node types in `ros/nodes/presets/` — `host_node` (main orchestrator, ~90KB), `controller_node`, `workstation`, `serial_node`, `camera`, `resource_mesh_manager`. Custom messages in `unilabos_msgs/` (80+ action types, pre-built via conda `ros-humble-unilabos-msgs`).
|
||||
|
||||
**Protocol Compilation** (`unilabos/compile/`): 20+ protocol compilers (add, centrifuge, dissolve, filter, heatchill, stir, pump, etc.) registered in `__init__.py:action_protocol_generators` dict. Utility parsers in `compile/utils/` (vessel, unit, logger).
|
||||
|
||||
**Workflow** (`unilabos/workflow/`): Converts workflow definitions from multiple formats — JSON (`convert_from_json.py`, `common.py`), Python scripts (`from_python_script.py`), XDL (`from_xdl.py`) — into executable `WorkflowGraph`. Legacy converters in `workflow/legacy/`.
|
||||
|
||||
**Communication** (`unilabos/device_comms/`): Hardware adapters — OPC-UA, Modbus PLC, RPC, universal driver. `app/communication.py` provides factory pattern for WebSocket connections.
|
||||
|
||||
**Web/API** (`unilabos/app/web/`): FastAPI server with REST API (`api.py`), Jinja2 templates (`pages.py`), HTTP client (`client.py`). Default port 8002.
|
||||
|
||||
**Labware Manager** (`unilabos/labware_manager/`): Standalone FastAPI web app (port 8010) for PRCXI labware CRUD. Pydantic models in `models.py`, JSON database in `labware_db.json`. Supports importing from existing Python/YAML (`importer.py`), code generation (`codegen.py`), and YAML generation (`yaml_gen.py`). Web UI with SVG visualization (`static/labware_viz.js`), dynamic form handling (`static/form_handler.js`), and Jinja2 templates.
|
||||
|
||||
### Configuration System
|
||||
|
||||
- **Config classes** in `unilabos/config/config.py`: `BasicConfig`, `WSConfig`, `HTTPConfig`, `ROSConfig` — class-level attributes, loaded from Python `.py` config files (see `config/example_config.py`)
|
||||
- Environment variable overrides with prefix `UNILABOS_` (e.g., `UNILABOS_BASICCONFIG_PORT=9000`)
|
||||
- Device topology defined in graph files (JSON node-link format or GraphML)
|
||||
|
||||
### Key Data Flow
|
||||
|
||||
1. Graph file → `graphio.read_node_link_json()` → `(nx.Graph, ResourceTreeSet, resource_links)`
|
||||
2. `ResourceTreeSet` + `Registry` → `initialize_device.initialize_device_from_dict()` → `ROS2DeviceNode` instances
|
||||
3. Device nodes communicate via ROS2 topics/actions or direct Python calls (simple backend)
|
||||
4. Cloud sync via WebSocket (`app/ws_client.py`) and HTTP (`app/web/client.py`)
|
||||
|
||||
### Test Data
|
||||
|
||||
Example device graphs and experiment configs are in `unilabos/test/experiments/` (not `tests/`). Registry test fixtures in `unilabos/test/registry/`.
|
||||
|
||||
## Code Conventions
|
||||
|
||||
- Code comments and log messages in **simplified Chinese**
|
||||
- Python 3.11+, type hints expected
|
||||
- Pydantic models for data validation (`resource_tracker.py`)
|
||||
- Singleton pattern via `@singleton` decorator (`utils/decorator.py`)
|
||||
- Dynamic class loading via `utils/import_manager.py` — device classes resolved at runtime from registry YAML paths
|
||||
- CLI argument dashes auto-converted to underscores for consistency
|
||||
- No linter/formatter configuration enforced (no ruff, black, flake8, mypy configs)
|
||||
- Documentation built with Sphinx (Chinese language, `sphinx_rtd_theme`, `myst_parser`)
|
||||
- CI runs on Windows (`windows-latest`); if registry files change, run `python -m unilabos --complete_registry` locally before committing
|
||||
|
||||
## Licensing
|
||||
|
||||
- Framework code: GPL-3.0
|
||||
- Device drivers (`unilabos/devices/`): DP Technology Proprietary License — do not redistribute
|
||||
@@ -1,5 +1,4 @@
|
||||
recursive-include unilabos/test *
|
||||
recursive-include unilabos/utils *
|
||||
recursive-include unilabos/registry *.yaml
|
||||
recursive-include unilabos/app/web/static *
|
||||
recursive-include unilabos/app/web/templates *
|
||||
|
||||
38
README.md
38
README.md
@@ -31,46 +31,26 @@ Detailed documentation can be found at:
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 1. Setup Conda Environment
|
||||
1. Setup Conda Environment
|
||||
|
||||
Uni-Lab-OS recommends using `mamba` for environment management. Choose the package that fits your needs:
|
||||
|
||||
| Package | Use Case | Contents |
|
||||
|---------|----------|----------|
|
||||
| `unilabos` | **Recommended for most users** | Complete package, ready to use |
|
||||
| `unilabos-env` | Developers (editable install) | Environment only, install unilabos via pip |
|
||||
| `unilabos-full` | Simulation/Visualization | unilabos + ROS2 Desktop + Gazebo + MoveIt |
|
||||
Uni-Lab-OS recommends using `mamba` for environment management:
|
||||
|
||||
```bash
|
||||
# Create new environment
|
||||
mamba create -n unilab python=3.11.14
|
||||
mamba create -n unilab python=3.11.11
|
||||
mamba activate unilab
|
||||
|
||||
# Option A: Standard installation (recommended for most users)
|
||||
mamba install uni-lab::unilabos -c robostack-staging -c conda-forge
|
||||
|
||||
# Option B: For developers (editable mode development)
|
||||
mamba install uni-lab::unilabos-env -c robostack-staging -c conda-forge
|
||||
# Then install unilabos and dependencies:
|
||||
git clone https://github.com/deepmodeling/Uni-Lab-OS.git && cd Uni-Lab-OS
|
||||
pip install -e .
|
||||
uv pip install -r unilabos/utils/requirements.txt
|
||||
|
||||
# Option C: Full installation (simulation/visualization)
|
||||
mamba install uni-lab::unilabos-full -c robostack-staging -c conda-forge
|
||||
mamba install -n unilab uni-lab::unilabos -c robostack-staging -c conda-forge
|
||||
```
|
||||
|
||||
**When to use which?**
|
||||
- **unilabos**: Standard installation for production deployment and general usage (recommended)
|
||||
- **unilabos-env**: For developers who need `pip install -e .` editable mode, modify source code
|
||||
- **unilabos-full**: For simulation (Gazebo), visualization (rviz2), and Jupyter notebooks
|
||||
|
||||
### 2. Clone Repository (Optional, for developers)
|
||||
2. Install Dev Uni-Lab-OS
|
||||
|
||||
```bash
|
||||
# Clone the repository (only needed for development or examples)
|
||||
# Clone the repository
|
||||
git clone https://github.com/deepmodeling/Uni-Lab-OS.git
|
||||
cd Uni-Lab-OS
|
||||
|
||||
# Install Uni-Lab-OS
|
||||
pip install .
|
||||
```
|
||||
|
||||
3. Start Uni-Lab System
|
||||
|
||||
38
README_zh.md
38
README_zh.md
@@ -31,46 +31,26 @@ Uni-Lab-OS 是一个用于实验室自动化的综合平台,旨在连接和控
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 1. 配置 Conda 环境
|
||||
1. 配置 Conda 环境
|
||||
|
||||
Uni-Lab-OS 建议使用 `mamba` 管理环境。根据您的需求选择合适的安装包:
|
||||
|
||||
| 安装包 | 适用场景 | 包含内容 |
|
||||
|--------|----------|----------|
|
||||
| `unilabos` | **推荐大多数用户** | 完整安装包,开箱即用 |
|
||||
| `unilabos-env` | 开发者(可编辑安装) | 仅环境依赖,通过 pip 安装 unilabos |
|
||||
| `unilabos-full` | 仿真/可视化 | unilabos + ROS2 桌面版 + Gazebo + MoveIt |
|
||||
Uni-Lab-OS 建议使用 `mamba` 管理环境。根据您的操作系统选择适当的环境文件:
|
||||
|
||||
```bash
|
||||
# 创建新环境
|
||||
mamba create -n unilab python=3.11.14
|
||||
mamba create -n unilab python=3.11.11
|
||||
mamba activate unilab
|
||||
|
||||
# 方案 A:标准安装(推荐大多数用户)
|
||||
mamba install uni-lab::unilabos -c robostack-staging -c conda-forge
|
||||
|
||||
# 方案 B:开发者环境(可编辑模式开发)
|
||||
mamba install uni-lab::unilabos-env -c robostack-staging -c conda-forge
|
||||
# 然后安装 unilabos 和依赖:
|
||||
git clone https://github.com/deepmodeling/Uni-Lab-OS.git && cd Uni-Lab-OS
|
||||
pip install -e .
|
||||
uv pip install -r unilabos/utils/requirements.txt
|
||||
|
||||
# 方案 C:完整安装(仿真/可视化)
|
||||
mamba install uni-lab::unilabos-full -c robostack-staging -c conda-forge
|
||||
mamba install -n unilab uni-lab::unilabos -c robostack-staging -c conda-forge
|
||||
```
|
||||
|
||||
**如何选择?**
|
||||
- **unilabos**:标准安装,适用于生产部署和日常使用(推荐)
|
||||
- **unilabos-env**:开发者使用,支持 `pip install -e .` 可编辑模式,可修改源代码
|
||||
- **unilabos-full**:需要仿真(Gazebo)、可视化(rviz2)或 Jupyter Notebook
|
||||
|
||||
### 2. 克隆仓库(可选,供开发者使用)
|
||||
2. 安装开发版 Uni-Lab-OS:
|
||||
|
||||
```bash
|
||||
# 克隆仓库(仅开发或查看示例时需要)
|
||||
# 克隆仓库
|
||||
git clone https://github.com/deepmodeling/Uni-Lab-OS.git
|
||||
cd Uni-Lab-OS
|
||||
|
||||
# 安装 Uni-Lab-OS
|
||||
pip install .
|
||||
```
|
||||
|
||||
3. 启动 Uni-Lab 系统
|
||||
|
||||
@@ -24,7 +24,7 @@ extensions = [
|
||||
"sphinx.ext.autodoc",
|
||||
"sphinx.ext.napoleon", # 如果您使用 Google 或 NumPy 风格的 docstrings
|
||||
"sphinx_rtd_theme",
|
||||
"sphinxcontrib.mermaid",
|
||||
"sphinxcontrib.mermaid"
|
||||
]
|
||||
|
||||
source_suffix = {
|
||||
@@ -58,7 +58,7 @@ html_theme = "sphinx_rtd_theme"
|
||||
|
||||
# sphinx-book-theme 主题选项
|
||||
html_theme_options = {
|
||||
"repository_url": "https://github.com/deepmodeling/Uni-Lab-OS",
|
||||
"repository_url": "https://github.com/用户名/Uni-Lab",
|
||||
"use_repository_button": True,
|
||||
"use_issues_button": True,
|
||||
"use_edit_page_button": True,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -15,9 +15,6 @@ Python 类设备驱动在完成注册表后可以直接在 Uni-Lab 中使用,
|
||||
**示例:**
|
||||
|
||||
```python
|
||||
from unilabos.registry.decorators import device, topic_config
|
||||
|
||||
@device(id="mock_gripper", category=["gripper"], description="Mock Gripper")
|
||||
class MockGripper:
|
||||
def __init__(self):
|
||||
self._position: float = 0.0
|
||||
@@ -26,23 +23,19 @@ class MockGripper:
|
||||
self._status = "Idle"
|
||||
|
||||
@property
|
||||
@topic_config() # 添加 @topic_config 才会定时广播
|
||||
def position(self) -> float:
|
||||
return self._position
|
||||
|
||||
@property
|
||||
@topic_config()
|
||||
def velocity(self) -> float:
|
||||
return self._velocity
|
||||
|
||||
@property
|
||||
@topic_config()
|
||||
def torque(self) -> float:
|
||||
return self._torque
|
||||
|
||||
# 使用 @topic_config 装饰的属性,接入 Uni-Lab 时会定时对外广播
|
||||
# 会被自动识别的设备属性,接入 Uni-Lab 时会定时对外广播
|
||||
@property
|
||||
@topic_config(period=2.0) # 可自定义发布周期
|
||||
def status(self) -> str:
|
||||
return self._status
|
||||
|
||||
@@ -156,7 +149,7 @@ my_device: # 设备唯一标识符
|
||||
|
||||
系统会自动分析您的 Python 驱动类并生成:
|
||||
|
||||
- `status_types`:从 `@topic_config` 装饰的 `@property` 或方法自动识别状态属性
|
||||
- `status_types`:从 `@property` 装饰的方法自动识别状态属性
|
||||
- `action_value_mappings`:从类方法自动生成动作映射
|
||||
- `init_param_schema`:从 `__init__` 方法分析初始化参数
|
||||
- `schema`:前端显示用的属性类型定义
|
||||
@@ -186,9 +179,7 @@ Uni-Lab 设备驱动是一个 Python 类,需要遵循以下结构:
|
||||
|
||||
```python
|
||||
from typing import Dict, Any
|
||||
from unilabos.registry.decorators import device, topic_config
|
||||
|
||||
@device(id="my_device", category=["general"], description="My Device")
|
||||
class MyDevice:
|
||||
"""设备类文档字符串
|
||||
|
||||
@@ -207,9 +198,8 @@ class MyDevice:
|
||||
# 初始化硬件连接
|
||||
|
||||
@property
|
||||
@topic_config() # 必须添加 @topic_config 才会广播
|
||||
def status(self) -> str:
|
||||
"""设备状态(通过 @topic_config 广播)"""
|
||||
"""设备状态(会自动广播)"""
|
||||
return self._status
|
||||
|
||||
def my_action(self, param: float) -> Dict[str, Any]:
|
||||
@@ -227,61 +217,34 @@ class MyDevice:
|
||||
|
||||
## 状态属性 vs 动作方法
|
||||
|
||||
### 状态属性(@property + @topic_config)
|
||||
### 状态属性(@property)
|
||||
|
||||
状态属性需要同时使用 `@property` 和 `@topic_config` 装饰器才会被识别并定期广播:
|
||||
状态属性会被自动识别并定期广播:
|
||||
|
||||
```python
|
||||
from unilabos.registry.decorators import topic_config
|
||||
|
||||
@property
|
||||
@topic_config() # 必须添加,否则不会广播
|
||||
def temperature(self) -> float:
|
||||
"""当前温度"""
|
||||
return self._read_temperature()
|
||||
|
||||
@property
|
||||
@topic_config(period=2.0) # 可自定义发布周期(秒)
|
||||
def status(self) -> str:
|
||||
"""设备状态: idle, running, error"""
|
||||
return self._status
|
||||
|
||||
@property
|
||||
@topic_config(name="ready") # 可自定义发布名称
|
||||
def is_ready(self) -> bool:
|
||||
"""设备是否就绪"""
|
||||
return self._status == "idle"
|
||||
```
|
||||
|
||||
也可以使用普通方法(非 @property)配合 `@topic_config`:
|
||||
|
||||
```python
|
||||
@topic_config(period=10.0)
|
||||
def get_sensor_data(self) -> Dict[str, float]:
|
||||
"""获取传感器数据(get_ 前缀会自动去除,发布名为 sensor_data)"""
|
||||
return {"temp": self._temp, "humidity": self._humidity}
|
||||
```
|
||||
|
||||
**`@topic_config` 参数**:
|
||||
|
||||
| 参数 | 类型 | 默认值 | 说明 |
|
||||
|------|------|--------|------|
|
||||
| `period` | float | 5.0 | 发布周期(秒) |
|
||||
| `print_publish` | bool | 节点默认 | 是否打印发布日志 |
|
||||
| `qos` | int | 10 | QoS 深度 |
|
||||
| `name` | str | None | 自定义发布名称 |
|
||||
|
||||
**发布名称优先级**:`@topic_config(name=...)` > `get_` 前缀去除 > 方法名
|
||||
|
||||
**特点**:
|
||||
|
||||
- 必须使用 `@topic_config` 装饰器
|
||||
- 支持 `@property` 和普通方法
|
||||
- 添加到注册表的 `status_types`
|
||||
- 使用`@property`装饰器
|
||||
- 只读,不能有参数
|
||||
- 自动添加到注册表的`status_types`
|
||||
- 定期发布到 ROS2 topic
|
||||
|
||||
> **⚠️ 重要:** 仅有 `@property` 装饰器而没有 `@topic_config` 的属性**不会**被广播。这是一个 Breaking Change。
|
||||
|
||||
### 动作方法
|
||||
|
||||
动作方法是设备可以执行的操作:
|
||||
@@ -534,7 +497,6 @@ class LiquidHandler:
|
||||
self._status = "idle"
|
||||
|
||||
@property
|
||||
@topic_config()
|
||||
def status(self) -> str:
|
||||
return self._status
|
||||
|
||||
@@ -924,52 +886,7 @@ class MyDevice:
|
||||
|
||||
## 最佳实践
|
||||
|
||||
### 1. 使用 `@device` 装饰器标识设备类
|
||||
|
||||
```python
|
||||
from unilabos.registry.decorators import device
|
||||
|
||||
@device(id="my_device", category=["heating"], description="My Heating Device", icon="heater.webp")
|
||||
class MyDevice:
|
||||
...
|
||||
```
|
||||
|
||||
- `id`:设备唯一标识符,用于注册表匹配
|
||||
- `category`:分类列表,前端用于分组显示
|
||||
- `description`:设备描述
|
||||
- `icon`:图标文件名(可选)
|
||||
|
||||
### 2. 使用 `@topic_config` 声明需要广播的状态
|
||||
|
||||
```python
|
||||
from unilabos.registry.decorators import topic_config
|
||||
|
||||
# ✓ @property + @topic_config → 会广播
|
||||
@property
|
||||
@topic_config(period=2.0)
|
||||
def temperature(self) -> float:
|
||||
return self._temp
|
||||
|
||||
# ✓ 普通方法 + @topic_config → 会广播(get_ 前缀自动去除)
|
||||
@topic_config(period=10.0)
|
||||
def get_sensor_data(self) -> Dict[str, float]:
|
||||
return {"temp": self._temp}
|
||||
|
||||
# ✓ 使用 name 参数自定义发布名称
|
||||
@property
|
||||
@topic_config(name="ready")
|
||||
def is_ready(self) -> bool:
|
||||
return self._status == "idle"
|
||||
|
||||
# ✗ 仅有 @property,没有 @topic_config → 不会广播
|
||||
@property
|
||||
def internal_state(self) -> str:
|
||||
return self._state
|
||||
```
|
||||
|
||||
> **注意:** 与 `@property` 连用时,`@topic_config` 必须放在 `@property` 下面。
|
||||
|
||||
### 3. 类型注解
|
||||
### 1. 类型注解
|
||||
|
||||
```python
|
||||
from typing import Dict, Any, Optional, List
|
||||
@@ -984,7 +901,7 @@ def method(
|
||||
pass
|
||||
```
|
||||
|
||||
### 4. 文档字符串
|
||||
### 2. 文档字符串
|
||||
|
||||
```python
|
||||
def method(self, param: float) -> Dict[str, Any]:
|
||||
@@ -1006,7 +923,7 @@ def method(self, param: float) -> Dict[str, Any]:
|
||||
pass
|
||||
```
|
||||
|
||||
### 5. 配置验证
|
||||
### 3. 配置验证
|
||||
|
||||
```python
|
||||
def __init__(self, config: Dict[str, Any]):
|
||||
@@ -1020,7 +937,7 @@ def __init__(self, config: Dict[str, Any]):
|
||||
self.baudrate = config['baudrate']
|
||||
```
|
||||
|
||||
### 6. 资源清理
|
||||
### 4. 资源清理
|
||||
|
||||
```python
|
||||
def __del__(self):
|
||||
@@ -1029,7 +946,7 @@ def __del__(self):
|
||||
self.connection.close()
|
||||
```
|
||||
|
||||
### 7. 设计前端友好的返回值
|
||||
### 5. 设计前端友好的返回值
|
||||
|
||||
**记住:返回值会直接显示在 Web 界面**
|
||||
|
||||
|
||||
@@ -422,20 +422,18 @@ placeholder_keys:
|
||||
|
||||
### status_types
|
||||
|
||||
系统会扫描你的 Python 类,从带有 `@topic_config` 装饰器的 `@property` 或方法自动生成这部分:
|
||||
系统会扫描你的 Python 类,从状态方法(property 或 get\_方法)自动生成这部分:
|
||||
|
||||
```yaml
|
||||
status_types:
|
||||
current_temperature: float # 从 @topic_config 装饰的 @property 或方法
|
||||
is_heating: bool
|
||||
status: str
|
||||
current_temperature: float # 从 get_current_temperature() 或 @property current_temperature
|
||||
is_heating: bool # 从 get_is_heating() 或 @property is_heating
|
||||
status: str # 从 get_status() 或 @property status
|
||||
```
|
||||
|
||||
**注意事项**:
|
||||
|
||||
- 仅有带 `@topic_config` 装饰器的 `@property` 或方法才会被识别为状态属性
|
||||
- 没有 `@topic_config` 的 `@property` 不会生成 status_types,也不会广播
|
||||
- `get_` 前缀的方法名会自动去除前缀(如 `get_temperature` → `temperature`)
|
||||
- 系统会查找所有 `get_` 开头的方法和 `@property` 装饰的属性
|
||||
- 类型会自动转成相应的类型(如 `str`、`float`、`bool`)
|
||||
- 如果类型是 `Any`、`None` 或未知的,默认使用 `String`
|
||||
|
||||
@@ -539,13 +537,11 @@ class AdvancedLiquidHandler:
|
||||
self._temperature = 25.0
|
||||
|
||||
@property
|
||||
@topic_config()
|
||||
def status(self) -> str:
|
||||
"""设备状态"""
|
||||
return self._status
|
||||
|
||||
@property
|
||||
@topic_config()
|
||||
def temperature(self) -> float:
|
||||
"""当前温度"""
|
||||
return self._temperature
|
||||
@@ -813,23 +809,21 @@ my_temperature_controller:
|
||||
你的设备类需要符合以下要求:
|
||||
|
||||
```python
|
||||
from unilabos.registry.decorators import device, topic_config
|
||||
from unilabos.common.device_base import DeviceBase
|
||||
|
||||
@device(id="my_device", category=["temperature"], description="My Device")
|
||||
class MyDevice:
|
||||
class MyDevice(DeviceBase):
|
||||
def __init__(self, config):
|
||||
"""初始化,参数会自动分析到 init_param_schema.config"""
|
||||
super().__init__(config)
|
||||
self.port = config.get('port', '/dev/ttyUSB0')
|
||||
|
||||
# 状态方法(必须添加 @topic_config 才会生成到 status_types 并广播)
|
||||
# 状态方法(会自动生成到 status_types)
|
||||
@property
|
||||
@topic_config()
|
||||
def status(self):
|
||||
"""返回设备状态"""
|
||||
return "idle"
|
||||
|
||||
@property
|
||||
@topic_config()
|
||||
def temperature(self):
|
||||
"""返回当前温度"""
|
||||
return 25.0
|
||||
@@ -1045,34 +1039,7 @@ resource.type # "resource"
|
||||
|
||||
### 代码规范
|
||||
|
||||
1. **使用 `@device` 装饰器标识设备类**
|
||||
|
||||
```python
|
||||
from unilabos.registry.decorators import device
|
||||
|
||||
@device(id="my_device", category=["heating"], description="My Device")
|
||||
class MyDevice:
|
||||
...
|
||||
```
|
||||
|
||||
2. **使用 `@topic_config` 声明广播属性**
|
||||
|
||||
```python
|
||||
from unilabos.registry.decorators import topic_config
|
||||
|
||||
# ✓ 需要广播的状态属性
|
||||
@property
|
||||
@topic_config(period=2.0)
|
||||
def temperature(self) -> float:
|
||||
return self._temp
|
||||
|
||||
# ✗ 仅有 @property 不会广播
|
||||
@property
|
||||
def internal_counter(self) -> int:
|
||||
return self._counter
|
||||
```
|
||||
|
||||
3. **始终使用类型注解**
|
||||
1. **始终使用类型注解**
|
||||
|
||||
```python
|
||||
# ✓ 好
|
||||
@@ -1084,7 +1051,7 @@ def method(self, resource, device):
|
||||
pass
|
||||
```
|
||||
|
||||
4. **提供有意义的参数名**
|
||||
2. **提供有意义的参数名**
|
||||
|
||||
```python
|
||||
# ✓ 好 - 清晰的参数名
|
||||
@@ -1096,7 +1063,7 @@ def transfer(self, r1: ResourceSlot, r2: ResourceSlot):
|
||||
pass
|
||||
```
|
||||
|
||||
5. **使用 Optional 表示可选参数**
|
||||
3. **使用 Optional 表示可选参数**
|
||||
|
||||
```python
|
||||
from typing import Optional
|
||||
@@ -1109,7 +1076,7 @@ def method(
|
||||
pass
|
||||
```
|
||||
|
||||
6. **添加详细的文档字符串**
|
||||
4. **添加详细的文档字符串**
|
||||
|
||||
```python
|
||||
def method(
|
||||
@@ -1129,13 +1096,13 @@ def method(
|
||||
pass
|
||||
```
|
||||
|
||||
7. **方法命名规范**
|
||||
5. **方法命名规范**
|
||||
|
||||
- 状态方法使用 `@property` + `@topic_config` 装饰器,或普通方法 + `@topic_config`
|
||||
- 状态方法使用 `@property` 装饰器或 `get_` 前缀
|
||||
- 动作方法使用动词开头
|
||||
- 保持命名清晰、一致
|
||||
|
||||
8. **完善的错误处理**
|
||||
6. **完善的错误处理**
|
||||
- 实现完善的错误处理
|
||||
- 添加日志记录
|
||||
- 提供有意义的错误信息
|
||||
|
||||
@@ -221,10 +221,10 @@ Laboratory A Laboratory B
|
||||
|
||||
```bash
|
||||
# 实验室A
|
||||
unilab --ak your_ak --sk your_sk --upload_registry
|
||||
unilab --ak your_ak --sk your_sk --upload_registry --use_remote_resource
|
||||
|
||||
# 实验室B
|
||||
unilab --ak your_ak --sk your_sk --upload_registry
|
||||
unilab --ak your_ak --sk your_sk --upload_registry --use_remote_resource
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -12,7 +12,3 @@ sphinx-copybutton>=0.5.0
|
||||
|
||||
# 用于自动摘要生成
|
||||
sphinx-autobuild>=2024.2.4
|
||||
|
||||
# 用于PDF导出 (rinohtype方案,纯Python无需LaTeX)
|
||||
rinohtype>=0.5.4
|
||||
sphinx-simplepdf>=1.6.0
|
||||
@@ -31,14 +31,6 @@
|
||||
|
||||
详细的安装步骤请参考 [安装指南](installation.md)。
|
||||
|
||||
**选择合适的安装包:**
|
||||
|
||||
| 安装包 | 适用场景 | 包含组件 |
|
||||
|--------|----------|----------|
|
||||
| `unilabos` | **推荐大多数用户**,生产部署 | 完整安装包,开箱即用 |
|
||||
| `unilabos-env` | 开发者(可编辑安装) | 仅环境依赖,通过 pip 安装 unilabos |
|
||||
| `unilabos-full` | 仿真/可视化 | unilabos + 完整 ROS2 桌面版 + Gazebo + MoveIt |
|
||||
|
||||
**关键步骤:**
|
||||
|
||||
```bash
|
||||
@@ -46,30 +38,15 @@
|
||||
# 下载 Miniforge: https://github.com/conda-forge/miniforge/releases
|
||||
|
||||
# 2. 创建 Conda 环境
|
||||
mamba create -n unilab python=3.11.14
|
||||
mamba create -n unilab python=3.11.11
|
||||
|
||||
# 3. 激活环境
|
||||
mamba activate unilab
|
||||
|
||||
# 4. 安装 Uni-Lab-OS(选择其一)
|
||||
|
||||
# 方案 A:标准安装(推荐大多数用户)
|
||||
# 4. 安装 Uni-Lab-OS
|
||||
mamba install uni-lab::unilabos -c robostack-staging -c conda-forge
|
||||
|
||||
# 方案 B:开发者环境(可编辑模式开发)
|
||||
mamba install uni-lab::unilabos-env -c robostack-staging -c conda-forge
|
||||
pip install -e /path/to/Uni-Lab-OS # 可编辑安装
|
||||
uv pip install -r unilabos/utils/requirements.txt # 安装 pip 依赖
|
||||
|
||||
# 方案 C:完整版(仿真/可视化)
|
||||
mamba install uni-lab::unilabos-full -c robostack-staging -c conda-forge
|
||||
```
|
||||
|
||||
**选择建议:**
|
||||
- **日常使用/生产部署**:使用 `unilabos`(推荐),完整功能,开箱即用
|
||||
- **开发者**:使用 `unilabos-env` + `pip install -e .` + `uv pip install -r unilabos/utils/requirements.txt`,代码修改立即生效
|
||||
- **仿真/可视化**:使用 `unilabos-full`,含 Gazebo、rviz2、MoveIt
|
||||
|
||||
#### 1.2 验证安装
|
||||
|
||||
```bash
|
||||
@@ -439,9 +416,6 @@ unilab --ak your_ak --sk your_sk -g test/experiments/mock_devices/mock_all.json
|
||||
1. 访问 Web 界面,进入"仪器耗材"模块
|
||||
2. 在"仪器设备"区域找到并添加上述设备
|
||||
3. 在"物料耗材"区域找到并添加容器
|
||||
4. 在workstation中配置protocol_type包含PumpTransferProtocol
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
@@ -452,9 +426,8 @@ unilab --ak your_ak --sk your_sk -g test/experiments/mock_devices/mock_all.json
|
||||
**操作步骤:**
|
||||
|
||||
1. 将两个 `container` 拖拽到 `workstation` 中
|
||||
2. 将 `virtual_multiway_valve` 拖拽到 `workstation` 中
|
||||
3. 将 `virtual_transfer_pump` 拖拽到 `workstation` 中
|
||||
4. 在画布上连接它们(建立父子关系)
|
||||
2. 将 `virtual_transfer_pump` 拖拽到 `workstation` 中
|
||||
3. 在画布上连接它们(建立父子关系)
|
||||
|
||||

|
||||
|
||||
@@ -795,43 +768,7 @@ Waiting for host service...
|
||||
|
||||
详细的设备驱动编写指南请参考 [添加设备驱动](../developer_guide/add_device.md)。
|
||||
|
||||
#### 9.1 开发环境准备
|
||||
|
||||
**推荐使用 `unilabos-env` + `pip install -e .` + `uv pip install`** 进行设备开发:
|
||||
|
||||
```bash
|
||||
# 1. 创建环境并安装 unilabos-env(ROS2 + conda 依赖 + uv)
|
||||
mamba create -n unilab python=3.11.14
|
||||
conda activate unilab
|
||||
mamba install uni-lab::unilabos-env -c robostack-staging -c conda-forge
|
||||
|
||||
# 2. 克隆代码
|
||||
git clone https://github.com/deepmodeling/Uni-Lab-OS.git
|
||||
cd Uni-Lab-OS
|
||||
|
||||
# 3. 以可编辑模式安装(推荐使用脚本,自动检测中文环境)
|
||||
python scripts/dev_install.py
|
||||
|
||||
# 或手动安装:
|
||||
pip install -e .
|
||||
uv pip install -r unilabos/utils/requirements.txt
|
||||
```
|
||||
|
||||
**为什么使用这种方式?**
|
||||
- `unilabos-env` 提供 ROS2 核心组件和 uv(通过 conda 安装,避免编译)
|
||||
- `unilabos/utils/requirements.txt` 包含所有运行时需要的 pip 依赖
|
||||
- `dev_install.py` 自动检测中文环境,中文系统自动使用清华镜像
|
||||
- 使用 `uv` 替代 `pip`,安装速度更快
|
||||
- 可编辑模式:代码修改**立即生效**,无需重新安装
|
||||
|
||||
**如果安装失败或速度太慢**,可以手动执行(使用清华镜像):
|
||||
|
||||
```bash
|
||||
pip install -e . -i https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple
|
||||
uv pip install -r unilabos/utils/requirements.txt -i https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple
|
||||
```
|
||||
|
||||
#### 9.2 为什么需要自定义设备?
|
||||
#### 9.1 为什么需要自定义设备?
|
||||
|
||||
Uni-Lab-OS 内置了常见设备,但您的实验室可能有特殊设备需要集成:
|
||||
|
||||
@@ -840,7 +777,7 @@ Uni-Lab-OS 内置了常见设备,但您的实验室可能有特殊设备需要
|
||||
- 特殊的实验流程
|
||||
- 第三方设备集成
|
||||
|
||||
#### 9.3 创建 Python 包
|
||||
#### 9.2 创建 Python 包
|
||||
|
||||
为了方便开发和管理,建议为您的实验室创建独立的 Python 包。
|
||||
|
||||
@@ -877,7 +814,7 @@ touch my_lab_devices/my_lab_devices/__init__.py
|
||||
touch my_lab_devices/my_lab_devices/devices/__init__.py
|
||||
```
|
||||
|
||||
#### 9.4 创建 setup.py
|
||||
#### 9.3 创建 setup.py
|
||||
|
||||
```python
|
||||
# my_lab_devices/setup.py
|
||||
@@ -908,7 +845,7 @@ setup(
|
||||
)
|
||||
```
|
||||
|
||||
#### 9.5 开发安装
|
||||
#### 9.4 开发安装
|
||||
|
||||
使用 `-e` 参数进行可编辑安装,这样代码修改后立即生效:
|
||||
|
||||
@@ -923,7 +860,7 @@ pip install -e . -i https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple
|
||||
- 方便调试和测试
|
||||
- 支持版本控制(git)
|
||||
|
||||
#### 9.6 编写设备驱动
|
||||
#### 9.5 编写设备驱动
|
||||
|
||||
创建设备驱动文件:
|
||||
|
||||
@@ -1064,7 +1001,7 @@ class MyPump:
|
||||
- **返回 Dict**:所有动作方法返回字典类型
|
||||
- **文档字符串**:详细说明参数和功能
|
||||
|
||||
#### 9.7 测试设备驱动
|
||||
#### 9.6 测试设备驱动
|
||||
|
||||
创建简单的测试脚本:
|
||||
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 81 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 415 KiB After Width: | Height: | Size: 275 KiB |
@@ -13,26 +13,15 @@
|
||||
- 开发者需要 Git 和基本的 Python 开发知识
|
||||
- 自定义 msgs 需要 GitHub 账号
|
||||
|
||||
## 安装包选择
|
||||
|
||||
Uni-Lab-OS 提供三个安装包版本,根据您的需求选择:
|
||||
|
||||
| 安装包 | 适用场景 | 包含组件 | 磁盘占用 |
|
||||
|--------|----------|----------|----------|
|
||||
| **unilabos** | **推荐大多数用户**,生产部署 | 完整安装包,开箱即用 | ~2-3 GB |
|
||||
| **unilabos-env** | 开发者环境(可编辑安装) | 仅环境依赖,通过 pip 安装 unilabos | ~2 GB |
|
||||
| **unilabos-full** | 仿真可视化、完整功能体验 | unilabos + 完整 ROS2 桌面版 + Gazebo + MoveIt | ~8-10 GB |
|
||||
|
||||
## 安装方式选择
|
||||
|
||||
根据您的使用场景,选择合适的安装方式:
|
||||
|
||||
| 安装方式 | 适用人群 | 推荐安装包 | 特点 | 安装时间 |
|
||||
| ---------------------- | -------------------- | ----------------- | ------------------------------ | ---------------------------- |
|
||||
| **方式一:一键安装** | 快速体验、演示 | 预打包环境 | 离线可用,无需配置 | 5-10 分钟 (网络良好的情况下) |
|
||||
| **方式二:手动安装** | **大多数用户** | `unilabos` | 完整功能,开箱即用 | 10-20 分钟 |
|
||||
| **方式三:开发者安装** | 开发者、需要修改源码 | `unilabos-env` | 可编辑模式,支持自定义开发 | 20-30 分钟 |
|
||||
| **仿真/可视化** | 仿真测试、可视化调试 | `unilabos-full` | 含 Gazebo、rviz2、MoveIt | 30-60 分钟 |
|
||||
| 安装方式 | 适用人群 | 特点 | 安装时间 |
|
||||
| ---------------------- | -------------------- | ------------------------------ | ---------------------------- |
|
||||
| **方式一:一键安装** | 实验室用户、快速体验 | 预打包环境,离线可用,无需配置 | 5-10 分钟 (网络良好的情况下) |
|
||||
| **方式二:手动安装** | 标准用户、生产环境 | 灵活配置,版本可控 | 10-20 分钟 |
|
||||
| **方式三:开发者安装** | 开发者、需要修改源码 | 可编辑模式,支持自定义 msgs | 20-30 分钟 |
|
||||
|
||||
---
|
||||
|
||||
@@ -155,38 +144,17 @@ bash Miniforge3-$(uname)-$(uname -m).sh
|
||||
使用以下命令创建 Uni-Lab 专用环境:
|
||||
|
||||
```bash
|
||||
mamba create -n unilab python=3.11.14 # 目前ros2组件依赖版本大多为3.11.14
|
||||
mamba create -n unilab python=3.11.11 # 目前ros2组件依赖版本大多为3.11.11
|
||||
mamba activate unilab
|
||||
|
||||
# 选择安装包(三选一):
|
||||
|
||||
# 方案 A:标准安装(推荐大多数用户)
|
||||
mamba install uni-lab::unilabos -c robostack-staging -c conda-forge
|
||||
|
||||
# 方案 B:开发者环境(可编辑模式开发)
|
||||
mamba install uni-lab::unilabos-env -c robostack-staging -c conda-forge
|
||||
# 然后安装 unilabos 和 pip 依赖:
|
||||
git clone https://github.com/deepmodeling/Uni-Lab-OS.git && cd Uni-Lab-OS
|
||||
pip install -e .
|
||||
uv pip install -r unilabos/utils/requirements.txt
|
||||
|
||||
# 方案 C:完整版(含仿真和可视化工具)
|
||||
mamba install uni-lab::unilabos-full -c robostack-staging -c conda-forge
|
||||
mamba install -n unilab uni-lab::unilabos -c robostack-staging -c conda-forge
|
||||
```
|
||||
|
||||
**参数说明**:
|
||||
|
||||
- `-n unilab`: 创建名为 "unilab" 的环境
|
||||
- `uni-lab::unilabos`: 安装 unilabos 完整包,开箱即用(推荐)
|
||||
- `uni-lab::unilabos-env`: 仅安装环境依赖,适合开发者使用 `pip install -e .`
|
||||
- `uni-lab::unilabos-full`: 安装完整包(含 ROS2 Desktop、Gazebo、MoveIt 等)
|
||||
- `uni-lab::unilabos`: 从 uni-lab channel 安装 unilabos 包
|
||||
- `-c robostack-staging -c conda-forge`: 添加额外的软件源
|
||||
|
||||
**包选择建议**:
|
||||
- **日常使用/生产部署**:安装 `unilabos`(推荐,完整功能,开箱即用)
|
||||
- **开发者**:安装 `unilabos-env`,然后使用 `uv pip install -r unilabos/utils/requirements.txt` 安装依赖,再 `pip install -e .` 进行可编辑安装
|
||||
- **仿真/可视化**:安装 `unilabos-full`(Gazebo、rviz2、MoveIt)
|
||||
|
||||
**如果遇到网络问题**,可以使用清华镜像源加速下载:
|
||||
|
||||
```bash
|
||||
@@ -195,14 +163,8 @@ mamba config --add channels https://mirrors.tuna.tsinghua.edu.cn/anaconda/pkgs/m
|
||||
mamba config --add channels https://mirrors.tuna.tsinghua.edu.cn/anaconda/pkgs/free/
|
||||
mamba config --add channels https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/
|
||||
|
||||
# 然后重新执行安装命令(推荐标准安装)
|
||||
# 然后重新执行安装命令
|
||||
mamba create -n unilab uni-lab::unilabos -c robostack-staging
|
||||
|
||||
# 或完整版(仿真/可视化)
|
||||
mamba create -n unilab uni-lab::unilabos-full -c robostack-staging
|
||||
|
||||
# pip 安装时使用清华镜像(开发者安装时使用)
|
||||
uv pip install -r unilabos/utils/requirements.txt -i https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple
|
||||
```
|
||||
|
||||
### 第三步:激活环境
|
||||
@@ -241,87 +203,58 @@ cd Uni-Lab-OS
|
||||
cd Uni-Lab-OS
|
||||
```
|
||||
|
||||
### 第二步:安装开发环境(unilabos-env)
|
||||
### 第二步:安装基础环境
|
||||
|
||||
**重要**:开发者请使用 `unilabos-env` 包,它专为开发者设计:
|
||||
- 包含 ROS2 核心组件和消息包(ros-humble-ros-core、std-msgs、geometry-msgs 等)
|
||||
- 包含 transforms3d、cv-bridge、tf2 等 conda 依赖
|
||||
- 包含 `uv` 工具,用于快速安装 pip 依赖
|
||||
- **不包含** pip 依赖和 unilabos 包(由 `pip install -e .` 和 `uv pip install` 安装)
|
||||
**推荐方式**:先通过**方式一(一键安装)**或**方式二(手动安装)**完成基础环境的安装,这将包含所有必需的依赖项(ROS2、msgs 等)。
|
||||
|
||||
#### 选项 A:通过一键安装(推荐)
|
||||
|
||||
参考上文"方式一:一键安装",完成基础环境的安装后,激活环境:
|
||||
|
||||
```bash
|
||||
# 创建并激活环境
|
||||
mamba create -n unilab python=3.11.14
|
||||
conda activate unilab
|
||||
|
||||
# 安装开发者环境包(ROS2 + conda 依赖 + uv)
|
||||
mamba install uni-lab::unilabos-env -c robostack-staging -c conda-forge
|
||||
```
|
||||
|
||||
### 第三步:安装 pip 依赖和可编辑模式安装
|
||||
#### 选项 B:通过手动安装
|
||||
|
||||
克隆代码并安装依赖:
|
||||
参考上文"方式二:手动安装",创建并安装环境:
|
||||
|
||||
```bash
|
||||
mamba create -n unilab python=3.11.11
|
||||
conda activate unilab
|
||||
mamba install -n unilab uni-lab::unilabos -c robostack-staging -c conda-forge
|
||||
```
|
||||
|
||||
**说明**:这会安装包括 Python 3.11.11、ROS2 Humble、ros-humble-unilabos-msgs 和所有必需依赖
|
||||
|
||||
### 第三步:切换到开发版本
|
||||
|
||||
现在你已经有了一个完整可用的 Uni-Lab 环境,接下来将 unilabos 包切换为开发版本:
|
||||
|
||||
```bash
|
||||
# 确保环境已激活
|
||||
conda activate unilab
|
||||
|
||||
# 克隆仓库(如果还未克隆)
|
||||
git clone https://github.com/deepmodeling/Uni-Lab-OS.git
|
||||
cd Uni-Lab-OS
|
||||
# 卸载 pip 安装的 unilabos(保留所有 conda 依赖)
|
||||
pip uninstall unilabos -y
|
||||
|
||||
# 切换到 dev 分支(可选)
|
||||
# 克隆 dev 分支(如果还未克隆)
|
||||
cd /path/to/your/workspace
|
||||
git clone -b dev https://github.com/deepmodeling/Uni-Lab-OS.git
|
||||
# 或者如果已经克隆,切换到 dev 分支
|
||||
cd Uni-Lab-OS
|
||||
git checkout dev
|
||||
git pull
|
||||
```
|
||||
|
||||
**推荐:使用安装脚本**(自动检测中文环境,使用 uv 加速):
|
||||
|
||||
```bash
|
||||
# 自动检测中文环境,如果是中文系统则使用清华镜像
|
||||
python scripts/dev_install.py
|
||||
|
||||
# 或者手动指定:
|
||||
python scripts/dev_install.py --china # 强制使用清华镜像
|
||||
python scripts/dev_install.py --no-mirror # 强制使用 PyPI
|
||||
python scripts/dev_install.py --skip-deps # 跳过 pip 依赖安装
|
||||
python scripts/dev_install.py --use-pip # 使用 pip 而非 uv
|
||||
```
|
||||
|
||||
**手动安装**(如果脚本安装失败或速度太慢):
|
||||
|
||||
```bash
|
||||
# 1. 安装 unilabos(可编辑模式)
|
||||
pip install -e .
|
||||
|
||||
# 2. 使用 uv 安装 pip 依赖(推荐,速度更快)
|
||||
uv pip install -r unilabos/utils/requirements.txt
|
||||
|
||||
# 国内用户使用清华镜像:
|
||||
# 以可编辑模式安装开发版 unilabos
|
||||
pip install -e . -i https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple
|
||||
uv pip install -r unilabos/utils/requirements.txt -i https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple
|
||||
```
|
||||
|
||||
**注意**:
|
||||
- `uv` 已包含在 `unilabos-env` 中,无需单独安装
|
||||
- `unilabos/utils/requirements.txt` 包含运行 unilabos 所需的所有 pip 依赖
|
||||
- 部分特殊包(如 pylabrobot)会在运行时由 unilabos 自动检测并安装
|
||||
**参数说明**:
|
||||
|
||||
**为什么使用可编辑模式?**
|
||||
|
||||
- `-e` (editable mode):代码修改**立即生效**,无需重新安装
|
||||
- 适合开发调试:修改代码后直接运行测试
|
||||
- 与 `unilabos-env` 配合:环境依赖由 conda 管理,unilabos 代码由 pip 管理
|
||||
|
||||
**验证安装**:
|
||||
|
||||
```bash
|
||||
# 检查 unilabos 版本
|
||||
python -c "import unilabos; print(unilabos.__version__)"
|
||||
|
||||
# 检查安装位置(应该指向你的代码目录)
|
||||
pip show unilabos | grep Location
|
||||
```
|
||||
- `-e`: editable mode(可编辑模式),代码修改立即生效,无需重新安装
|
||||
- `-i`: 使用清华镜像源加速下载
|
||||
- `pip uninstall unilabos`: 只卸载 pip 安装的 unilabos 包,不影响 conda 安装的其他依赖(如 ROS2、msgs 等)
|
||||
|
||||
### 第四步:安装或自定义 ros-humble-unilabos-msgs(可选)
|
||||
|
||||
@@ -531,45 +464,7 @@ cd $CONDA_PREFIX/envs/unilab
|
||||
|
||||
### 问题 8: 环境很大,有办法减小吗?
|
||||
|
||||
**解决方案**:
|
||||
|
||||
1. **使用 `unilabos` 标准版**(推荐大多数用户):
|
||||
```bash
|
||||
mamba install uni-lab::unilabos -c robostack-staging -c conda-forge
|
||||
```
|
||||
标准版包含完整功能,环境大小约 2-3GB(相比完整版的 8-10GB)。
|
||||
|
||||
2. **使用 `unilabos-env` 开发者版**(最小化):
|
||||
```bash
|
||||
mamba install uni-lab::unilabos-env -c robostack-staging -c conda-forge
|
||||
# 然后手动安装依赖
|
||||
pip install -e .
|
||||
uv pip install -r unilabos/utils/requirements.txt
|
||||
```
|
||||
开发者版只包含环境依赖,体积最小约 2GB。
|
||||
|
||||
3. **按需安装额外组件**:
|
||||
如果后续需要特定功能,可以单独安装:
|
||||
```bash
|
||||
# 需要 Jupyter
|
||||
mamba install jupyter jupyros
|
||||
|
||||
# 需要可视化
|
||||
mamba install matplotlib opencv
|
||||
|
||||
# 需要仿真(注意:这会安装大量依赖)
|
||||
mamba install ros-humble-gazebo-ros
|
||||
```
|
||||
|
||||
4. **预打包环境问题**:
|
||||
预打包环境(方式一)包含所有依赖,通常较大(压缩后 2-5GB)。这是为了确保离线安装和完整功能。
|
||||
|
||||
**包选择建议**:
|
||||
| 需求 | 推荐包 | 预估大小 |
|
||||
|------|--------|----------|
|
||||
| 日常使用/生产部署 | `unilabos` | ~2-3 GB |
|
||||
| 开发调试(可编辑模式) | `unilabos-env` | ~2 GB |
|
||||
| 仿真/可视化 | `unilabos-full` | ~8-10 GB |
|
||||
**解决方案**: 预打包的环境包含所有依赖,通常较大(压缩后 2-5GB)。这是为了确保离线安装和完整功能。如果空间有限,考虑使用方式二手动安装,只安装需要的组件。
|
||||
|
||||
### 问题 9: 如何更新到最新版本?
|
||||
|
||||
@@ -616,7 +511,6 @@ mamba update ros-humble-unilabos-msgs -c uni-lab -c robostack-staging -c conda-f
|
||||
|
||||
**提示**:
|
||||
|
||||
- **大多数用户**推荐使用方式二(手动安装)的 `unilabos` 标准版
|
||||
- **开发者**推荐使用方式三(开发者安装),安装 `unilabos-env` 后使用 `uv pip install -r unilabos/utils/requirements.txt` 安装依赖
|
||||
- **仿真/可视化**推荐安装 `unilabos-full` 完整版
|
||||
- **快速体验和演示**推荐使用方式一(一键安装)
|
||||
- 生产环境推荐使用方式二(手动安装)的稳定版本
|
||||
- 开发和测试推荐使用方式三(开发者安装)
|
||||
- 快速体验和演示推荐使用方式一(一键安装)
|
||||
|
||||
@@ -22,6 +22,7 @@ options:
|
||||
--is_slave Run the backend as slave node (without host privileges).
|
||||
--slave_no_host Skip waiting for host service in slave mode
|
||||
--upload_registry Upload registry information when starting unilab
|
||||
--use_remote_resource Use remote resources when starting unilab
|
||||
--config CONFIG Configuration file path, supports .py format Python config files
|
||||
--port PORT Port for web service information page
|
||||
--disable_browser Disable opening information page on startup
|
||||
@@ -84,7 +85,7 @@ Uni-Lab 的启动过程分为以下几个阶段:
|
||||
支持两种方式:
|
||||
|
||||
- **本地文件**:使用 `-g` 指定图谱文件(支持 JSON 和 GraphML 格式)
|
||||
- **远程资源**:不指定本地文件即可
|
||||
- **远程资源**:使用 `--use_remote_resource` 从云端获取
|
||||
|
||||
### 7. 注册表构建
|
||||
|
||||
@@ -195,7 +196,7 @@ unilab --config path/to/your/config.py
|
||||
unilab --ak your_ak --sk your_sk -g path/to/graph.json --upload_registry
|
||||
|
||||
# 使用远程资源启动
|
||||
unilab --ak your_ak --sk your_sk
|
||||
unilab --ak your_ak --sk your_sk --use_remote_resource
|
||||
|
||||
# 更新注册表
|
||||
unilab --ak your_ak --sk your_sk --complete_registry
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
package:
|
||||
name: ros-humble-unilabos-msgs
|
||||
version: 0.10.19
|
||||
version: 0.10.15
|
||||
source:
|
||||
path: ../../unilabos_msgs
|
||||
target_directory: src
|
||||
@@ -25,7 +25,7 @@ requirements:
|
||||
build:
|
||||
- ${{ compiler('cxx') }}
|
||||
- ${{ compiler('c') }}
|
||||
- python ==3.11.14
|
||||
- python ==3.11.11
|
||||
- numpy
|
||||
- if: build_platform != target_platform
|
||||
then:
|
||||
@@ -63,14 +63,14 @@ requirements:
|
||||
- robostack-staging::ros-humble-rosidl-default-generators
|
||||
- robostack-staging::ros-humble-std-msgs
|
||||
- robostack-staging::ros-humble-geometry-msgs
|
||||
- robostack-staging::ros2-distro-mutex=0.7
|
||||
- robostack-staging::ros2-distro-mutex=0.6
|
||||
run:
|
||||
- robostack-staging::ros-humble-action-msgs
|
||||
- robostack-staging::ros-humble-ros-workspace
|
||||
- robostack-staging::ros-humble-rosidl-default-runtime
|
||||
- robostack-staging::ros-humble-std-msgs
|
||||
- robostack-staging::ros-humble-geometry-msgs
|
||||
- robostack-staging::ros2-distro-mutex=0.7
|
||||
- robostack-staging::ros2-distro-mutex=0.6
|
||||
- if: osx and x86_64
|
||||
then:
|
||||
- __osx >=${{ MACOSX_DEPLOYMENT_TARGET|default('10.14') }}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
package:
|
||||
name: unilabos
|
||||
version: "0.10.19"
|
||||
version: "0.10.15"
|
||||
|
||||
source:
|
||||
path: ../..
|
||||
|
||||
@@ -85,7 +85,7 @@ Verification:
|
||||
-------------
|
||||
|
||||
The verify_installation.py script will check:
|
||||
- Python version (3.11.14)
|
||||
- Python version (3.11.11)
|
||||
- ROS2 rclpy installation
|
||||
- UniLabOS installation and dependencies
|
||||
|
||||
@@ -104,7 +104,7 @@ Build Information:
|
||||
|
||||
Branch: {branch}
|
||||
Platform: {platform}
|
||||
Python: 3.11.14
|
||||
Python: 3.11.11
|
||||
Date: {build_date}
|
||||
|
||||
Troubleshooting:
|
||||
|
||||
@@ -1,214 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Development installation script for UniLabOS.
|
||||
Auto-detects Chinese locale and uses appropriate mirror.
|
||||
|
||||
Usage:
|
||||
python scripts/dev_install.py
|
||||
python scripts/dev_install.py --no-mirror # Force no mirror
|
||||
python scripts/dev_install.py --china # Force China mirror
|
||||
python scripts/dev_install.py --skip-deps # Skip pip dependencies installation
|
||||
|
||||
Flow:
|
||||
1. pip install -e . (install unilabos in editable mode)
|
||||
2. Detect Chinese locale
|
||||
3. Use uv to install pip dependencies from requirements.txt
|
||||
4. Special packages (like pylabrobot) are handled by environment_check.py at runtime
|
||||
"""
|
||||
|
||||
import locale
|
||||
import subprocess
|
||||
import sys
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
|
||||
# Tsinghua mirror URL
|
||||
TSINGHUA_MIRROR = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple"
|
||||
|
||||
|
||||
def is_chinese_locale() -> bool:
|
||||
"""
|
||||
Detect if system is in Chinese locale.
|
||||
Same logic as EnvironmentChecker._is_chinese_locale()
|
||||
"""
|
||||
try:
|
||||
lang = locale.getdefaultlocale()[0]
|
||||
if lang and ("zh" in lang.lower() or "chinese" in lang.lower()):
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
return False
|
||||
|
||||
|
||||
def run_command(cmd: list, description: str, retry: int = 2) -> bool:
|
||||
"""Run command with retry support."""
|
||||
print(f"[INFO] {description}")
|
||||
print(f"[CMD] {' '.join(cmd)}")
|
||||
|
||||
for attempt in range(retry + 1):
|
||||
try:
|
||||
result = subprocess.run(cmd, check=True, timeout=600)
|
||||
print(f"[OK] {description}")
|
||||
return True
|
||||
except subprocess.CalledProcessError as e:
|
||||
if attempt < retry:
|
||||
print(f"[WARN] Attempt {attempt + 1} failed, retrying...")
|
||||
else:
|
||||
print(f"[ERROR] {description} failed: {e}")
|
||||
return False
|
||||
except subprocess.TimeoutExpired:
|
||||
print(f"[ERROR] {description} timed out")
|
||||
return False
|
||||
return False
|
||||
|
||||
|
||||
def install_editable(project_root: Path, use_mirror: bool) -> bool:
|
||||
"""Install unilabos in editable mode using pip."""
|
||||
cmd = [sys.executable, "-m", "pip", "install", "-e", str(project_root)]
|
||||
if use_mirror:
|
||||
cmd.extend(["-i", TSINGHUA_MIRROR])
|
||||
|
||||
return run_command(cmd, "Installing unilabos in editable mode")
|
||||
|
||||
|
||||
def install_requirements_uv(requirements_file: Path, use_mirror: bool) -> bool:
|
||||
"""Install pip dependencies using uv (installed via conda-forge::uv)."""
|
||||
cmd = ["uv", "pip", "install", "-r", str(requirements_file)]
|
||||
if use_mirror:
|
||||
cmd.extend(["-i", TSINGHUA_MIRROR])
|
||||
|
||||
return run_command(cmd, "Installing pip dependencies with uv", retry=2)
|
||||
|
||||
|
||||
def install_requirements_pip(requirements_file: Path, use_mirror: bool) -> bool:
|
||||
"""Fallback: Install pip dependencies using pip."""
|
||||
cmd = [sys.executable, "-m", "pip", "install", "-r", str(requirements_file)]
|
||||
if use_mirror:
|
||||
cmd.extend(["-i", TSINGHUA_MIRROR])
|
||||
|
||||
return run_command(cmd, "Installing pip dependencies with pip", retry=2)
|
||||
|
||||
|
||||
def check_uv_available() -> bool:
|
||||
"""Check if uv is available (installed via conda-forge::uv)."""
|
||||
try:
|
||||
subprocess.run(["uv", "--version"], capture_output=True, check=True)
|
||||
return True
|
||||
except (subprocess.CalledProcessError, FileNotFoundError):
|
||||
return False
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Development installation script for UniLabOS")
|
||||
parser.add_argument("--china", action="store_true", help="Force use China mirror (Tsinghua)")
|
||||
parser.add_argument("--no-mirror", action="store_true", help="Force use default PyPI (no mirror)")
|
||||
parser.add_argument(
|
||||
"--skip-deps", action="store_true", help="Skip pip dependencies installation (only install unilabos)"
|
||||
)
|
||||
parser.add_argument("--use-pip", action="store_true", help="Use pip instead of uv for dependencies")
|
||||
args = parser.parse_args()
|
||||
|
||||
# Determine project root
|
||||
script_dir = Path(__file__).parent
|
||||
project_root = script_dir.parent
|
||||
requirements_file = project_root / "unilabos" / "utils" / "requirements.txt"
|
||||
|
||||
if not (project_root / "setup.py").exists():
|
||||
print(f"[ERROR] setup.py not found in {project_root}")
|
||||
sys.exit(1)
|
||||
|
||||
print("=" * 60)
|
||||
print("UniLabOS Development Installation")
|
||||
print("=" * 60)
|
||||
print(f"Project root: {project_root}")
|
||||
print()
|
||||
|
||||
# Determine mirror usage based on locale
|
||||
if args.no_mirror:
|
||||
use_mirror = False
|
||||
print("[INFO] Mirror disabled by --no-mirror flag")
|
||||
elif args.china:
|
||||
use_mirror = True
|
||||
print("[INFO] China mirror enabled by --china flag")
|
||||
else:
|
||||
use_mirror = is_chinese_locale()
|
||||
if use_mirror:
|
||||
print("[INFO] Chinese locale detected, using Tsinghua mirror")
|
||||
else:
|
||||
print("[INFO] Non-Chinese locale detected, using default PyPI")
|
||||
|
||||
print()
|
||||
|
||||
# Step 1: Install unilabos in editable mode
|
||||
print("[STEP 1] Installing unilabos in editable mode...")
|
||||
if not install_editable(project_root, use_mirror):
|
||||
print("[ERROR] Failed to install unilabos")
|
||||
print()
|
||||
print("Manual fallback:")
|
||||
if use_mirror:
|
||||
print(f" pip install -e {project_root} -i {TSINGHUA_MIRROR}")
|
||||
else:
|
||||
print(f" pip install -e {project_root}")
|
||||
sys.exit(1)
|
||||
|
||||
print()
|
||||
|
||||
# Step 2: Install pip dependencies
|
||||
if args.skip_deps:
|
||||
print("[INFO] Skipping pip dependencies installation (--skip-deps)")
|
||||
else:
|
||||
print("[STEP 2] Installing pip dependencies...")
|
||||
|
||||
if not requirements_file.exists():
|
||||
print(f"[WARN] Requirements file not found: {requirements_file}")
|
||||
print("[INFO] Skipping dependencies installation")
|
||||
else:
|
||||
# Try uv first (faster), fallback to pip
|
||||
if args.use_pip:
|
||||
print("[INFO] Using pip (--use-pip flag)")
|
||||
success = install_requirements_pip(requirements_file, use_mirror)
|
||||
elif check_uv_available():
|
||||
print("[INFO] Using uv (installed via conda-forge::uv)")
|
||||
success = install_requirements_uv(requirements_file, use_mirror)
|
||||
if not success:
|
||||
print("[WARN] uv failed, falling back to pip...")
|
||||
success = install_requirements_pip(requirements_file, use_mirror)
|
||||
else:
|
||||
print("[WARN] uv not available (should be installed via: mamba install conda-forge::uv)")
|
||||
print("[INFO] Falling back to pip...")
|
||||
success = install_requirements_pip(requirements_file, use_mirror)
|
||||
|
||||
if not success:
|
||||
print()
|
||||
print("[WARN] Failed to install some dependencies automatically.")
|
||||
print("You can manually install them:")
|
||||
if use_mirror:
|
||||
print(f" uv pip install -r {requirements_file} -i {TSINGHUA_MIRROR}")
|
||||
print(" or:")
|
||||
print(f" pip install -r {requirements_file} -i {TSINGHUA_MIRROR}")
|
||||
else:
|
||||
print(f" uv pip install -r {requirements_file}")
|
||||
print(" or:")
|
||||
print(f" pip install -r {requirements_file}")
|
||||
|
||||
print()
|
||||
print("=" * 60)
|
||||
print("Installation complete!")
|
||||
print("=" * 60)
|
||||
print()
|
||||
print("Note: Some special packages (like pylabrobot) are installed")
|
||||
print("automatically at runtime by unilabos if needed.")
|
||||
print()
|
||||
print("Verify installation:")
|
||||
print(' python -c "import unilabos; print(unilabos.__version__)"')
|
||||
print()
|
||||
print("If you encounter issues, you can manually install dependencies:")
|
||||
if use_mirror:
|
||||
print(f" uv pip install -r unilabos/utils/requirements.txt -i {TSINGHUA_MIRROR}")
|
||||
else:
|
||||
print(" uv pip install -r unilabos/utils/requirements.txt")
|
||||
print()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
2
setup.py
2
setup.py
@@ -4,7 +4,7 @@ package_name = 'unilabos'
|
||||
|
||||
setup(
|
||||
name=package_name,
|
||||
version='0.10.19',
|
||||
version='0.10.15',
|
||||
packages=find_packages(),
|
||||
include_package_data=True,
|
||||
install_requires=['setuptools'],
|
||||
|
||||
@@ -1,539 +0,0 @@
|
||||
import pytest
|
||||
import json
|
||||
import os
|
||||
import asyncio
|
||||
import collections
|
||||
from typing import List, Dict, Any
|
||||
|
||||
from pylabrobot.resources import Coordinate
|
||||
from pylabrobot.resources.opentrons.tip_racks import opentrons_96_tiprack_300ul, opentrons_96_tiprack_10ul
|
||||
from pylabrobot.resources.opentrons.plates import corning_96_wellplate_360ul_flat, nest_96_wellplate_2ml_deep
|
||||
|
||||
from unilabos.devices.liquid_handling.prcxi.prcxi import (
|
||||
PRCXI9300Deck,
|
||||
PRCXI9300Container,
|
||||
PRCXI9300Trash,
|
||||
PRCXI9300Handler,
|
||||
PRCXI9300Backend,
|
||||
DefaultLayout,
|
||||
Material,
|
||||
WorkTablets,
|
||||
MatrixInfo
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def prcxi_materials() -> Dict[str, Any]:
|
||||
"""加载 PRCXI 物料数据"""
|
||||
print("加载 PRCXI 物料数据...")
|
||||
material_path = os.path.join(os.path.dirname(__file__), "..", "..", "unilabos", "devices", "liquid_handling", "prcxi", "prcxi_material.json")
|
||||
with open(material_path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
print(f"加载了 {len(data)} 条物料数据")
|
||||
return data
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def prcxi_9300_deck() -> PRCXI9300Deck:
|
||||
"""创建 PRCXI 9300 工作台"""
|
||||
return PRCXI9300Deck(name="PRCXI_Deck_9300", size_x=100, size_y=100, size_z=100, model="9300")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def prcxi_9320_deck() -> PRCXI9300Deck:
|
||||
"""创建 PRCXI 9320 工作台"""
|
||||
return PRCXI9300Deck(name="PRCXI_Deck_9320", size_x=100, size_y=100, size_z=100, model="9320")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def prcxi_9300_handler(prcxi_9300_deck) -> PRCXI9300Handler:
|
||||
"""创建 PRCXI 9300 处理器(模拟模式)"""
|
||||
return PRCXI9300Handler(
|
||||
deck=prcxi_9300_deck,
|
||||
host="192.168.1.201",
|
||||
port=9999,
|
||||
timeout=10.0,
|
||||
channel_num=8,
|
||||
axis="Left",
|
||||
setup=False,
|
||||
debug=True,
|
||||
simulator=True,
|
||||
matrix_id="test-matrix-9300"
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def prcxi_9320_handler(prcxi_9320_deck) -> PRCXI9300Handler:
|
||||
"""创建 PRCXI 9320 处理器(模拟模式)"""
|
||||
return PRCXI9300Handler(
|
||||
deck=prcxi_9320_deck,
|
||||
host="192.168.1.201",
|
||||
port=9999,
|
||||
timeout=10.0,
|
||||
channel_num=1,
|
||||
axis="Right",
|
||||
setup=False,
|
||||
debug=True,
|
||||
simulator=True,
|
||||
matrix_id="test-matrix-9320",
|
||||
is_9320=True
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tip_rack_300ul(prcxi_materials) -> PRCXI9300Container:
|
||||
"""创建 300μL 枪头盒"""
|
||||
tip_rack = PRCXI9300Container(
|
||||
name="tip_rack_300ul",
|
||||
size_x=50,
|
||||
size_y=50,
|
||||
size_z=10,
|
||||
category="tip_rack",
|
||||
ordering=collections.OrderedDict()
|
||||
)
|
||||
tip_rack.load_state({
|
||||
"Material": {
|
||||
"uuid": prcxi_materials["300μL Tip头"]["uuid"],
|
||||
"Code": "ZX-001-300",
|
||||
"Name": "300μL Tip头"
|
||||
}
|
||||
})
|
||||
return tip_rack
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tip_rack_10ul(prcxi_materials) -> PRCXI9300Container:
|
||||
"""创建 10μL 枪头盒"""
|
||||
tip_rack = PRCXI9300Container(
|
||||
name="tip_rack_10ul",
|
||||
size_x=50,
|
||||
size_y=50,
|
||||
size_z=10,
|
||||
category="tip_rack",
|
||||
ordering=collections.OrderedDict()
|
||||
)
|
||||
tip_rack.load_state({
|
||||
"Material": {
|
||||
"uuid": prcxi_materials["10μL加长 Tip头"]["uuid"],
|
||||
"Code": "ZX-001-10+",
|
||||
"Name": "10μL加长 Tip头"
|
||||
}
|
||||
})
|
||||
return tip_rack
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def well_plate_96(prcxi_materials) -> PRCXI9300Container:
|
||||
"""创建 96 孔板"""
|
||||
plate = PRCXI9300Container(
|
||||
name="well_plate_96",
|
||||
size_x=50,
|
||||
size_y=50,
|
||||
size_z=10,
|
||||
category="plate",
|
||||
ordering=collections.OrderedDict()
|
||||
)
|
||||
plate.load_state({
|
||||
"Material": {
|
||||
"uuid": prcxi_materials["96深孔板"]["uuid"],
|
||||
"Code": "ZX-019-2.2",
|
||||
"Name": "96深孔板"
|
||||
}
|
||||
})
|
||||
return plate
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def deep_well_plate(prcxi_materials) -> PRCXI9300Container:
|
||||
"""创建深孔板"""
|
||||
plate = PRCXI9300Container(
|
||||
name="deep_well_plate",
|
||||
size_x=50,
|
||||
size_y=50,
|
||||
size_z=10,
|
||||
category="plate",
|
||||
ordering=collections.OrderedDict()
|
||||
)
|
||||
plate.load_state({
|
||||
"Material": {
|
||||
"uuid": prcxi_materials["96深孔板"]["uuid"],
|
||||
"Code": "ZX-019-2.2",
|
||||
"Name": "96深孔板"
|
||||
}
|
||||
})
|
||||
return plate
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def trash_container(prcxi_materials) -> PRCXI9300Trash:
|
||||
"""创建垃圾桶"""
|
||||
trash = PRCXI9300Trash(name="trash", size_x=50, size_y=50, size_z=10, category="trash")
|
||||
trash.load_state({
|
||||
"Material": {
|
||||
"uuid": prcxi_materials["废弃槽"]["uuid"]
|
||||
}
|
||||
})
|
||||
return trash
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def default_layout_9300() -> DefaultLayout:
|
||||
"""创建 PRCXI 9300 默认布局"""
|
||||
return DefaultLayout("PRCXI9300")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def default_layout_9320() -> DefaultLayout:
|
||||
"""创建 PRCXI 9320 默认布局"""
|
||||
return DefaultLayout("PRCXI9320")
|
||||
|
||||
|
||||
class TestPRCXIDeckSetup:
|
||||
"""测试 PRCXI 工作台设置功能"""
|
||||
|
||||
def test_prcxi_9300_deck_creation(self, prcxi_9300_deck):
|
||||
"""测试 PRCXI 9300 工作台创建"""
|
||||
assert prcxi_9300_deck.name == "PRCXI_Deck_9300"
|
||||
assert len(prcxi_9300_deck.sites) == 6
|
||||
assert prcxi_9300_deck._size_x == 100
|
||||
assert prcxi_9300_deck._size_y == 100
|
||||
assert prcxi_9300_deck._size_z == 100
|
||||
|
||||
def test_prcxi_9320_deck_creation(self, prcxi_9320_deck):
|
||||
"""测试 PRCXI 9320 工作台创建"""
|
||||
assert prcxi_9320_deck.name == "PRCXI_Deck_9320"
|
||||
assert len(prcxi_9320_deck.sites) == 16
|
||||
assert prcxi_9320_deck._size_x == 100
|
||||
assert prcxi_9320_deck._size_y == 100
|
||||
assert prcxi_9320_deck._size_z == 100
|
||||
|
||||
def test_container_assignment(self, prcxi_9300_deck, tip_rack_300ul, well_plate_96, trash_container):
|
||||
"""测试容器分配到工作台"""
|
||||
# 分配枪头盒
|
||||
prcxi_9300_deck.assign_child_resource(tip_rack_300ul, location=Coordinate(0, 0, 0))
|
||||
assert tip_rack_300ul in prcxi_9300_deck.children
|
||||
|
||||
# 分配孔板
|
||||
prcxi_9300_deck.assign_child_resource(well_plate_96, location=Coordinate(0, 0, 0))
|
||||
assert well_plate_96 in prcxi_9300_deck.children
|
||||
|
||||
# 分配垃圾桶
|
||||
prcxi_9300_deck.assign_child_resource(trash_container, location=Coordinate(0, 0, 0))
|
||||
assert trash_container in prcxi_9300_deck.children
|
||||
|
||||
def test_container_material_loading(self, tip_rack_300ul, well_plate_96, prcxi_materials):
|
||||
"""测试容器物料信息加载"""
|
||||
# 测试枪头盒物料信息
|
||||
tip_material = tip_rack_300ul._unilabos_state["Material"]
|
||||
assert tip_material["uuid"] == prcxi_materials["300μL Tip头"]["uuid"]
|
||||
assert tip_material["Name"] == "300μL Tip头"
|
||||
|
||||
# 测试孔板物料信息
|
||||
plate_material = well_plate_96._unilabos_state["Material"]
|
||||
assert plate_material["uuid"] == prcxi_materials["96深孔板"]["uuid"]
|
||||
assert plate_material["Name"] == "96深孔板"
|
||||
|
||||
|
||||
class TestPRCXISingleStepOperations:
|
||||
"""测试 PRCXI 单步操作功能"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pick_up_tips_single_channel(self, prcxi_9320_handler, prcxi_9320_deck, tip_rack_10ul):
|
||||
"""测试单通道拾取枪头"""
|
||||
# 将枪头盒添加到工作台
|
||||
prcxi_9320_deck.assign_child_resource(tip_rack_10ul, location=Coordinate(0, 0, 0))
|
||||
|
||||
# 初始化处理器
|
||||
await prcxi_9320_handler.setup()
|
||||
|
||||
# 设置枪头盒
|
||||
prcxi_9320_handler.set_tiprack([tip_rack_10ul])
|
||||
|
||||
# 创建模拟的枪头位置
|
||||
from pylabrobot.resources import TipSpot, Tip
|
||||
tip = Tip(has_filter=False, total_tip_length=10, maximal_volume=10, fitting_depth=5)
|
||||
tip_spot = TipSpot("A1", size_x=1, size_y=1, size_z=1, make_tip=lambda: tip)
|
||||
tip_rack_10ul.assign_child_resource(tip_spot, location=Coordinate(0, 0, 0))
|
||||
|
||||
# 直接测试后端方法
|
||||
from pylabrobot.liquid_handling import Pickup
|
||||
pickup = Pickup(resource=tip_spot, offset=None, tip=tip)
|
||||
await prcxi_9320_handler._unilabos_backend.pick_up_tips([pickup], [0])
|
||||
|
||||
# 验证步骤已添加到待办列表
|
||||
assert len(prcxi_9320_handler._unilabos_backend.steps_todo_list) == 1
|
||||
step = prcxi_9320_handler._unilabos_backend.steps_todo_list[0]
|
||||
assert step["Function"] == "Load"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pick_up_tips_multi_channel(self, prcxi_9300_handler, tip_rack_300ul):
|
||||
"""测试多通道拾取枪头"""
|
||||
# 设置枪头盒
|
||||
prcxi_9300_handler.set_tiprack([tip_rack_300ul])
|
||||
|
||||
# 拾取8个枪头
|
||||
tip_spots = tip_rack_300ul.children[:8]
|
||||
await prcxi_9300_handler.pick_up_tips(tip_spots, [0, 1, 2, 3, 4, 5, 6, 7])
|
||||
|
||||
# 验证步骤已添加到待办列表
|
||||
assert len(prcxi_9300_handler._unilabos_backend.steps_todo_list) == 1
|
||||
step = prcxi_9300_handler._unilabos_backend.steps_todo_list[0]
|
||||
assert step["Function"] == "Load"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_aspirate_single_channel(self, prcxi_9320_handler, well_plate_96):
|
||||
"""测试单通道吸取液体"""
|
||||
# 设置液体
|
||||
well = well_plate_96.get_item("A1")
|
||||
prcxi_9320_handler.set_liquid([well], ["water"], [50])
|
||||
|
||||
# 吸取液体
|
||||
await prcxi_9320_handler.aspirate([well], [50], [0])
|
||||
|
||||
# 验证步骤已添加到待办列表
|
||||
assert len(prcxi_9320_handler._unilabos_backend.steps_todo_list) == 1
|
||||
step = prcxi_9320_handler._unilabos_backend.steps_todo_list[0]
|
||||
assert step["Function"] == "Imbibing"
|
||||
assert step["DosageNum"] == 50
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dispense_single_channel(self, prcxi_9320_handler, well_plate_96):
|
||||
"""测试单通道分配液体"""
|
||||
# 分配液体
|
||||
well = well_plate_96.get_item("A1")
|
||||
await prcxi_9320_handler.dispense([well], [25], [0])
|
||||
|
||||
# 验证步骤已添加到待办列表
|
||||
assert len(prcxi_9320_handler._unilabos_backend.steps_todo_list) == 1
|
||||
step = prcxi_9320_handler._unilabos_backend.steps_todo_list[0]
|
||||
assert step["Function"] == "Tapping"
|
||||
assert step["DosageNum"] == 25
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mix_single_channel(self, prcxi_9320_handler, well_plate_96):
|
||||
"""测试单通道混合液体"""
|
||||
# 混合液体
|
||||
well = well_plate_96.get_item("A1")
|
||||
await prcxi_9320_handler.mix([well], mix_time=3, mix_vol=50)
|
||||
|
||||
# 验证步骤已添加到待办列表
|
||||
assert len(prcxi_9320_handler._unilabos_backend.steps_todo_list) == 1
|
||||
step = prcxi_9320_handler._unilabos_backend.steps_todo_list[0]
|
||||
assert step["Function"] == "Blending"
|
||||
assert step["BlendingTimes"] == 3
|
||||
assert step["DosageNum"] == 50
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_drop_tips_to_trash(self, prcxi_9320_handler, trash_container):
|
||||
"""测试丢弃枪头到垃圾桶"""
|
||||
# 丢弃枪头
|
||||
await prcxi_9320_handler.drop_tips([trash_container], [0])
|
||||
|
||||
# 验证步骤已添加到待办列表
|
||||
assert len(prcxi_9320_handler._unilabos_backend.steps_todo_list) == 1
|
||||
step = prcxi_9320_handler._unilabos_backend.steps_todo_list[0]
|
||||
assert step["Function"] == "UnLoad"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_discard_tips(self, prcxi_9320_handler):
|
||||
"""测试丢弃枪头"""
|
||||
# 丢弃枪头
|
||||
await prcxi_9320_handler.discard_tips([0])
|
||||
|
||||
# 验证步骤已添加到待办列表
|
||||
assert len(prcxi_9320_handler._unilabos_backend.steps_todo_list) == 1
|
||||
step = prcxi_9320_handler._unilabos_backend.steps_todo_list[0]
|
||||
assert step["Function"] == "UnLoad"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_liquid_transfer_workflow(self, prcxi_9320_handler, tip_rack_10ul, well_plate_96):
|
||||
"""测试完整的液体转移工作流程"""
|
||||
# 设置枪头盒和液体
|
||||
prcxi_9320_handler.set_tiprack([tip_rack_10ul])
|
||||
source_well = well_plate_96.get_item("A1")
|
||||
target_well = well_plate_96.get_item("B1")
|
||||
prcxi_9320_handler.set_liquid([source_well], ["water"], [100])
|
||||
|
||||
# 创建协议
|
||||
await prcxi_9320_handler.create_protocol(protocol_name="Test Transfer Protocol")
|
||||
|
||||
# 执行转移流程
|
||||
tip_spot = tip_rack_10ul.get_item("A1")
|
||||
await prcxi_9320_handler.pick_up_tips([tip_spot], [0])
|
||||
await prcxi_9320_handler.aspirate([source_well], [50], [0])
|
||||
await prcxi_9320_handler.dispense([target_well], [50], [0])
|
||||
await prcxi_9320_handler.discard_tips([0])
|
||||
|
||||
# 验证所有步骤都已添加
|
||||
assert len(prcxi_9320_handler._unilabos_backend.steps_todo_list) == 4
|
||||
functions = [step["Function"] for step in prcxi_9320_handler._unilabos_backend.steps_todo_list]
|
||||
assert functions == ["Load", "Imbibing", "Tapping", "UnLoad"]
|
||||
|
||||
|
||||
class TestPRCXILayoutRecommendation:
|
||||
"""测试 PRCXI 板位推荐功能"""
|
||||
|
||||
def test_9300_layout_creation(self, default_layout_9300):
|
||||
"""测试 PRCXI 9300 布局创建"""
|
||||
layout_info = default_layout_9300.get_layout()
|
||||
assert layout_info["rows"] == 2
|
||||
assert layout_info["columns"] == 3
|
||||
assert len(layout_info["layout"]) == 6
|
||||
assert layout_info["trash_slot"] == 6
|
||||
assert "waste_liquid_slot" not in layout_info
|
||||
|
||||
def test_9320_layout_creation(self, default_layout_9320):
|
||||
"""测试 PRCXI 9320 布局创建"""
|
||||
layout_info = default_layout_9320.get_layout()
|
||||
assert layout_info["rows"] == 4
|
||||
assert layout_info["columns"] == 4
|
||||
assert len(layout_info["layout"]) == 16
|
||||
assert layout_info["trash_slot"] == 16
|
||||
assert layout_info["waste_liquid_slot"] == 12
|
||||
|
||||
def test_layout_recommendation_9320(self, default_layout_9320, prcxi_materials):
|
||||
"""测试 PRCXI 9320 板位推荐功能"""
|
||||
# 添加物料信息
|
||||
default_layout_9320.add_lab_resource(prcxi_materials)
|
||||
|
||||
# 推荐布局
|
||||
needs = [
|
||||
("reagent_1", "96 细胞培养皿", 3),
|
||||
("reagent_2", "12道储液槽", 1),
|
||||
("reagent_3", "200μL Tip头", 7),
|
||||
("reagent_4", "10μL加长 Tip头", 1),
|
||||
]
|
||||
|
||||
matrix_layout, layout_list = default_layout_9320.recommend_layout(needs)
|
||||
|
||||
# 验证返回结果
|
||||
assert "MatrixId" in matrix_layout
|
||||
assert "MatrixName" in matrix_layout
|
||||
assert "MatrixCount" in matrix_layout
|
||||
assert "WorkTablets" in matrix_layout
|
||||
assert len(layout_list) == 12 # 3+1+7+1 = 12个位置
|
||||
|
||||
# 验证推荐的位置不包含预留位置
|
||||
reserved_positions = {12, 16}
|
||||
recommended_positions = [item["positions"] for item in layout_list]
|
||||
for pos in recommended_positions:
|
||||
assert pos not in reserved_positions
|
||||
|
||||
def test_layout_recommendation_insufficient_space(self, default_layout_9320, prcxi_materials):
|
||||
"""测试板位推荐空间不足的情况"""
|
||||
# 添加物料信息
|
||||
default_layout_9320.add_lab_resource(prcxi_materials)
|
||||
|
||||
# 尝试推荐超过可用空间的布局
|
||||
needs = [
|
||||
("reagent_1", "96 细胞培养皿", 15), # 需要15个位置,但只有14个可用
|
||||
]
|
||||
|
||||
with pytest.raises(ValueError, match="需要 .* 个位置,但只有 .* 个可用位置"):
|
||||
default_layout_9320.recommend_layout(needs)
|
||||
|
||||
def test_layout_recommendation_material_not_found(self, default_layout_9320, prcxi_materials):
|
||||
"""测试板位推荐物料不存在的情况"""
|
||||
# 添加物料信息
|
||||
default_layout_9320.add_lab_resource(prcxi_materials)
|
||||
|
||||
# 尝试推荐不存在的物料
|
||||
needs = [
|
||||
("reagent_1", "不存在的物料", 1),
|
||||
]
|
||||
|
||||
with pytest.raises(ValueError, match="Material .* not found in lab resources"):
|
||||
default_layout_9320.recommend_layout(needs)
|
||||
|
||||
|
||||
class TestPRCXIBackendOperations:
|
||||
"""测试 PRCXI 后端操作功能"""
|
||||
|
||||
def test_backend_initialization(self, prcxi_9300_handler):
|
||||
"""测试后端初始化"""
|
||||
backend = prcxi_9300_handler._unilabos_backend
|
||||
assert isinstance(backend, PRCXI9300Backend)
|
||||
assert backend._num_channels == 8
|
||||
assert backend.debug is True
|
||||
|
||||
def test_protocol_creation(self, prcxi_9300_handler):
|
||||
"""测试协议创建"""
|
||||
backend = prcxi_9300_handler._unilabos_backend
|
||||
backend.create_protocol("Test Protocol")
|
||||
assert backend.protocol_name == "Test Protocol"
|
||||
assert len(backend.steps_todo_list) == 0
|
||||
|
||||
def test_channel_validation(self):
|
||||
"""测试通道验证"""
|
||||
# 测试正确的8通道配置
|
||||
valid_channels = [0, 1, 2, 3, 4, 5, 6, 7]
|
||||
result = PRCXI9300Backend.check_channels(valid_channels)
|
||||
assert result == valid_channels
|
||||
|
||||
# 测试错误的通道配置
|
||||
invalid_channels = [0, 1, 2, 3]
|
||||
result = PRCXI9300Backend.check_channels(invalid_channels)
|
||||
assert result == [0, 1, 2, 3, 4, 5, 6, 7]
|
||||
|
||||
def test_matrix_info_creation(self, prcxi_9300_handler):
|
||||
"""测试矩阵信息创建"""
|
||||
backend = prcxi_9300_handler._unilabos_backend
|
||||
backend.create_protocol("Test Protocol")
|
||||
|
||||
# 模拟运行协议时的矩阵信息创建
|
||||
run_time = 1234567890
|
||||
matrix_info = MatrixInfo(
|
||||
MatrixId=f"{int(run_time)}",
|
||||
MatrixName=f"protocol_{run_time}",
|
||||
MatrixCount=len(backend.tablets_info),
|
||||
WorkTablets=backend.tablets_info,
|
||||
)
|
||||
|
||||
assert matrix_info["MatrixId"] == str(int(run_time))
|
||||
assert matrix_info["MatrixName"] == f"protocol_{run_time}"
|
||||
assert "WorkTablets" in matrix_info
|
||||
|
||||
|
||||
class TestPRCXIContainerOperations:
|
||||
"""测试 PRCXI 容器操作功能"""
|
||||
|
||||
def test_container_serialization(self, tip_rack_300ul):
|
||||
"""测试容器序列化"""
|
||||
serialized = tip_rack_300ul.serialize_state()
|
||||
assert "Material" in serialized
|
||||
assert serialized["Material"]["Name"] == "300μL Tip头"
|
||||
|
||||
def test_container_deserialization(self, tip_rack_300ul):
|
||||
"""测试容器反序列化"""
|
||||
# 序列化
|
||||
serialized = tip_rack_300ul.serialize_state()
|
||||
|
||||
# 创建新容器并反序列化
|
||||
new_tip_rack = PRCXI9300Container(
|
||||
name="new_tip_rack",
|
||||
size_x=50,
|
||||
size_y=50,
|
||||
size_z=10,
|
||||
category="tip_rack",
|
||||
ordering=collections.OrderedDict()
|
||||
)
|
||||
new_tip_rack.load_state(serialized)
|
||||
|
||||
assert new_tip_rack._unilabos_state["Material"]["Name"] == "300μL Tip头"
|
||||
|
||||
def test_trash_container_creation(self, prcxi_materials):
|
||||
"""测试垃圾桶容器创建"""
|
||||
trash = PRCXI9300Trash(name="trash", size_x=50, size_y=50, size_z=10, category="trash")
|
||||
trash.load_state({
|
||||
"Material": {
|
||||
"uuid": prcxi_materials["废弃槽"]["uuid"]
|
||||
}
|
||||
})
|
||||
|
||||
assert trash.name == "trash"
|
||||
assert trash._unilabos_state["Material"]["uuid"] == prcxi_materials["废弃槽"]["uuid"]
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# 运行测试
|
||||
pytest.main([__file__, "-v"])
|
||||
@@ -1,15 +0,0 @@
|
||||
# Liquid handling 集成测试
|
||||
|
||||
`test_transfer_liquid.py` 现在会调用 PRCXI 的 RViz 仿真 backend,运行前请确保:
|
||||
|
||||
1. 已安装包含 `pylabrobot`、`rclpy` 的运行环境;
|
||||
2. 启动 ROS 依赖(`rviz` 可选,但是 `rviz_backend` 会创建 ROS 节点);
|
||||
3. 在 shell 中设置 `UNILAB_SIM_TEST=1`,否则 pytest 会自动跳过这些慢速用例:
|
||||
|
||||
```bash
|
||||
export UNILAB_SIM_TEST=1
|
||||
pytest tests/devices/liquid_handling/test_transfer_liquid.py -m slow
|
||||
```
|
||||
|
||||
如果只需验证逻辑层(不依赖仿真),可以直接运行 `tests/devices/liquid_handling/unit_test.py`,该文件使用 Fake backend,适合作为 CI 的快速测试。***
|
||||
|
||||
@@ -1,547 +0,0 @@
|
||||
import asyncio
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Iterable, List, Optional, Sequence, Tuple
|
||||
|
||||
import pytest
|
||||
|
||||
from unilabos.devices.liquid_handling.liquid_handler_abstract import LiquidHandlerAbstract
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DummyContainer:
|
||||
name: str
|
||||
|
||||
def __repr__(self) -> str: # pragma: no cover
|
||||
return f"DummyContainer({self.name})"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DummyTipSpot:
|
||||
name: str
|
||||
|
||||
def __repr__(self) -> str: # pragma: no cover
|
||||
return f"DummyTipSpot({self.name})"
|
||||
|
||||
|
||||
def make_tip_iter(n: int = 256) -> Iterable[List[DummyTipSpot]]:
|
||||
"""Yield lists so code can safely call `tip.extend(next(self.current_tip))`."""
|
||||
for i in range(n):
|
||||
yield [DummyTipSpot(f"tip_{i}")]
|
||||
|
||||
|
||||
class FakeLiquidHandler(LiquidHandlerAbstract):
|
||||
"""不初始化真实 backend/deck;仅用来记录 transfer_liquid 内部调用序列。"""
|
||||
|
||||
def __init__(self, channel_num: int = 8):
|
||||
# 不调用 super().__init__,避免真实硬件/后端依赖
|
||||
self.channel_num = channel_num
|
||||
self.support_touch_tip = True
|
||||
self.current_tip = iter(make_tip_iter())
|
||||
self.calls: List[Tuple[str, Any]] = []
|
||||
|
||||
async def pick_up_tips(self, tip_spots, use_channels=None, offsets=None, **backend_kwargs):
|
||||
self.calls.append(("pick_up_tips", {"tips": list(tip_spots), "use_channels": use_channels}))
|
||||
|
||||
async def aspirate(
|
||||
self,
|
||||
resources: Sequence[Any],
|
||||
vols: List[float],
|
||||
use_channels: Optional[List[int]] = None,
|
||||
flow_rates: Optional[List[Optional[float]]] = None,
|
||||
offsets: Any = None,
|
||||
liquid_height: Any = None,
|
||||
blow_out_air_volume: Any = None,
|
||||
spread: str = "wide",
|
||||
**backend_kwargs,
|
||||
):
|
||||
self.calls.append(
|
||||
(
|
||||
"aspirate",
|
||||
{
|
||||
"resources": list(resources),
|
||||
"vols": list(vols),
|
||||
"use_channels": list(use_channels) if use_channels is not None else None,
|
||||
"flow_rates": list(flow_rates) if flow_rates is not None else None,
|
||||
"offsets": list(offsets) if offsets is not None else None,
|
||||
"liquid_height": list(liquid_height) if liquid_height is not None else None,
|
||||
"blow_out_air_volume": list(blow_out_air_volume) if blow_out_air_volume is not None else None,
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
async def dispense(
|
||||
self,
|
||||
resources: Sequence[Any],
|
||||
vols: List[float],
|
||||
use_channels: Optional[List[int]] = None,
|
||||
flow_rates: Optional[List[Optional[float]]] = None,
|
||||
offsets: Any = None,
|
||||
liquid_height: Any = None,
|
||||
blow_out_air_volume: Any = None,
|
||||
spread: str = "wide",
|
||||
**backend_kwargs,
|
||||
):
|
||||
self.calls.append(
|
||||
(
|
||||
"dispense",
|
||||
{
|
||||
"resources": list(resources),
|
||||
"vols": list(vols),
|
||||
"use_channels": list(use_channels) if use_channels is not None else None,
|
||||
"flow_rates": list(flow_rates) if flow_rates is not None else None,
|
||||
"offsets": list(offsets) if offsets is not None else None,
|
||||
"liquid_height": list(liquid_height) if liquid_height is not None else None,
|
||||
"blow_out_air_volume": list(blow_out_air_volume) if blow_out_air_volume is not None else None,
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
async def discard_tips(self, use_channels=None, *args, **kwargs):
|
||||
# 有的分支是 discard_tips(use_channels=[0]),有的分支是 discard_tips([0..7])(位置参数)
|
||||
self.calls.append(("discard_tips", {"use_channels": list(use_channels) if use_channels is not None else None}))
|
||||
|
||||
async def custom_delay(self, seconds=0, msg=None):
|
||||
self.calls.append(("custom_delay", {"seconds": seconds, "msg": msg}))
|
||||
|
||||
async def touch_tip(self, targets):
|
||||
# 原实现会访问 targets.get_size_x() 等;测试里只记录调用
|
||||
self.calls.append(("touch_tip", {"targets": targets}))
|
||||
|
||||
def run(coro):
|
||||
return asyncio.run(coro)
|
||||
|
||||
|
||||
def test_one_to_one_single_channel_basic_calls():
|
||||
lh = FakeLiquidHandler(channel_num=1)
|
||||
lh.current_tip = iter(make_tip_iter(64))
|
||||
|
||||
sources = [DummyContainer(f"S{i}") for i in range(3)]
|
||||
targets = [DummyContainer(f"T{i}") for i in range(3)]
|
||||
|
||||
run(
|
||||
lh.transfer_liquid(
|
||||
sources=sources,
|
||||
targets=targets,
|
||||
tip_racks=[],
|
||||
use_channels=[0],
|
||||
asp_vols=[1, 2, 3],
|
||||
dis_vols=[4, 5, 6],
|
||||
mix_times=None, # 应该仍能执行(不 mix)
|
||||
)
|
||||
)
|
||||
|
||||
assert [c[0] for c in lh.calls].count("pick_up_tips") == 3
|
||||
assert [c[0] for c in lh.calls].count("aspirate") == 3
|
||||
assert [c[0] for c in lh.calls].count("dispense") == 3
|
||||
assert [c[0] for c in lh.calls].count("discard_tips") == 3
|
||||
|
||||
# 每次 aspirate/dispense 都是单孔列表
|
||||
aspirates = [payload for name, payload in lh.calls if name == "aspirate"]
|
||||
assert aspirates[0]["resources"] == [sources[0]]
|
||||
assert aspirates[0]["vols"] == [1.0]
|
||||
|
||||
dispenses = [payload for name, payload in lh.calls if name == "dispense"]
|
||||
assert dispenses[2]["resources"] == [targets[2]]
|
||||
assert dispenses[2]["vols"] == [6.0]
|
||||
|
||||
|
||||
def test_one_to_one_single_channel_before_stage_mixes_prior_to_aspirate():
|
||||
lh = FakeLiquidHandler(channel_num=1)
|
||||
lh.current_tip = iter(make_tip_iter(16))
|
||||
|
||||
source = DummyContainer("S0")
|
||||
target = DummyContainer("T0")
|
||||
|
||||
run(
|
||||
lh.transfer_liquid(
|
||||
sources=[source],
|
||||
targets=[target],
|
||||
tip_racks=[],
|
||||
use_channels=[0],
|
||||
asp_vols=[5],
|
||||
dis_vols=[5],
|
||||
mix_stage="before",
|
||||
mix_times=1,
|
||||
mix_vol=3,
|
||||
)
|
||||
)
|
||||
|
||||
aspirate_calls = [(idx, payload) for idx, (name, payload) in enumerate(lh.calls) if name == "aspirate"]
|
||||
assert len(aspirate_calls) >= 2
|
||||
mix_idx, mix_payload = aspirate_calls[0]
|
||||
assert mix_payload["resources"] == [target]
|
||||
assert mix_payload["vols"] == [3]
|
||||
transfer_idx, transfer_payload = aspirate_calls[1]
|
||||
assert transfer_payload["resources"] == [source]
|
||||
assert mix_idx < transfer_idx
|
||||
|
||||
|
||||
def test_one_to_one_eight_channel_groups_by_8():
|
||||
lh = FakeLiquidHandler(channel_num=8)
|
||||
lh.current_tip = iter(make_tip_iter(256))
|
||||
|
||||
sources = [DummyContainer(f"S{i}") for i in range(16)]
|
||||
targets = [DummyContainer(f"T{i}") for i in range(16)]
|
||||
asp_vols = list(range(1, 17))
|
||||
dis_vols = list(range(101, 117))
|
||||
|
||||
run(
|
||||
lh.transfer_liquid(
|
||||
sources=sources,
|
||||
targets=targets,
|
||||
tip_racks=[],
|
||||
use_channels=list(range(8)),
|
||||
asp_vols=asp_vols,
|
||||
dis_vols=dis_vols,
|
||||
mix_times=0, # 触发逻辑但不 mix
|
||||
)
|
||||
)
|
||||
|
||||
# 16 个任务 -> 2 组,每组 8 通道一起做
|
||||
assert [c[0] for c in lh.calls].count("pick_up_tips") == 2
|
||||
aspirates = [payload for name, payload in lh.calls if name == "aspirate"]
|
||||
dispenses = [payload for name, payload in lh.calls if name == "dispense"]
|
||||
assert len(aspirates) == 2
|
||||
assert len(dispenses) == 2
|
||||
|
||||
assert aspirates[0]["resources"] == sources[0:8]
|
||||
assert aspirates[0]["vols"] == [float(v) for v in asp_vols[0:8]]
|
||||
assert dispenses[1]["resources"] == targets[8:16]
|
||||
assert dispenses[1]["vols"] == [float(v) for v in dis_vols[8:16]]
|
||||
|
||||
|
||||
def test_one_to_one_eight_channel_requires_multiple_of_8_targets():
|
||||
lh = FakeLiquidHandler(channel_num=8)
|
||||
lh.current_tip = iter(make_tip_iter(64))
|
||||
|
||||
sources = [DummyContainer(f"S{i}") for i in range(9)]
|
||||
targets = [DummyContainer(f"T{i}") for i in range(9)]
|
||||
|
||||
with pytest.raises(ValueError, match="multiple of 8"):
|
||||
run(
|
||||
lh.transfer_liquid(
|
||||
sources=sources,
|
||||
targets=targets,
|
||||
tip_racks=[],
|
||||
use_channels=list(range(8)),
|
||||
asp_vols=[1] * 9,
|
||||
dis_vols=[1] * 9,
|
||||
mix_times=0,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def test_one_to_one_eight_channel_parameter_lists_are_chunked_per_8():
|
||||
lh = FakeLiquidHandler(channel_num=8)
|
||||
lh.current_tip = iter(make_tip_iter(512))
|
||||
|
||||
sources = [DummyContainer(f"S{i}") for i in range(16)]
|
||||
targets = [DummyContainer(f"T{i}") for i in range(16)]
|
||||
asp_vols = [i + 1 for i in range(16)]
|
||||
dis_vols = [200 + i for i in range(16)]
|
||||
asp_flow_rates = [0.1 * (i + 1) for i in range(16)]
|
||||
dis_flow_rates = [0.2 * (i + 1) for i in range(16)]
|
||||
offsets = [f"offset_{i}" for i in range(16)]
|
||||
liquid_heights = [i * 0.5 for i in range(16)]
|
||||
blow_out_air_volume = [i + 0.05 for i in range(16)]
|
||||
|
||||
run(
|
||||
lh.transfer_liquid(
|
||||
sources=sources,
|
||||
targets=targets,
|
||||
tip_racks=[],
|
||||
use_channels=list(range(8)),
|
||||
asp_vols=asp_vols,
|
||||
dis_vols=dis_vols,
|
||||
asp_flow_rates=asp_flow_rates,
|
||||
dis_flow_rates=dis_flow_rates,
|
||||
offsets=offsets,
|
||||
liquid_height=liquid_heights,
|
||||
blow_out_air_volume=blow_out_air_volume,
|
||||
mix_times=0,
|
||||
)
|
||||
)
|
||||
|
||||
aspirates = [payload for name, payload in lh.calls if name == "aspirate"]
|
||||
dispenses = [payload for name, payload in lh.calls if name == "dispense"]
|
||||
assert len(aspirates) == len(dispenses) == 2
|
||||
|
||||
for batch_idx in range(2):
|
||||
start = batch_idx * 8
|
||||
end = start + 8
|
||||
asp_call = aspirates[batch_idx]
|
||||
dis_call = dispenses[batch_idx]
|
||||
assert asp_call["resources"] == sources[start:end]
|
||||
assert asp_call["flow_rates"] == asp_flow_rates[start:end]
|
||||
assert asp_call["offsets"] == offsets[start:end]
|
||||
assert asp_call["liquid_height"] == liquid_heights[start:end]
|
||||
assert asp_call["blow_out_air_volume"] == blow_out_air_volume[start:end]
|
||||
assert dis_call["flow_rates"] == dis_flow_rates[start:end]
|
||||
assert dis_call["offsets"] == offsets[start:end]
|
||||
assert dis_call["liquid_height"] == liquid_heights[start:end]
|
||||
assert dis_call["blow_out_air_volume"] == blow_out_air_volume[start:end]
|
||||
|
||||
|
||||
def test_one_to_one_eight_channel_handles_32_tasks_four_batches():
|
||||
lh = FakeLiquidHandler(channel_num=8)
|
||||
lh.current_tip = iter(make_tip_iter(1024))
|
||||
|
||||
sources = [DummyContainer(f"S{i}") for i in range(32)]
|
||||
targets = [DummyContainer(f"T{i}") for i in range(32)]
|
||||
asp_vols = [i + 1 for i in range(32)]
|
||||
dis_vols = [300 + i for i in range(32)]
|
||||
|
||||
run(
|
||||
lh.transfer_liquid(
|
||||
sources=sources,
|
||||
targets=targets,
|
||||
tip_racks=[],
|
||||
use_channels=list(range(8)),
|
||||
asp_vols=asp_vols,
|
||||
dis_vols=dis_vols,
|
||||
mix_times=0,
|
||||
)
|
||||
)
|
||||
|
||||
pick_calls = [name for name, _ in lh.calls if name == "pick_up_tips"]
|
||||
aspirates = [payload for name, payload in lh.calls if name == "aspirate"]
|
||||
dispenses = [payload for name, payload in lh.calls if name == "dispense"]
|
||||
assert len(pick_calls) == 4
|
||||
assert len(aspirates) == len(dispenses) == 4
|
||||
assert aspirates[0]["resources"] == sources[0:8]
|
||||
assert aspirates[-1]["resources"] == sources[24:32]
|
||||
assert dispenses[0]["resources"] == targets[0:8]
|
||||
assert dispenses[-1]["resources"] == targets[24:32]
|
||||
|
||||
|
||||
def test_one_to_many_single_channel_aspirates_total_when_asp_vol_too_small():
|
||||
lh = FakeLiquidHandler(channel_num=1)
|
||||
lh.current_tip = iter(make_tip_iter(64))
|
||||
|
||||
source = DummyContainer("SRC")
|
||||
targets = [DummyContainer(f"T{i}") for i in range(3)]
|
||||
dis_vols = [10, 20, 30] # sum=60
|
||||
|
||||
run(
|
||||
lh.transfer_liquid(
|
||||
sources=[source],
|
||||
targets=targets,
|
||||
tip_racks=[],
|
||||
use_channels=[0],
|
||||
asp_vols=10, # 小于 sum(dis_vols) -> 应吸 60
|
||||
dis_vols=dis_vols,
|
||||
mix_times=0,
|
||||
)
|
||||
)
|
||||
|
||||
aspirates = [payload for name, payload in lh.calls if name == "aspirate"]
|
||||
assert len(aspirates) == 1
|
||||
assert aspirates[0]["resources"] == [source]
|
||||
assert aspirates[0]["vols"] == [60.0]
|
||||
assert aspirates[0]["use_channels"] == [0]
|
||||
dispenses = [payload for name, payload in lh.calls if name == "dispense"]
|
||||
assert [d["vols"][0] for d in dispenses] == [10.0, 20.0, 30.0]
|
||||
|
||||
|
||||
def test_one_to_many_eight_channel_basic():
|
||||
lh = FakeLiquidHandler(channel_num=8)
|
||||
lh.current_tip = iter(make_tip_iter(128))
|
||||
|
||||
source = DummyContainer("SRC")
|
||||
targets = [DummyContainer(f"T{i}") for i in range(8)]
|
||||
dis_vols = [i + 1 for i in range(8)]
|
||||
|
||||
run(
|
||||
lh.transfer_liquid(
|
||||
sources=[source],
|
||||
targets=targets,
|
||||
tip_racks=[],
|
||||
use_channels=list(range(8)),
|
||||
asp_vols=999, # one-to-many 8ch 会按 dis_vols 吸(每通道各自)
|
||||
dis_vols=dis_vols,
|
||||
mix_times=0,
|
||||
)
|
||||
)
|
||||
|
||||
aspirates = [payload for name, payload in lh.calls if name == "aspirate"]
|
||||
assert aspirates[0]["resources"] == [source] * 8
|
||||
assert aspirates[0]["vols"] == [float(v) for v in dis_vols]
|
||||
dispenses = [payload for name, payload in lh.calls if name == "dispense"]
|
||||
assert dispenses[0]["resources"] == targets
|
||||
assert dispenses[0]["vols"] == [float(v) for v in dis_vols]
|
||||
|
||||
|
||||
def test_many_to_one_single_channel_standard_dispense_equals_asp_by_default():
|
||||
lh = FakeLiquidHandler(channel_num=1)
|
||||
lh.current_tip = iter(make_tip_iter(128))
|
||||
|
||||
sources = [DummyContainer(f"S{i}") for i in range(3)]
|
||||
target = DummyContainer("T")
|
||||
asp_vols = [5, 6, 7]
|
||||
|
||||
run(
|
||||
lh.transfer_liquid(
|
||||
sources=sources,
|
||||
targets=[target],
|
||||
tip_racks=[],
|
||||
use_channels=[0],
|
||||
asp_vols=asp_vols,
|
||||
dis_vols=1, # many-to-one 允许标量;非比例模式下实际每次分液=对应 asp_vol
|
||||
mix_times=0,
|
||||
)
|
||||
)
|
||||
|
||||
dispenses = [payload for name, payload in lh.calls if name == "dispense"]
|
||||
assert [d["vols"][0] for d in dispenses] == [float(v) for v in asp_vols]
|
||||
assert all(d["resources"] == [target] for d in dispenses)
|
||||
|
||||
|
||||
def test_many_to_one_single_channel_before_stage_mixes_target_once():
|
||||
lh = FakeLiquidHandler(channel_num=1)
|
||||
lh.current_tip = iter(make_tip_iter(128))
|
||||
|
||||
sources = [DummyContainer("S0"), DummyContainer("S1")]
|
||||
target = DummyContainer("T")
|
||||
|
||||
run(
|
||||
lh.transfer_liquid(
|
||||
sources=sources,
|
||||
targets=[target],
|
||||
tip_racks=[],
|
||||
use_channels=[0],
|
||||
asp_vols=[5, 6],
|
||||
dis_vols=1,
|
||||
mix_stage="before",
|
||||
mix_times=2,
|
||||
mix_vol=4,
|
||||
)
|
||||
)
|
||||
|
||||
aspirate_calls = [(idx, payload) for idx, (name, payload) in enumerate(lh.calls) if name == "aspirate"]
|
||||
assert len(aspirate_calls) >= 1
|
||||
mix_idx, mix_payload = aspirate_calls[0]
|
||||
assert mix_payload["resources"] == [target]
|
||||
assert mix_payload["vols"] == [4]
|
||||
# 第一個 mix 之後會真正開始吸 source
|
||||
assert any(call["resources"] == [sources[0]] for _, call in aspirate_calls[1:])
|
||||
|
||||
|
||||
def test_many_to_one_single_channel_proportional_mixing_uses_dis_vols_per_source():
|
||||
lh = FakeLiquidHandler(channel_num=1)
|
||||
lh.current_tip = iter(make_tip_iter(128))
|
||||
|
||||
sources = [DummyContainer(f"S{i}") for i in range(3)]
|
||||
target = DummyContainer("T")
|
||||
asp_vols = [5, 6, 7]
|
||||
dis_vols = [1, 2, 3]
|
||||
|
||||
run(
|
||||
lh.transfer_liquid(
|
||||
sources=sources,
|
||||
targets=[target],
|
||||
tip_racks=[],
|
||||
use_channels=[0],
|
||||
asp_vols=asp_vols,
|
||||
dis_vols=dis_vols, # 比例模式
|
||||
mix_times=0,
|
||||
)
|
||||
)
|
||||
|
||||
dispenses = [payload for name, payload in lh.calls if name == "dispense"]
|
||||
assert [d["vols"][0] for d in dispenses] == [float(v) for v in dis_vols]
|
||||
|
||||
|
||||
def test_many_to_one_eight_channel_basic():
|
||||
lh = FakeLiquidHandler(channel_num=8)
|
||||
lh.current_tip = iter(make_tip_iter(256))
|
||||
|
||||
sources = [DummyContainer(f"S{i}") for i in range(8)]
|
||||
target = DummyContainer("T")
|
||||
asp_vols = [10 + i for i in range(8)]
|
||||
|
||||
run(
|
||||
lh.transfer_liquid(
|
||||
sources=sources,
|
||||
targets=[target],
|
||||
tip_racks=[],
|
||||
use_channels=list(range(8)),
|
||||
asp_vols=asp_vols,
|
||||
dis_vols=999, # 非比例模式下每通道分液=对应 asp_vol
|
||||
mix_times=0,
|
||||
)
|
||||
)
|
||||
|
||||
aspirates = [payload for name, payload in lh.calls if name == "aspirate"]
|
||||
dispenses = [payload for name, payload in lh.calls if name == "dispense"]
|
||||
assert aspirates[0]["resources"] == sources
|
||||
assert aspirates[0]["vols"] == [float(v) for v in asp_vols]
|
||||
assert dispenses[0]["resources"] == [target] * 8
|
||||
assert dispenses[0]["vols"] == [float(v) for v in asp_vols]
|
||||
|
||||
|
||||
def test_transfer_liquid_mode_detection_unsupported_shape_raises():
|
||||
lh = FakeLiquidHandler(channel_num=8)
|
||||
lh.current_tip = iter(make_tip_iter(64))
|
||||
|
||||
sources = [DummyContainer("S0"), DummyContainer("S1")]
|
||||
targets = [DummyContainer("T0"), DummyContainer("T1"), DummyContainer("T2")]
|
||||
|
||||
with pytest.raises(ValueError, match="Unsupported transfer mode"):
|
||||
run(
|
||||
lh.transfer_liquid(
|
||||
sources=sources,
|
||||
targets=targets,
|
||||
tip_racks=[],
|
||||
use_channels=[0],
|
||||
asp_vols=[1, 1],
|
||||
dis_vols=[1, 1, 1],
|
||||
mix_times=0,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def test_mix_single_target_produces_matching_cycles():
|
||||
lh = FakeLiquidHandler(channel_num=1)
|
||||
target = DummyContainer("T_mix")
|
||||
|
||||
run(lh.mix(targets=[target], mix_time=2, mix_vol=5))
|
||||
|
||||
aspirates = [payload for name, payload in lh.calls if name == "aspirate"]
|
||||
dispenses = [payload for name, payload in lh.calls if name == "dispense"]
|
||||
assert len(aspirates) == len(dispenses) == 2
|
||||
assert all(call["resources"] == [target] for call in aspirates)
|
||||
assert all(call["vols"] == [5] for call in aspirates)
|
||||
assert all(call["resources"] == [target] for call in dispenses)
|
||||
assert all(call["vols"] == [5] for call in dispenses)
|
||||
|
||||
|
||||
def test_mix_multiple_targets_supports_per_target_offsets():
|
||||
lh = FakeLiquidHandler(channel_num=1)
|
||||
targets = [DummyContainer("T0"), DummyContainer("T1")]
|
||||
offsets = ["left", "right"]
|
||||
heights = [0.1, 0.2]
|
||||
rates = [0.5, 1.0]
|
||||
|
||||
run(
|
||||
lh.mix(
|
||||
targets=targets,
|
||||
mix_time=1,
|
||||
mix_vol=3,
|
||||
offsets=offsets,
|
||||
height_to_bottom=heights,
|
||||
mix_rate=rates,
|
||||
)
|
||||
)
|
||||
|
||||
aspirates = [payload for name, payload in lh.calls if name == "aspirate"]
|
||||
assert len(aspirates) == 2
|
||||
assert aspirates[0]["resources"] == [targets[0]]
|
||||
assert aspirates[0]["offsets"] == [offsets[0]]
|
||||
assert aspirates[0]["liquid_height"] == [heights[0]]
|
||||
assert aspirates[0]["flow_rates"] == [rates[0]]
|
||||
assert aspirates[1]["resources"] == [targets[1]]
|
||||
assert aspirates[1]["offsets"] == [offsets[1]]
|
||||
assert aspirates[1]["liquid_height"] == [heights[1]]
|
||||
assert aspirates[1]["flow_rates"] == [rates[1]]
|
||||
|
||||
|
||||
@@ -1,213 +0,0 @@
|
||||
{
|
||||
"workflow": [
|
||||
{
|
||||
"action": "transfer_liquid",
|
||||
"action_args": {
|
||||
"sources": "cell_lines",
|
||||
"targets": "Liquid_1",
|
||||
"asp_vol": 100.0,
|
||||
"dis_vol": 74.75,
|
||||
"asp_flow_rate": 94.0,
|
||||
"dis_flow_rate": 95.5
|
||||
}
|
||||
},
|
||||
{
|
||||
"action": "transfer_liquid",
|
||||
"action_args": {
|
||||
"sources": "cell_lines",
|
||||
"targets": "Liquid_2",
|
||||
"asp_vol": 100.0,
|
||||
"dis_vol": 74.75,
|
||||
"asp_flow_rate": 94.0,
|
||||
"dis_flow_rate": 95.5
|
||||
}
|
||||
},
|
||||
{
|
||||
"action": "transfer_liquid",
|
||||
"action_args": {
|
||||
"sources": "cell_lines",
|
||||
"targets": "Liquid_3",
|
||||
"asp_vol": 100.0,
|
||||
"dis_vol": 74.75,
|
||||
"asp_flow_rate": 94.0,
|
||||
"dis_flow_rate": 95.5
|
||||
}
|
||||
},
|
||||
{
|
||||
"action": "transfer_liquid",
|
||||
"action_args": {
|
||||
"sources": "cell_lines_2",
|
||||
"targets": "Liquid_4",
|
||||
"asp_vol": 100.0,
|
||||
"dis_vol": 74.75,
|
||||
"asp_flow_rate": 94.0,
|
||||
"dis_flow_rate": 95.5
|
||||
}
|
||||
},
|
||||
{
|
||||
"action": "transfer_liquid",
|
||||
"action_args": {
|
||||
"sources": "cell_lines_2",
|
||||
"targets": "Liquid_5",
|
||||
"asp_vol": 100.0,
|
||||
"dis_vol": 74.75,
|
||||
"asp_flow_rate": 94.0,
|
||||
"dis_flow_rate": 95.5
|
||||
}
|
||||
},
|
||||
{
|
||||
"action": "transfer_liquid",
|
||||
"action_args": {
|
||||
"sources": "cell_lines_2",
|
||||
"targets": "Liquid_6",
|
||||
"asp_vol": 100.0,
|
||||
"dis_vol": 74.75,
|
||||
"asp_flow_rate": 94.0,
|
||||
"dis_flow_rate": 95.5
|
||||
}
|
||||
},
|
||||
{
|
||||
"action": "transfer_liquid",
|
||||
"action_args": {
|
||||
"sources": "cell_lines_3",
|
||||
"targets": "dest_set",
|
||||
"asp_vol": 100.0,
|
||||
"dis_vol": 74.75,
|
||||
"asp_flow_rate": 94.0,
|
||||
"dis_flow_rate": 95.5
|
||||
}
|
||||
},
|
||||
{
|
||||
"action": "transfer_liquid",
|
||||
"action_args": {
|
||||
"sources": "cell_lines_3",
|
||||
"targets": "dest_set_2",
|
||||
"asp_vol": 100.0,
|
||||
"dis_vol": 74.75,
|
||||
"asp_flow_rate": 94.0,
|
||||
"dis_flow_rate": 95.5
|
||||
}
|
||||
},
|
||||
{
|
||||
"action": "transfer_liquid",
|
||||
"action_args": {
|
||||
"sources": "cell_lines_3",
|
||||
"targets": "dest_set_3",
|
||||
"asp_vol": 100.0,
|
||||
"dis_vol": 74.75,
|
||||
"asp_flow_rate": 94.0,
|
||||
"dis_flow_rate": 95.5
|
||||
}
|
||||
}
|
||||
],
|
||||
"reagent": {
|
||||
"Liquid_1": {
|
||||
"slot": 1,
|
||||
"well": [
|
||||
"A4",
|
||||
"A7",
|
||||
"A10"
|
||||
],
|
||||
"labware": "rep 1"
|
||||
},
|
||||
"Liquid_4": {
|
||||
"slot": 1,
|
||||
"well": [
|
||||
"A4",
|
||||
"A7",
|
||||
"A10"
|
||||
],
|
||||
"labware": "rep 1"
|
||||
},
|
||||
"dest_set": {
|
||||
"slot": 1,
|
||||
"well": [
|
||||
"A4",
|
||||
"A7",
|
||||
"A10"
|
||||
],
|
||||
"labware": "rep 1"
|
||||
},
|
||||
"Liquid_2": {
|
||||
"slot": 2,
|
||||
"well": [
|
||||
"A3",
|
||||
"A5",
|
||||
"A8"
|
||||
],
|
||||
"labware": "rep 2"
|
||||
},
|
||||
"Liquid_5": {
|
||||
"slot": 2,
|
||||
"well": [
|
||||
"A3",
|
||||
"A5",
|
||||
"A8"
|
||||
],
|
||||
"labware": "rep 2"
|
||||
},
|
||||
"dest_set_2": {
|
||||
"slot": 2,
|
||||
"well": [
|
||||
"A3",
|
||||
"A5",
|
||||
"A8"
|
||||
],
|
||||
"labware": "rep 2"
|
||||
},
|
||||
"Liquid_3": {
|
||||
"slot": 3,
|
||||
"well": [
|
||||
"A4",
|
||||
"A6",
|
||||
"A10"
|
||||
],
|
||||
"labware": "rep 3"
|
||||
},
|
||||
"Liquid_6": {
|
||||
"slot": 3,
|
||||
"well": [
|
||||
"A4",
|
||||
"A6",
|
||||
"A10"
|
||||
],
|
||||
"labware": "rep 3"
|
||||
},
|
||||
"dest_set_3": {
|
||||
"slot": 3,
|
||||
"well": [
|
||||
"A4",
|
||||
"A6",
|
||||
"A10"
|
||||
],
|
||||
"labware": "rep 3"
|
||||
},
|
||||
"cell_lines": {
|
||||
"slot": 4,
|
||||
"well": [
|
||||
"A1",
|
||||
"A3",
|
||||
"A5"
|
||||
],
|
||||
"labware": "DRUG + YOYO-MEDIA"
|
||||
},
|
||||
"cell_lines_2": {
|
||||
"slot": 4,
|
||||
"well": [
|
||||
"A1",
|
||||
"A3",
|
||||
"A5"
|
||||
],
|
||||
"labware": "DRUG + YOYO-MEDIA"
|
||||
},
|
||||
"cell_lines_3": {
|
||||
"slot": 4,
|
||||
"well": [
|
||||
"A1",
|
||||
"A3",
|
||||
"A5"
|
||||
],
|
||||
"labware": "DRUG + YOYO-MEDIA"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1 +1 @@
|
||||
__version__ = "0.10.19"
|
||||
__version__ = "0.10.15"
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
import argparse
|
||||
import asyncio
|
||||
import os
|
||||
import platform
|
||||
import shutil
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from typing import Dict, Any, List
|
||||
|
||||
import networkx as nx
|
||||
import yaml
|
||||
|
||||
@@ -18,92 +17,9 @@ unilabos_dir = os.path.dirname(os.path.dirname(current_dir))
|
||||
if unilabos_dir not in sys.path:
|
||||
sys.path.append(unilabos_dir)
|
||||
|
||||
from unilabos.app.utils import cleanup_for_restart
|
||||
from unilabos.utils.banner_print import print_status, print_unilab_banner
|
||||
from unilabos.config.config import load_config, BasicConfig, HTTPConfig
|
||||
|
||||
# Global restart flags (used by ws_client and web/server)
|
||||
_restart_requested: bool = False
|
||||
_restart_reason: str = ""
|
||||
|
||||
RESTART_EXIT_CODE = 42
|
||||
|
||||
|
||||
def _build_child_argv():
|
||||
"""Build sys.argv for child process, stripping supervisor-only arguments."""
|
||||
result = []
|
||||
skip_next = False
|
||||
for arg in sys.argv:
|
||||
if skip_next:
|
||||
skip_next = False
|
||||
continue
|
||||
if arg in ("--restart_mode", "--restart-mode"):
|
||||
continue
|
||||
if arg in ("--auto_restart_count", "--auto-restart-count"):
|
||||
skip_next = True
|
||||
continue
|
||||
if arg.startswith("--auto_restart_count=") or arg.startswith("--auto-restart-count="):
|
||||
continue
|
||||
result.append(arg)
|
||||
return result
|
||||
|
||||
|
||||
def _run_as_supervisor(max_restarts: int):
|
||||
"""
|
||||
Supervisor process that spawns and monitors child processes.
|
||||
|
||||
Similar to Uvicorn's --reload: the supervisor itself does no heavy work,
|
||||
it only launches the real process as a child and restarts it when the child
|
||||
exits with RESTART_EXIT_CODE.
|
||||
"""
|
||||
child_argv = [sys.executable] + _build_child_argv()
|
||||
restart_count = 0
|
||||
|
||||
print_status(
|
||||
f"[Supervisor] Restart mode enabled (max restarts: {max_restarts}), "
|
||||
f"child command: {' '.join(child_argv)}",
|
||||
"info",
|
||||
)
|
||||
|
||||
while True:
|
||||
print_status(
|
||||
f"[Supervisor] Launching process (restart {restart_count}/{max_restarts})...",
|
||||
"info",
|
||||
)
|
||||
|
||||
try:
|
||||
process = subprocess.Popen(child_argv)
|
||||
exit_code = process.wait()
|
||||
except KeyboardInterrupt:
|
||||
print_status("[Supervisor] Interrupted, terminating child process...", "info")
|
||||
process.terminate()
|
||||
try:
|
||||
process.wait(timeout=10)
|
||||
except subprocess.TimeoutExpired:
|
||||
process.kill()
|
||||
process.wait()
|
||||
sys.exit(1)
|
||||
|
||||
if exit_code == RESTART_EXIT_CODE:
|
||||
restart_count += 1
|
||||
if restart_count > max_restarts:
|
||||
print_status(
|
||||
f"[Supervisor] Maximum restart count ({max_restarts}) reached, exiting",
|
||||
"warning",
|
||||
)
|
||||
sys.exit(1)
|
||||
print_status(
|
||||
f"[Supervisor] Child requested restart ({restart_count}/{max_restarts}), restarting in 2s...",
|
||||
"info",
|
||||
)
|
||||
time.sleep(2)
|
||||
else:
|
||||
if exit_code != 0:
|
||||
print_status(f"[Supervisor] Child exited with code {exit_code}", "warning")
|
||||
else:
|
||||
print_status("[Supervisor] Child exited normally", "info")
|
||||
sys.exit(exit_code)
|
||||
|
||||
|
||||
def load_config_from_file(config_path):
|
||||
if config_path is None:
|
||||
@@ -145,13 +61,6 @@ def parse_args():
|
||||
action="append",
|
||||
help="Path to the registry directory",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--devices",
|
||||
type=str,
|
||||
default=None,
|
||||
action="append",
|
||||
help="Path to Python code directory for AST-based device/resource scanning",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--working_dir",
|
||||
type=str,
|
||||
@@ -241,52 +150,11 @@ def parse_args():
|
||||
action="store_true",
|
||||
help="Skip environment dependency check on startup",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--check_mode",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Run in check mode for CI: validates registry imports and ensures no file changes",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--complete_registry",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Complete and rewrite YAML registry files using AST analysis results",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--no_update_feedback",
|
||||
action="store_true",
|
||||
help="Disable sending update feedback to server",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--test_mode",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Test mode: all actions simulate execution and return mock results without running real hardware",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--external_devices_only",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Only load external device packages (--devices), skip built-in unilabos/devices/ scanning and YAML device registry",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--extra_resource",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Load extra lab_ prefixed labware resources (529 auto-generated definitions from lab_resources.py)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--restart_mode",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Enable supervisor mode: automatically restart the process when triggered via WebSocket",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--auto_restart_count",
|
||||
type=int,
|
||||
default=500,
|
||||
help="Maximum number of automatic restarts in restart mode (default: 500)",
|
||||
help="Complete registry information",
|
||||
)
|
||||
# workflow upload subcommand
|
||||
workflow_parser = subparsers.add_parser(
|
||||
@@ -321,12 +189,6 @@ def parse_args():
|
||||
default=False,
|
||||
help="Whether to publish the workflow (default: False)",
|
||||
)
|
||||
workflow_parser.add_argument(
|
||||
"--description",
|
||||
type=str,
|
||||
default="",
|
||||
help="Workflow description, used when publishing the workflow",
|
||||
)
|
||||
return parser
|
||||
|
||||
|
||||
@@ -338,92 +200,53 @@ def main():
|
||||
args = parser.parse_args()
|
||||
args_dict = vars(args)
|
||||
|
||||
# Supervisor mode: spawn child processes and monitor for restart
|
||||
if args_dict.get("restart_mode", False):
|
||||
_run_as_supervisor(args_dict.get("auto_restart_count", 5))
|
||||
return
|
||||
|
||||
# 环境检查 - 检查并自动安装必需的包 (可选)
|
||||
skip_env_check = args_dict.get("skip_env_check", False)
|
||||
check_mode = args_dict.get("check_mode", False)
|
||||
|
||||
if not skip_env_check:
|
||||
from unilabos.utils.environment_check import check_environment, check_device_package_requirements
|
||||
if not args_dict.get("skip_env_check", False):
|
||||
from unilabos.utils.environment_check import check_environment
|
||||
|
||||
if not check_environment(auto_install=True):
|
||||
print_status("环境检查失败,程序退出", "error")
|
||||
os._exit(1)
|
||||
|
||||
# 第一次设备包依赖检查:build_registry 之前,确保 import map 可用
|
||||
devices_dirs_for_req = args_dict.get("devices", None)
|
||||
if devices_dirs_for_req:
|
||||
if not check_device_package_requirements(devices_dirs_for_req):
|
||||
print_status("设备包依赖检查失败,程序退出", "error")
|
||||
os._exit(1)
|
||||
else:
|
||||
print_status("跳过环境依赖检查", "warning")
|
||||
|
||||
# 加载配置文件,优先加载config,然后从env读取
|
||||
config_path = args_dict.get("config")
|
||||
|
||||
# === 解析 working_dir ===
|
||||
# 规则1: working_dir 传入 → 检测 unilabos_data 子目录,已是则不修改
|
||||
# 规则2: 仅 config_path 传入 → 用其父目录作为 working_dir
|
||||
# 规则4: 两者都传入 → 各用各的,但 working_dir 仍做 unilabos_data 子目录检测
|
||||
raw_working_dir = args_dict.get("working_dir")
|
||||
if raw_working_dir:
|
||||
working_dir = os.path.abspath(raw_working_dir)
|
||||
elif config_path and os.path.exists(config_path):
|
||||
working_dir = os.path.dirname(os.path.abspath(config_path))
|
||||
else:
|
||||
if os.getcwd().endswith("unilabos_data"):
|
||||
working_dir = os.path.abspath(os.getcwd())
|
||||
|
||||
# unilabos_data 子目录自动检测
|
||||
if os.path.basename(working_dir) != "unilabos_data":
|
||||
unilabos_data_sub = os.path.join(working_dir, "unilabos_data")
|
||||
if os.path.isdir(unilabos_data_sub):
|
||||
working_dir = unilabos_data_sub
|
||||
elif not raw_working_dir and not (config_path and os.path.exists(config_path)):
|
||||
# 未显式指定路径,默认使用 cwd/unilabos_data
|
||||
else:
|
||||
working_dir = os.path.abspath(os.path.join(os.getcwd(), "unilabos_data"))
|
||||
|
||||
# === 解析 config_path ===
|
||||
if args_dict.get("working_dir"):
|
||||
working_dir = args_dict.get("working_dir", "")
|
||||
if config_path and not os.path.exists(config_path):
|
||||
# config_path 传入但不存在,尝试在 working_dir 中查找
|
||||
candidate = os.path.join(working_dir, "local_config.py")
|
||||
if os.path.exists(candidate):
|
||||
config_path = candidate
|
||||
print_status(f"在工作目录中发现配置文件: {config_path}", "info")
|
||||
else:
|
||||
config_path = os.path.join(working_dir, "local_config.py")
|
||||
if not os.path.exists(config_path):
|
||||
print_status(
|
||||
f"配置文件 {config_path} 不存在,工作目录 {working_dir} 中也未找到 local_config.py,"
|
||||
f"请通过 --config 传入 local_config.py 文件路径",
|
||||
f"当前工作目录 {working_dir} 未找到local_config.py,请通过 --config 传入 local_config.py 文件路径",
|
||||
"error",
|
||||
)
|
||||
os._exit(1)
|
||||
elif not config_path:
|
||||
# 规则3: 未传入 config_path,尝试 working_dir/local_config.py
|
||||
candidate = os.path.join(working_dir, "local_config.py")
|
||||
if os.path.exists(candidate):
|
||||
config_path = candidate
|
||||
print_status(f"发现本地配置文件: {config_path}", "info")
|
||||
else:
|
||||
elif config_path and os.path.exists(config_path):
|
||||
working_dir = os.path.dirname(config_path)
|
||||
elif os.path.exists(working_dir) and os.path.exists(os.path.join(working_dir, "local_config.py")):
|
||||
config_path = os.path.join(working_dir, "local_config.py")
|
||||
elif not config_path and (
|
||||
not os.path.exists(working_dir) or not os.path.exists(os.path.join(working_dir, "local_config.py"))
|
||||
):
|
||||
print_status(f"未指定config路径,可通过 --config 传入 local_config.py 文件路径", "info")
|
||||
print_status(f"您是否为第一次使用?并将当前路径 {working_dir} 作为工作目录? (Y/n)", "info")
|
||||
if check_mode or input() != "n":
|
||||
if input() != "n":
|
||||
os.makedirs(working_dir, exist_ok=True)
|
||||
config_path = os.path.join(working_dir, "local_config.py")
|
||||
shutil.copy(
|
||||
os.path.join(os.path.dirname(os.path.dirname(__file__)), "config", "example_config.py"),
|
||||
config_path,
|
||||
os.path.join(os.path.dirname(os.path.dirname(__file__)), "config", "example_config.py"), config_path
|
||||
)
|
||||
print_status(f"已创建 local_config.py 路径: {config_path}", "info")
|
||||
else:
|
||||
os._exit(1)
|
||||
|
||||
# 加载配置文件 (check_mode 跳过)
|
||||
# 加载配置文件
|
||||
print_status(f"当前工作目录为 {working_dir}", "info")
|
||||
if not check_mode:
|
||||
load_config_from_file(config_path)
|
||||
|
||||
# 根据配置重新设置日志级别
|
||||
@@ -431,9 +254,7 @@ def main():
|
||||
|
||||
if hasattr(BasicConfig, "log_level"):
|
||||
logger.info(f"Log level set to '{BasicConfig.log_level}' from config file.")
|
||||
file_path = configure_logger(loglevel=BasicConfig.log_level, working_dir=working_dir)
|
||||
if file_path is not None:
|
||||
logger.info(f"[LOG_FILE] {file_path}")
|
||||
configure_logger(loglevel=BasicConfig.log_level, working_dir=working_dir)
|
||||
|
||||
if args.addr != parser.get_default("addr"):
|
||||
if args.addr == "test":
|
||||
@@ -476,67 +297,41 @@ def main():
|
||||
BasicConfig.is_host_mode = not args_dict.get("is_slave", False)
|
||||
BasicConfig.slave_no_host = args_dict.get("slave_no_host", False)
|
||||
BasicConfig.upload_registry = args_dict.get("upload_registry", False)
|
||||
BasicConfig.no_update_feedback = args_dict.get("no_update_feedback", False)
|
||||
BasicConfig.test_mode = args_dict.get("test_mode", False)
|
||||
if BasicConfig.test_mode:
|
||||
print_status("启用测试模式:所有动作将模拟执行,不调用真实硬件", "warning")
|
||||
BasicConfig.extra_resource = args_dict.get("extra_resource", False)
|
||||
if BasicConfig.extra_resource:
|
||||
print_status("启用额外资源加载:将加载lab_开头的labware资源定义", "info")
|
||||
BasicConfig.communication_protocol = "websocket"
|
||||
machine_name = platform.node()
|
||||
machine_name = os.popen("hostname").read().strip()
|
||||
machine_name = "".join([c if c.isalnum() or c == "_" else "_" for c in machine_name])
|
||||
BasicConfig.machine_name = machine_name
|
||||
BasicConfig.vis_2d_enable = args_dict["2d_vis"]
|
||||
BasicConfig.check_mode = check_mode
|
||||
|
||||
from unilabos.registry.registry import build_registry
|
||||
|
||||
# 显示启动横幅
|
||||
print_unilab_banner(args_dict)
|
||||
|
||||
# Step 0: AST 分析优先 + YAML 注册表加载
|
||||
# check_mode 和 upload_registry 都会执行实际 import 验证
|
||||
devices_dirs = args_dict.get("devices", None)
|
||||
complete_registry = args_dict.get("complete_registry", False) or check_mode
|
||||
external_only = args_dict.get("external_devices_only", False)
|
||||
lab_registry = build_registry(
|
||||
registry_paths=args_dict["registry_path"],
|
||||
devices_dirs=devices_dirs,
|
||||
upload_registry=BasicConfig.upload_registry,
|
||||
check_mode=check_mode,
|
||||
complete_registry=complete_registry,
|
||||
external_only=external_only,
|
||||
)
|
||||
|
||||
# Check mode: 注册表验证完成后直接退出
|
||||
if check_mode:
|
||||
device_count = len(lab_registry.device_type_registry)
|
||||
resource_count = len(lab_registry.resource_type_registry)
|
||||
print_status(f"Check mode: 注册表验证完成 ({device_count} 设备, {resource_count} 资源),退出", "info")
|
||||
os._exit(0)
|
||||
|
||||
# 以下导入依赖 ROS2 环境,check_mode 已退出不需要
|
||||
from unilabos.resources.graphio import (
|
||||
read_node_link_json,
|
||||
read_graphml,
|
||||
dict_from_graph,
|
||||
modify_to_backend_format,
|
||||
)
|
||||
from unilabos.app.communication import get_communication_client
|
||||
from unilabos.registry.registry import build_registry
|
||||
from unilabos.app.backend import start_backend
|
||||
from unilabos.app.web import http_client
|
||||
from unilabos.app.web import start_server
|
||||
from unilabos.app.register import register_devices_and_resources
|
||||
from unilabos.resources.graphio import modify_to_backend_format
|
||||
from unilabos.resources.resource_tracker import ResourceTreeSet, ResourceDict
|
||||
|
||||
# Step 1: 上传全部注册表到服务端,同步保存到 unilabos_data
|
||||
# 显示启动横幅
|
||||
print_unilab_banner(args_dict)
|
||||
|
||||
# 注册表
|
||||
lab_registry = build_registry(
|
||||
args_dict["registry_path"], args_dict.get("complete_registry", False), BasicConfig.upload_registry
|
||||
)
|
||||
|
||||
if BasicConfig.upload_registry:
|
||||
# 设备注册到服务端 - 需要 ak 和 sk
|
||||
if BasicConfig.ak and BasicConfig.sk:
|
||||
# print_status("开始注册设备到服务端...", "info")
|
||||
print_status("开始注册设备到服务端...", "info")
|
||||
try:
|
||||
register_devices_and_resources(lab_registry)
|
||||
# print_status("设备注册完成", "info")
|
||||
print_status("设备注册完成", "info")
|
||||
except Exception as e:
|
||||
print_status(f"设备注册失败: {e}", "error")
|
||||
else:
|
||||
@@ -621,16 +416,12 @@ def main():
|
||||
continue
|
||||
|
||||
# 如果从远端获取了物料信息,则与本地物料进行同步
|
||||
if file_path is not None and request_startup_json and "nodes" in request_startup_json:
|
||||
if request_startup_json and "nodes" in request_startup_json:
|
||||
print_status("开始同步远端物料到本地...", "info")
|
||||
remote_tree_set = ResourceTreeSet.from_raw_dict_list(request_startup_json["nodes"])
|
||||
resource_tree_set.merge_remote_resources(remote_tree_set)
|
||||
print_status("远端物料同步完成", "info")
|
||||
|
||||
# 第二次设备包依赖检查:云端物料同步后,community 包可能引入新的 requirements
|
||||
# TODO: 当 community device package 功能上线后,在这里调用
|
||||
# install_requirements_txt(community_pkg_path / "requirements.txt", label="community.xxx")
|
||||
|
||||
# 使用 ResourceTreeSet 代替 list
|
||||
args_dict["resources_config"] = resource_tree_set
|
||||
args_dict["devices_config"] = resource_tree_set
|
||||
@@ -706,26 +497,16 @@ def main():
|
||||
time.sleep(1)
|
||||
else:
|
||||
start_backend(**args_dict)
|
||||
restart_requested = start_server(
|
||||
start_server(
|
||||
open_browser=not args_dict["disable_browser"],
|
||||
port=BasicConfig.port,
|
||||
)
|
||||
if restart_requested:
|
||||
print_status("[Main] Restart requested, cleaning up...", "info")
|
||||
cleanup_for_restart()
|
||||
return
|
||||
else:
|
||||
start_backend(**args_dict)
|
||||
|
||||
# 启动服务器(默认支持WebSocket触发重启)
|
||||
restart_requested = start_server(
|
||||
start_server(
|
||||
open_browser=not args_dict["disable_browser"],
|
||||
port=BasicConfig.port,
|
||||
)
|
||||
if restart_requested:
|
||||
print_status("[Main] Restart requested, cleaning up...", "info")
|
||||
cleanup_for_restart()
|
||||
os._exit(RESTART_EXIT_CODE)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -54,7 +54,6 @@ class JobAddReq(BaseModel):
|
||||
action_type: str = Field(
|
||||
examples=["unilabos_msgs.action._str_single_input.StrSingleInput"], description="action type", default=""
|
||||
)
|
||||
sample_material: dict = Field(examples=[{"string": "string"}], description="sample uuid to material uuid")
|
||||
action_args: dict = Field(examples=[{"string": "string"}], description="action arguments", default_factory=dict)
|
||||
task_id: str = Field(examples=["task_id"], description="task uuid (auto-generated if empty)", default="")
|
||||
job_id: str = Field(examples=["job_id"], description="goal uuid (auto-generated if empty)", default="")
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import json
|
||||
import time
|
||||
from typing import Any, Dict, Optional, Tuple
|
||||
from typing import Optional, Tuple, Dict, Any
|
||||
|
||||
from unilabos.utils.log import logger
|
||||
from unilabos.utils.tools import normalize_json as _normalize_device
|
||||
from unilabos.utils.type_check import TypeEncoder
|
||||
|
||||
|
||||
def register_devices_and_resources(lab_registry, gather_only=False) -> Optional[Tuple[Dict[str, Any], Dict[str, Any]]]:
|
||||
@@ -10,63 +11,50 @@ def register_devices_and_resources(lab_registry, gather_only=False) -> Optional[
|
||||
注册设备和资源到服务器(仅支持HTTP)
|
||||
"""
|
||||
|
||||
# 注册资源信息 - 使用HTTP方式
|
||||
from unilabos.app.web.client import http_client
|
||||
|
||||
logger.info("[UniLab Register] 开始注册设备和资源...")
|
||||
|
||||
# 注册设备信息
|
||||
devices_to_register = {}
|
||||
for device_info in lab_registry.obtain_registry_device_info():
|
||||
devices_to_register[device_info["id"]] = _normalize_device(device_info)
|
||||
logger.trace(f"[UniLab Register] 收集设备: {device_info['id']}")
|
||||
devices_to_register[device_info["id"]] = json.loads(
|
||||
json.dumps(device_info, ensure_ascii=False, cls=TypeEncoder)
|
||||
)
|
||||
logger.debug(f"[UniLab Register] 收集设备: {device_info['id']}")
|
||||
|
||||
resources_to_register = {}
|
||||
for resource_info in lab_registry.obtain_registry_resource_info():
|
||||
resources_to_register[resource_info["id"]] = resource_info
|
||||
logger.trace(f"[UniLab Register] 收集资源: {resource_info['id']}")
|
||||
logger.debug(f"[UniLab Register] 收集资源: {resource_info['id']}")
|
||||
|
||||
if gather_only:
|
||||
return devices_to_register, resources_to_register
|
||||
|
||||
# 注册设备
|
||||
if devices_to_register:
|
||||
try:
|
||||
start_time = time.time()
|
||||
response = http_client.resource_registry(
|
||||
{"resources": list(devices_to_register.values())},
|
||||
tag="device_registry",
|
||||
)
|
||||
response = http_client.resource_registry({"resources": list(devices_to_register.values())})
|
||||
cost_time = time.time() - start_time
|
||||
res_data = response.json() if response.status_code == 200 else {}
|
||||
skipped = res_data.get("data", {}).get("skipped", False)
|
||||
if skipped:
|
||||
logger.info(
|
||||
f"[UniLab Register] 设备注册跳过(内容未变化)"
|
||||
f" {len(devices_to_register)} 个 {cost_time:.3f}s"
|
||||
)
|
||||
elif response.status_code in [200, 201]:
|
||||
logger.info(f"[UniLab Register] 成功注册 {len(devices_to_register)} 个设备 {cost_time:.3f}s")
|
||||
if response.status_code in [200, 201]:
|
||||
logger.info(f"[UniLab Register] 成功注册 {len(devices_to_register)} 个设备 {cost_time}ms")
|
||||
else:
|
||||
logger.error(f"[UniLab Register] 设备注册失败: {response.status_code}, {response.text} {cost_time:.3f}s")
|
||||
logger.error(f"[UniLab Register] 设备注册失败: {response.status_code}, {response.text} {cost_time}ms")
|
||||
except Exception as e:
|
||||
logger.error(f"[UniLab Register] 设备注册异常: {e}")
|
||||
|
||||
# 注册资源
|
||||
if resources_to_register:
|
||||
try:
|
||||
start_time = time.time()
|
||||
response = http_client.resource_registry(
|
||||
{"resources": list(resources_to_register.values())},
|
||||
tag="resource_registry",
|
||||
)
|
||||
response = http_client.resource_registry({"resources": list(resources_to_register.values())})
|
||||
cost_time = time.time() - start_time
|
||||
res_data = response.json() if response.status_code == 200 else {}
|
||||
skipped = res_data.get("data", {}).get("skipped", False)
|
||||
if skipped:
|
||||
logger.info(
|
||||
f"[UniLab Register] 资源注册跳过(内容未变化)"
|
||||
f" {len(resources_to_register)} 个 {cost_time:.3f}s"
|
||||
)
|
||||
elif response.status_code in [200, 201]:
|
||||
logger.info(f"[UniLab Register] 成功注册 {len(resources_to_register)} 个资源 {cost_time:.3f}s")
|
||||
if response.status_code in [200, 201]:
|
||||
logger.info(f"[UniLab Register] 成功注册 {len(resources_to_register)} 个资源 {cost_time}ms")
|
||||
else:
|
||||
logger.error(f"[UniLab Register] 资源注册失败: {response.status_code}, {response.text} {cost_time:.3f}s")
|
||||
logger.error(f"[UniLab Register] 资源注册失败: {response.status_code}, {response.text} {cost_time}ms")
|
||||
except Exception as e:
|
||||
logger.error(f"[UniLab Register] 资源注册异常: {e}")
|
||||
|
||||
logger.info("[UniLab Register] 设备和资源注册完成.")
|
||||
|
||||
@@ -1,176 +0,0 @@
|
||||
"""
|
||||
UniLabOS 应用工具函数
|
||||
|
||||
提供清理、重启等工具函数
|
||||
"""
|
||||
|
||||
import glob
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
|
||||
|
||||
def patch_rclpy_dll_windows():
|
||||
"""在 Windows + conda 环境下为 rclpy 打 DLL 加载补丁"""
|
||||
if sys.platform != "win32" or not os.environ.get("CONDA_PREFIX"):
|
||||
return
|
||||
try:
|
||||
import rclpy
|
||||
|
||||
return
|
||||
except ImportError as e:
|
||||
if not str(e).startswith("DLL load failed"):
|
||||
return
|
||||
cp = os.environ["CONDA_PREFIX"]
|
||||
impl = os.path.join(cp, "Lib", "site-packages", "rclpy", "impl", "implementation_singleton.py")
|
||||
pyd = glob.glob(os.path.join(cp, "Lib", "site-packages", "rclpy", "_rclpy_pybind11*.pyd"))
|
||||
if not os.path.exists(impl) or not pyd:
|
||||
return
|
||||
with open(impl, "r", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
lib_bin = os.path.join(cp, "Library", "bin").replace("\\", "/")
|
||||
patch = f'# UniLabOS DLL Patch\nimport os,ctypes\nos.add_dll_directory("{lib_bin}") if hasattr(os,"add_dll_directory") else None\ntry: ctypes.CDLL("{pyd[0].replace(chr(92),"/")}")\nexcept: pass\n# End Patch\n'
|
||||
shutil.copy2(impl, impl + ".bak")
|
||||
with open(impl, "w", encoding="utf-8") as f:
|
||||
f.write(patch + content)
|
||||
|
||||
|
||||
patch_rclpy_dll_windows()
|
||||
|
||||
import gc
|
||||
import threading
|
||||
import time
|
||||
|
||||
from unilabos.utils.banner_print import print_status
|
||||
|
||||
|
||||
def cleanup_for_restart() -> bool:
|
||||
"""
|
||||
Clean up all resources for restart without exiting the process.
|
||||
|
||||
This function prepares the system for re-initialization by:
|
||||
1. Stopping all communication clients
|
||||
2. Destroying ROS nodes
|
||||
3. Resetting singletons
|
||||
4. Waiting for threads to finish
|
||||
|
||||
Returns:
|
||||
bool: True if cleanup was successful, False otherwise
|
||||
"""
|
||||
print_status("[Restart] Starting cleanup for restart...", "info")
|
||||
|
||||
# Step 1: Stop WebSocket communication client
|
||||
print_status("[Restart] Step 1: Stopping WebSocket client...", "info")
|
||||
try:
|
||||
from unilabos.app.communication import get_communication_client
|
||||
|
||||
comm_client = get_communication_client()
|
||||
if comm_client is not None:
|
||||
comm_client.stop()
|
||||
print_status("[Restart] WebSocket client stopped", "info")
|
||||
except Exception as e:
|
||||
print_status(f"[Restart] Error stopping WebSocket: {e}", "warning")
|
||||
|
||||
# Step 2: Get HostNode and cleanup ROS
|
||||
print_status("[Restart] Step 2: Cleaning up ROS nodes...", "info")
|
||||
try:
|
||||
from unilabos.ros.nodes.presets.host_node import HostNode
|
||||
import rclpy
|
||||
from rclpy.timer import Timer
|
||||
|
||||
host_instance = HostNode.get_instance(timeout=5)
|
||||
if host_instance is not None:
|
||||
print_status(f"[Restart] Found HostNode: {host_instance.device_id}", "info")
|
||||
|
||||
# Gracefully shutdown background threads
|
||||
print_status("[Restart] Shutting down background threads...", "info")
|
||||
HostNode.shutdown_background_threads(timeout=5.0)
|
||||
print_status("[Restart] Background threads shutdown complete", "info")
|
||||
|
||||
# Stop discovery timer
|
||||
if hasattr(host_instance, "_discovery_timer") and isinstance(host_instance._discovery_timer, Timer):
|
||||
host_instance._discovery_timer.cancel()
|
||||
print_status("[Restart] Discovery timer cancelled", "info")
|
||||
|
||||
# Destroy device nodes
|
||||
device_count = len(host_instance.devices_instances)
|
||||
print_status(f"[Restart] Destroying {device_count} device instances...", "info")
|
||||
for device_id, device_node in list(host_instance.devices_instances.items()):
|
||||
try:
|
||||
if hasattr(device_node, "ros_node_instance") and device_node.ros_node_instance is not None:
|
||||
device_node.ros_node_instance.destroy_node()
|
||||
print_status(f"[Restart] Device {device_id} destroyed", "info")
|
||||
except Exception as e:
|
||||
print_status(f"[Restart] Error destroying device {device_id}: {e}", "warning")
|
||||
|
||||
# Clear devices instances
|
||||
host_instance.devices_instances.clear()
|
||||
host_instance.devices_names.clear()
|
||||
|
||||
# Destroy host node
|
||||
try:
|
||||
host_instance.destroy_node()
|
||||
print_status("[Restart] HostNode destroyed", "info")
|
||||
except Exception as e:
|
||||
print_status(f"[Restart] Error destroying HostNode: {e}", "warning")
|
||||
|
||||
# Reset HostNode state
|
||||
HostNode.reset_state()
|
||||
print_status("[Restart] HostNode state reset", "info")
|
||||
|
||||
# Shutdown executor first (to stop executor.spin() gracefully)
|
||||
if hasattr(rclpy, "__executor") and rclpy.__executor is not None:
|
||||
try:
|
||||
rclpy.__executor.shutdown()
|
||||
rclpy.__executor = None # Clear for restart
|
||||
print_status("[Restart] ROS executor shutdown complete", "info")
|
||||
except Exception as e:
|
||||
print_status(f"[Restart] Error shutting down executor: {e}", "warning")
|
||||
|
||||
# Shutdown rclpy
|
||||
if rclpy.ok():
|
||||
rclpy.shutdown()
|
||||
print_status("[Restart] rclpy shutdown complete", "info")
|
||||
|
||||
except ImportError as e:
|
||||
print_status(f"[Restart] ROS modules not available: {e}", "warning")
|
||||
except Exception as e:
|
||||
print_status(f"[Restart] Error in ROS cleanup: {e}", "warning")
|
||||
return False
|
||||
|
||||
# Step 3: Reset communication client singleton
|
||||
print_status("[Restart] Step 3: Resetting singletons...", "info")
|
||||
try:
|
||||
from unilabos.app import communication
|
||||
|
||||
if hasattr(communication, "_communication_client"):
|
||||
communication._communication_client = None
|
||||
print_status("[Restart] Communication client singleton reset", "info")
|
||||
except Exception as e:
|
||||
print_status(f"[Restart] Error resetting communication singleton: {e}", "warning")
|
||||
|
||||
# Step 4: Wait for threads to finish
|
||||
print_status("[Restart] Step 4: Waiting for threads to finish...", "info")
|
||||
time.sleep(3) # Give threads time to finish
|
||||
|
||||
# Check remaining threads
|
||||
remaining_threads = []
|
||||
for t in threading.enumerate():
|
||||
if t.name != "MainThread" and t.is_alive():
|
||||
remaining_threads.append(t.name)
|
||||
|
||||
if remaining_threads:
|
||||
print_status(
|
||||
f"[Restart] Warning: {len(remaining_threads)} threads still running: {remaining_threads}", "warning"
|
||||
)
|
||||
else:
|
||||
print_status("[Restart] All threads stopped", "info")
|
||||
|
||||
# Step 5: Force garbage collection
|
||||
print_status("[Restart] Step 5: Running garbage collection...", "info")
|
||||
gc.collect()
|
||||
gc.collect() # Run twice for weak references
|
||||
print_status("[Restart] Garbage collection complete", "info")
|
||||
|
||||
print_status("[Restart] Cleanup complete. Ready for re-initialization.", "info")
|
||||
return True
|
||||
@@ -1052,7 +1052,7 @@ async def handle_file_import(websocket: WebSocket, request_data: dict):
|
||||
"result": {},
|
||||
"schema": lab_registry._generate_unilab_json_command_schema(v["args"], k),
|
||||
"goal_default": {i["name"]: i["default"] for i in v["args"]},
|
||||
"handles": {},
|
||||
"handles": [],
|
||||
}
|
||||
# 不生成已配置action的动作
|
||||
for k, v in enhanced_info["action_methods"].items()
|
||||
@@ -1340,5 +1340,5 @@ def setup_api_routes(app):
|
||||
# 启动广播任务
|
||||
@app.on_event("startup")
|
||||
async def startup_event():
|
||||
asyncio.create_task(broadcast_device_status(), name="web-api-startup-device")
|
||||
asyncio.create_task(broadcast_status_page_data(), name="web-api-startup-status")
|
||||
asyncio.create_task(broadcast_device_status())
|
||||
asyncio.create_task(broadcast_status_page_data())
|
||||
|
||||
@@ -3,13 +3,11 @@ HTTP客户端模块
|
||||
|
||||
提供与远程服务器通信的客户端功能,只有host需要用
|
||||
"""
|
||||
import gzip
|
||||
|
||||
import json
|
||||
import os
|
||||
from typing import List, Dict, Any, Optional
|
||||
|
||||
from unilabos.utils.tools import fast_dumps as _fast_dumps, fast_dumps_pretty as _fast_dumps_pretty
|
||||
|
||||
import requests
|
||||
from unilabos.resources.resource_tracker import ResourceTreeSet
|
||||
from unilabos.utils.log import info
|
||||
@@ -282,54 +280,22 @@ class HTTPClient:
|
||||
)
|
||||
return response
|
||||
|
||||
def resource_registry(
|
||||
self, registry_data: Dict[str, Any] | List[Dict[str, Any]], tag: str = "registry",
|
||||
) -> requests.Response:
|
||||
def resource_registry(self, registry_data: Dict[str, Any] | List[Dict[str, Any]]) -> requests.Response:
|
||||
"""
|
||||
注册资源到服务器,同步保存请求/响应到 unilabos_data
|
||||
注册资源到服务器
|
||||
|
||||
Args:
|
||||
registry_data: 注册表数据,格式为 {resource_id: resource_info} / [{resource_info}]
|
||||
tag: 保存文件的标签后缀 (如 "device_registry" / "resource_registry")
|
||||
|
||||
Returns:
|
||||
Response: API响应对象
|
||||
"""
|
||||
# 序列化一次,同时用于保存和发送
|
||||
json_bytes = _fast_dumps(registry_data)
|
||||
|
||||
# 保存请求数据到 unilabos_data
|
||||
req_path = os.path.join(BasicConfig.working_dir, f"req_{tag}_upload.json")
|
||||
try:
|
||||
os.makedirs(BasicConfig.working_dir, exist_ok=True)
|
||||
with open(req_path, "wb") as f:
|
||||
f.write(_fast_dumps_pretty(registry_data))
|
||||
logger.trace(f"注册表请求数据已保存: {req_path}")
|
||||
except Exception as e:
|
||||
logger.warning(f"保存注册表请求数据失败: {e}")
|
||||
|
||||
compressed_body = gzip.compress(json_bytes)
|
||||
headers = {
|
||||
"Authorization": f"Lab {self.auth}",
|
||||
"Content-Type": "application/json",
|
||||
"Content-Encoding": "gzip",
|
||||
}
|
||||
response = requests.post(
|
||||
f"{self.remote_addr}/lab/resource",
|
||||
data=compressed_body,
|
||||
headers=headers,
|
||||
json=registry_data,
|
||||
headers={"Authorization": f"Lab {self.auth}"},
|
||||
timeout=30,
|
||||
)
|
||||
|
||||
# 保存响应数据到 unilabos_data
|
||||
res_path = os.path.join(BasicConfig.working_dir, f"res_{tag}_upload.json")
|
||||
try:
|
||||
with open(res_path, "w", encoding="utf-8") as f:
|
||||
f.write(f"{response.status_code}\n{response.text}")
|
||||
logger.trace(f"注册表响应数据已保存: {res_path}")
|
||||
except Exception as e:
|
||||
logger.warning(f"保存注册表响应数据失败: {e}")
|
||||
|
||||
if response.status_code not in [200, 201]:
|
||||
logger.error(f"注册资源失败: {response.status_code}, {response.text}")
|
||||
if response.status_code == 200:
|
||||
@@ -377,10 +343,9 @@ class HTTPClient:
|
||||
edges: List[Dict[str, Any]],
|
||||
tags: Optional[List[str]] = None,
|
||||
published: bool = False,
|
||||
description: str = "",
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
导入工作流到服务器,如果 published 为 True,则额外发起发布请求
|
||||
导入工作流到服务器
|
||||
|
||||
Args:
|
||||
name: 工作流名称(顶层)
|
||||
@@ -390,12 +355,13 @@ class HTTPClient:
|
||||
edges: 工作流边列表
|
||||
tags: 工作流标签列表,默认为空列表
|
||||
published: 是否发布工作流,默认为False
|
||||
description: 工作流描述,发布时使用
|
||||
|
||||
Returns:
|
||||
Dict: API响应数据,包含 code 和 data (uuid, name)
|
||||
"""
|
||||
# target_lab_uuid 暂时使用默认值,后续由后端根据 ak/sk 获取
|
||||
payload = {
|
||||
"target_lab_uuid": "28c38bb0-63f6-4352-b0d8-b5b8eb1766d5",
|
||||
"name": name,
|
||||
"data": {
|
||||
"workflow_uuid": workflow_uuid,
|
||||
@@ -403,6 +369,7 @@ class HTTPClient:
|
||||
"nodes": nodes,
|
||||
"edges": edges,
|
||||
"tags": tags if tags is not None else [],
|
||||
"published": published,
|
||||
},
|
||||
}
|
||||
# 保存请求到文件
|
||||
@@ -424,50 +391,10 @@ class HTTPClient:
|
||||
if "code" in res and res["code"] != 0:
|
||||
logger.error(f"导入工作流失败: {response.text}")
|
||||
return res
|
||||
# 导入成功后,如果需要发布则额外发起发布请求
|
||||
if published:
|
||||
imported_uuid = res.get("data", {}).get("uuid", workflow_uuid)
|
||||
publish_res = self.workflow_publish(imported_uuid, description)
|
||||
res["publish_result"] = publish_res
|
||||
return res
|
||||
else:
|
||||
logger.error(f"导入工作流失败: {response.status_code}, {response.text}")
|
||||
return {"code": response.status_code, "message": response.text}
|
||||
|
||||
def workflow_publish(self, workflow_uuid: str, description: str = "") -> Dict[str, Any]:
|
||||
"""
|
||||
发布工作流
|
||||
|
||||
Args:
|
||||
workflow_uuid: 工作流UUID
|
||||
description: 工作流描述
|
||||
|
||||
Returns:
|
||||
Dict: API响应数据
|
||||
"""
|
||||
payload = {
|
||||
"uuid": workflow_uuid,
|
||||
"description": description,
|
||||
"published": True,
|
||||
}
|
||||
logger.info(f"正在发布工作流: {workflow_uuid}")
|
||||
response = requests.patch(
|
||||
f"{self.remote_addr}/lab/workflow/owner",
|
||||
json=payload,
|
||||
headers={"Authorization": f"Lab {self.auth}"},
|
||||
timeout=60,
|
||||
)
|
||||
if response.status_code == 200:
|
||||
res = response.json()
|
||||
if "code" in res and res["code"] != 0:
|
||||
logger.error(f"发布工作流失败: {response.text}")
|
||||
else:
|
||||
logger.info(f"工作流发布成功: {workflow_uuid}")
|
||||
return res
|
||||
else:
|
||||
logger.error(f"发布工作流失败: {response.status_code}, {response.text}")
|
||||
return {"code": response.status_code, "message": response.text}
|
||||
|
||||
|
||||
# 创建默认客户端实例
|
||||
http_client = HTTPClient()
|
||||
|
||||
@@ -327,7 +327,6 @@ def job_add(req: JobAddReq) -> JobData:
|
||||
queue_item,
|
||||
action_type=action_type,
|
||||
action_kwargs=action_args,
|
||||
sample_material=req.sample_material,
|
||||
server_info=server_info,
|
||||
)
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ Web服务器模块
|
||||
|
||||
import webbrowser
|
||||
|
||||
import uvicorn
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from starlette.responses import Response
|
||||
@@ -86,7 +87,7 @@ def setup_server() -> FastAPI:
|
||||
# 设置页面路由
|
||||
try:
|
||||
setup_web_pages(pages)
|
||||
# info("[Web] 已加载Web UI模块")
|
||||
info("[Web] 已加载Web UI模块")
|
||||
except ImportError as e:
|
||||
info(f"[Web] 未找到Web页面模块: {str(e)}")
|
||||
except Exception as e:
|
||||
@@ -95,7 +96,7 @@ def setup_server() -> FastAPI:
|
||||
return app
|
||||
|
||||
|
||||
def start_server(host: str = "0.0.0.0", port: int = 8002, open_browser: bool = True) -> bool:
|
||||
def start_server(host: str = "0.0.0.0", port: int = 8002, open_browser: bool = True) -> None:
|
||||
"""
|
||||
启动服务器
|
||||
|
||||
@@ -103,14 +104,7 @@ def start_server(host: str = "0.0.0.0", port: int = 8002, open_browser: bool = T
|
||||
host: 服务器主机
|
||||
port: 服务器端口
|
||||
open_browser: 是否自动打开浏览器
|
||||
|
||||
Returns:
|
||||
bool: True if restart was requested, False otherwise
|
||||
"""
|
||||
import threading
|
||||
import time
|
||||
from uvicorn import Config, Server
|
||||
|
||||
# 设置服务器
|
||||
setup_server()
|
||||
|
||||
@@ -129,37 +123,7 @@ def start_server(host: str = "0.0.0.0", port: int = 8002, open_browser: bool = T
|
||||
|
||||
# 启动服务器
|
||||
info(f"[Web] 启动FastAPI服务器: {host}:{port}")
|
||||
|
||||
# 使用支持重启的模式
|
||||
config = Config(app=app, host=host, port=port, log_config=log_config)
|
||||
server = Server(config)
|
||||
|
||||
# 启动服务器线程
|
||||
server_thread = threading.Thread(target=server.run, daemon=True, name="uvicorn_server")
|
||||
server_thread.start()
|
||||
|
||||
# info("[Web] Server started, monitoring for restart requests...")
|
||||
|
||||
# 监控重启标志
|
||||
import unilabos.app.main as main_module
|
||||
|
||||
while server_thread.is_alive():
|
||||
if hasattr(main_module, "_restart_requested") and main_module._restart_requested:
|
||||
info(
|
||||
f"[Web] Restart requested via WebSocket, reason: {getattr(main_module, '_restart_reason', 'unknown')}"
|
||||
)
|
||||
main_module._restart_requested = False
|
||||
|
||||
# 停止服务器
|
||||
server.should_exit = True
|
||||
server_thread.join(timeout=5)
|
||||
|
||||
info("[Web] Server stopped, ready for restart")
|
||||
return True
|
||||
|
||||
time.sleep(1)
|
||||
|
||||
return False
|
||||
uvicorn.run(app, host=host, port=port, log_config=log_config)
|
||||
|
||||
|
||||
# 当脚本直接运行时启动服务器
|
||||
|
||||
@@ -23,10 +23,9 @@ from typing import Optional, Dict, Any, List
|
||||
from urllib.parse import urlparse
|
||||
from enum import Enum
|
||||
|
||||
from typing_extensions import TypedDict
|
||||
from jedi.inference.gradual.typing import TypedDict
|
||||
|
||||
from unilabos.app.model import JobAddReq
|
||||
from unilabos.resources.resource_tracker import ResourceDictType
|
||||
from unilabos.ros.nodes.presets.host_node import HostNode
|
||||
from unilabos.utils.type_check import serialize_result_info
|
||||
from unilabos.app.communication import BaseCommunicationClient
|
||||
@@ -77,7 +76,6 @@ class JobInfo:
|
||||
start_time: float
|
||||
last_update_time: float = field(default_factory=time.time)
|
||||
ready_timeout: Optional[float] = None # READY状态的超时时间
|
||||
always_free: bool = False # 是否为永久闲置动作(不受排队限制)
|
||||
|
||||
def update_timestamp(self):
|
||||
"""更新最后更新时间"""
|
||||
@@ -129,15 +127,6 @@ class DeviceActionManager:
|
||||
# 总是将job添加到all_jobs中
|
||||
self.all_jobs[job_info.job_id] = job_info
|
||||
|
||||
# always_free的动作不受排队限制,直接设为READY
|
||||
if job_info.always_free:
|
||||
job_info.status = JobStatus.READY
|
||||
job_info.update_timestamp()
|
||||
job_info.set_ready_timeout(10)
|
||||
job_log = format_job_log(job_info.job_id, job_info.task_id, job_info.device_id, job_info.action_name)
|
||||
logger.trace(f"[DeviceActionManager] Job {job_log} always_free, start immediately")
|
||||
return True
|
||||
|
||||
# 检查是否有正在执行或准备执行的任务
|
||||
if device_key in self.active_jobs:
|
||||
# 有正在执行或准备执行的任务,加入队列
|
||||
@@ -165,7 +154,7 @@ class DeviceActionManager:
|
||||
job_info.set_ready_timeout(10) # 设置10秒超时
|
||||
self.active_jobs[device_key] = job_info
|
||||
job_log = format_job_log(job_info.job_id, job_info.task_id, job_info.device_id, job_info.action_name)
|
||||
logger.trace(f"[DeviceActionManager] Job {job_log} can start immediately for {device_key}")
|
||||
logger.info(f"[DeviceActionManager] Job {job_log} can start immediately for {device_key}")
|
||||
return True
|
||||
|
||||
def start_job(self, job_id: str) -> bool:
|
||||
@@ -187,13 +176,9 @@ class DeviceActionManager:
|
||||
logger.error(f"[DeviceActionManager] Job {job_log} is not in READY status, current: {job_info.status}")
|
||||
return False
|
||||
|
||||
# always_free的job不需要检查active_jobs
|
||||
if not job_info.always_free:
|
||||
# 检查设备上是否是这个job
|
||||
if device_key not in self.active_jobs or self.active_jobs[device_key].job_id != job_id:
|
||||
job_log = format_job_log(
|
||||
job_info.job_id, job_info.task_id, job_info.device_id, job_info.action_name
|
||||
)
|
||||
job_log = format_job_log(job_info.job_id, job_info.task_id, job_info.device_id, job_info.action_name)
|
||||
logger.error(f"[DeviceActionManager] Job {job_log} is not the active job for {device_key}")
|
||||
return False
|
||||
|
||||
@@ -218,13 +203,6 @@ class DeviceActionManager:
|
||||
job_info = self.all_jobs[job_id]
|
||||
device_key = job_info.device_action_key
|
||||
|
||||
# always_free的job直接清理,不影响队列
|
||||
if job_info.always_free:
|
||||
job_info.status = JobStatus.ENDED
|
||||
job_info.update_timestamp()
|
||||
del self.all_jobs[job_id]
|
||||
return None
|
||||
|
||||
# 移除活跃任务
|
||||
if device_key in self.active_jobs and self.active_jobs[device_key].job_id == job_id:
|
||||
del self.active_jobs[device_key]
|
||||
@@ -232,9 +210,8 @@ class DeviceActionManager:
|
||||
job_info.update_timestamp()
|
||||
# 从all_jobs中移除已结束的job
|
||||
del self.all_jobs[job_id]
|
||||
# job_log = format_job_log(job_info.job_id, job_info.task_id, job_info.device_id, job_info.action_name)
|
||||
# logger.debug(f"[DeviceActionManager] Job {job_log} ended for {device_key}")
|
||||
pass
|
||||
job_log = format_job_log(job_info.job_id, job_info.task_id, job_info.device_id, job_info.action_name)
|
||||
logger.info(f"[DeviceActionManager] Job {job_log} ended for {device_key}")
|
||||
else:
|
||||
job_log = format_job_log(job_info.job_id, job_info.task_id, job_info.device_id, job_info.action_name)
|
||||
logger.warning(f"[DeviceActionManager] Job {job_log} was not active for {device_key}")
|
||||
@@ -250,20 +227,15 @@ class DeviceActionManager:
|
||||
next_job_log = format_job_log(
|
||||
next_job.job_id, next_job.task_id, next_job.device_id, next_job.action_name
|
||||
)
|
||||
logger.trace(f"[DeviceActionManager] Next job {next_job_log} can start for {device_key}")
|
||||
logger.info(f"[DeviceActionManager] Next job {next_job_log} can start for {device_key}")
|
||||
return next_job
|
||||
|
||||
return None
|
||||
|
||||
def get_active_jobs(self) -> List[JobInfo]:
|
||||
"""获取所有正在执行的任务(含active_jobs和always_free的STARTED job)"""
|
||||
"""获取所有正在执行的任务"""
|
||||
with self.lock:
|
||||
jobs = list(self.active_jobs.values())
|
||||
# 补充 always_free 的 STARTED job(它们不在 active_jobs 中)
|
||||
for job in self.all_jobs.values():
|
||||
if job.always_free and job.status == JobStatus.STARTED and job not in jobs:
|
||||
jobs.append(job)
|
||||
return jobs
|
||||
return list(self.active_jobs.values())
|
||||
|
||||
def get_queued_jobs(self) -> List[JobInfo]:
|
||||
"""获取所有排队中的任务"""
|
||||
@@ -288,14 +260,6 @@ class DeviceActionManager:
|
||||
job_info = self.all_jobs[job_id]
|
||||
device_key = job_info.device_action_key
|
||||
|
||||
# always_free的job直接清理
|
||||
if job_info.always_free:
|
||||
job_info.status = JobStatus.ENDED
|
||||
del self.all_jobs[job_id]
|
||||
job_log = format_job_log(job_info.job_id, job_info.task_id, job_info.device_id, job_info.action_name)
|
||||
logger.trace(f"[DeviceActionManager] Always-free job {job_log} cancelled")
|
||||
return True
|
||||
|
||||
# 如果是正在执行的任务
|
||||
if device_key in self.active_jobs and self.active_jobs[device_key].job_id == job_id:
|
||||
# 清理active job状态
|
||||
@@ -304,7 +268,7 @@ class DeviceActionManager:
|
||||
# 从all_jobs中移除
|
||||
del self.all_jobs[job_id]
|
||||
job_log = format_job_log(job_info.job_id, job_info.task_id, job_info.device_id, job_info.action_name)
|
||||
logger.trace(f"[DeviceActionManager] Active job {job_log} cancelled for {device_key}")
|
||||
logger.info(f"[DeviceActionManager] Active job {job_log} cancelled for {device_key}")
|
||||
|
||||
# 启动下一个任务
|
||||
if device_key in self.device_queues and self.device_queues[device_key]:
|
||||
@@ -317,7 +281,7 @@ class DeviceActionManager:
|
||||
next_job_log = format_job_log(
|
||||
next_job.job_id, next_job.task_id, next_job.device_id, next_job.action_name
|
||||
)
|
||||
logger.trace(f"[DeviceActionManager] Next job {next_job_log} can start after cancel")
|
||||
logger.info(f"[DeviceActionManager] Next job {next_job_log} can start after cancel")
|
||||
return True
|
||||
|
||||
# 如果是排队中的任务
|
||||
@@ -331,7 +295,7 @@ class DeviceActionManager:
|
||||
job_log = format_job_log(
|
||||
job_info.job_id, job_info.task_id, job_info.device_id, job_info.action_name
|
||||
)
|
||||
logger.trace(f"[DeviceActionManager] Queued job {job_log} cancelled for {device_key}")
|
||||
logger.info(f"[DeviceActionManager] Queued job {job_log} cancelled for {device_key}")
|
||||
return True
|
||||
|
||||
job_log = format_job_log(job_info.job_id, job_info.task_id, job_info.device_id, job_info.action_name)
|
||||
@@ -369,18 +333,13 @@ class DeviceActionManager:
|
||||
timeout_jobs = []
|
||||
|
||||
with self.lock:
|
||||
# 收集所有需要检查的 READY 任务(active_jobs + always_free READY jobs)
|
||||
ready_candidates = list(self.active_jobs.values())
|
||||
for job in self.all_jobs.values():
|
||||
if job.always_free and job.status == JobStatus.READY and job not in ready_candidates:
|
||||
ready_candidates.append(job)
|
||||
|
||||
ready_jobs_count = sum(1 for job in ready_candidates if job.status == JobStatus.READY)
|
||||
# 统计READY状态的任务数量
|
||||
ready_jobs_count = sum(1 for job in self.active_jobs.values() if job.status == JobStatus.READY)
|
||||
if ready_jobs_count > 0:
|
||||
logger.trace(f"[DeviceActionManager] Checking {ready_jobs_count} READY jobs for timeout") # type: ignore # noqa: E501
|
||||
|
||||
# 找到所有超时的READY任务(只检测,不处理)
|
||||
for job_info in ready_candidates:
|
||||
for job_info in self.active_jobs.values():
|
||||
if job_info.is_ready_timeout():
|
||||
timeout_jobs.append(job_info)
|
||||
job_log = format_job_log(
|
||||
@@ -400,7 +359,7 @@ class MessageProcessor:
|
||||
self.device_manager = device_manager
|
||||
self.queue_processor = None # 延迟设置
|
||||
self.websocket_client = None # 延迟设置
|
||||
self.session_id = str(uuid.uuid4())[:6] # 产生一个随机的session_id
|
||||
self.session_id = ""
|
||||
|
||||
# WebSocket连接
|
||||
self.websocket = None
|
||||
@@ -409,7 +368,6 @@ class MessageProcessor:
|
||||
# 线程控制
|
||||
self.is_running = False
|
||||
self.thread = None
|
||||
self._loop = None # asyncio event loop引用,用于外部关闭websocket
|
||||
self.reconnect_count = 0
|
||||
|
||||
logger.info(f"[MessageProcessor] Initialized for URL: {websocket_url}")
|
||||
@@ -436,31 +394,22 @@ class MessageProcessor:
|
||||
def stop(self) -> None:
|
||||
"""停止消息处理线程"""
|
||||
self.is_running = False
|
||||
# 主动关闭websocket以快速中断消息接收循环
|
||||
ws = self.websocket
|
||||
loop = self._loop
|
||||
if ws and loop and loop.is_running():
|
||||
try:
|
||||
asyncio.run_coroutine_threadsafe(ws.close(), loop)
|
||||
except Exception:
|
||||
pass
|
||||
if self.thread and self.thread.is_alive():
|
||||
self.thread.join(timeout=2)
|
||||
logger.info("[MessageProcessor] Stopped")
|
||||
|
||||
def _run(self):
|
||||
"""运行消息处理主循环"""
|
||||
self._loop = asyncio.new_event_loop()
|
||||
loop = asyncio.new_event_loop()
|
||||
try:
|
||||
asyncio.set_event_loop(self._loop)
|
||||
self._loop.run_until_complete(self._connection_handler())
|
||||
asyncio.set_event_loop(loop)
|
||||
loop.run_until_complete(self._connection_handler())
|
||||
except Exception as e:
|
||||
logger.error(f"[MessageProcessor] Thread error: {str(e)}")
|
||||
logger.error(traceback.format_exc())
|
||||
finally:
|
||||
if self._loop:
|
||||
self._loop.close()
|
||||
self._loop = None
|
||||
if loop:
|
||||
loop.close()
|
||||
|
||||
async def _connection_handler(self):
|
||||
"""处理WebSocket连接和重连逻辑"""
|
||||
@@ -477,10 +426,8 @@ class MessageProcessor:
|
||||
async with websockets.connect(
|
||||
self.websocket_url,
|
||||
ssl=ssl_context,
|
||||
open_timeout=20,
|
||||
ping_interval=WSConfig.ping_interval,
|
||||
ping_timeout=10,
|
||||
close_timeout=5,
|
||||
additional_headers={
|
||||
"Authorization": f"Lab {BasicConfig.auth_secret()}",
|
||||
"EdgeSession": f"{self.session_id}",
|
||||
@@ -491,95 +438,69 @@ class MessageProcessor:
|
||||
self.connected = True
|
||||
self.reconnect_count = 0
|
||||
|
||||
logger.info(f"[MessageProcessor] 已连接到 {self.websocket_url}")
|
||||
logger.trace(f"[MessageProcessor] Connected to {self.websocket_url}")
|
||||
|
||||
# 启动发送协程
|
||||
send_task = asyncio.create_task(self._send_handler(), name="websocket-send_task")
|
||||
|
||||
# 每次连接(含重连)后重新向服务端注册,
|
||||
# 否则服务端不知道客户端已上线,不会推送消息。
|
||||
if self.websocket_client:
|
||||
self.websocket_client.publish_host_ready()
|
||||
send_task = asyncio.create_task(self._send_handler())
|
||||
|
||||
try:
|
||||
# 接收消息循环
|
||||
await self._message_handler()
|
||||
finally:
|
||||
# 必须在 async with __aexit__ 之前停止 send_task,
|
||||
# 否则 send_task 会在关闭握手期间继续发送数据,
|
||||
# 干扰 websockets 库的内部清理,导致 task 泄漏。
|
||||
self.connected = False
|
||||
send_task.cancel()
|
||||
try:
|
||||
await send_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
self.connected = False
|
||||
|
||||
except websockets.exceptions.ConnectionClosed:
|
||||
logger.warning("[MessageProcessor] 与服务端连接中断")
|
||||
except TimeoutError:
|
||||
logger.warning(
|
||||
f"[MessageProcessor] 与服务端连接通信超时 (已尝试 {self.reconnect_count + 1} 次),请检查您的网络状况"
|
||||
)
|
||||
except websockets.exceptions.InvalidStatus as e:
|
||||
logger.warning(
|
||||
f"[MessageProcessor] 收到服务端注册码 {e.response.status_code}, 上一进程可能还未退出"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(traceback.format_exc())
|
||||
logger.error(f"[MessageProcessor] 尝试重连时出错 {str(e)}")
|
||||
finally:
|
||||
logger.warning("[MessageProcessor] Connection closed")
|
||||
self.connected = False
|
||||
except Exception as e:
|
||||
logger.error(f"[MessageProcessor] Connection error: {str(e)}")
|
||||
logger.error(traceback.format_exc())
|
||||
self.connected = False
|
||||
finally:
|
||||
self.websocket = None
|
||||
|
||||
# 重连逻辑
|
||||
if not self.is_running:
|
||||
break
|
||||
if self.reconnect_count < WSConfig.max_reconnect_attempts:
|
||||
if self.is_running and self.reconnect_count < WSConfig.max_reconnect_attempts:
|
||||
self.reconnect_count += 1
|
||||
backoff = WSConfig.reconnect_interval
|
||||
logger.info(
|
||||
f"[MessageProcessor] 即将在 {backoff} 秒后重连 (已尝试 {self.reconnect_count}/{WSConfig.max_reconnect_attempts})"
|
||||
f"[MessageProcessor] Reconnecting in {WSConfig.reconnect_interval}s "
|
||||
f"(attempt {self.reconnect_count}/{WSConfig.max_reconnect_attempts})"
|
||||
)
|
||||
await asyncio.sleep(backoff)
|
||||
else:
|
||||
await asyncio.sleep(WSConfig.reconnect_interval)
|
||||
elif self.reconnect_count >= WSConfig.max_reconnect_attempts:
|
||||
logger.error("[MessageProcessor] Max reconnection attempts reached")
|
||||
break
|
||||
else:
|
||||
self.reconnect_count -= 1
|
||||
|
||||
async def _message_handler(self):
|
||||
"""处理接收到的消息。
|
||||
|
||||
ConnectionClosed 不在此处捕获,让其向上传播到 _connection_handler,
|
||||
以便 async with websockets.connect() 的 __aexit__ 能感知连接已断,
|
||||
正确清理内部 task,避免 task 泄漏。
|
||||
"""
|
||||
"""处理接收到的消息"""
|
||||
if not self.websocket:
|
||||
logger.error("[MessageProcessor] WebSocket connection is None")
|
||||
return
|
||||
|
||||
try:
|
||||
async for message in self.websocket:
|
||||
try:
|
||||
data = json.loads(message)
|
||||
message_type = data.get("action", "")
|
||||
message_data = data.get("data")
|
||||
if self.session_id and self.session_id == data.get("edge_session"):
|
||||
await self._process_message(message_type, message_data)
|
||||
else:
|
||||
if message_type.endswith("_material"):
|
||||
logger.trace(
|
||||
f"[MessageProcessor] 收到一条归属 {data.get('edge_session')} 的旧消息:{data}"
|
||||
)
|
||||
logger.debug(
|
||||
f"[MessageProcessor] 跳过了一条归属 {data.get('edge_session')} 的旧消息: {data.get('action')}"
|
||||
)
|
||||
else:
|
||||
await self._process_message(message_type, message_data)
|
||||
await self._process_message(data)
|
||||
except json.JSONDecodeError:
|
||||
logger.error(f"[MessageProcessor] Invalid JSON received: {message}")
|
||||
except Exception as e:
|
||||
logger.error(f"[MessageProcessor] Error processing message: {str(e)}")
|
||||
logger.error(traceback.format_exc())
|
||||
|
||||
except websockets.exceptions.ConnectionClosed:
|
||||
logger.info("[MessageProcessor] Message handler stopped - connection closed")
|
||||
except Exception as e:
|
||||
logger.error(f"[MessageProcessor] Message handler error: {str(e)}")
|
||||
logger.error(traceback.format_exc())
|
||||
|
||||
async def _send_handler(self):
|
||||
"""处理发送队列中的消息"""
|
||||
logger.trace("[MessageProcessor] Send handler started")
|
||||
@@ -610,7 +531,7 @@ class MessageProcessor:
|
||||
try:
|
||||
message_str = json.dumps(msg, ensure_ascii=False)
|
||||
await self.websocket.send(message_str)
|
||||
# logger.trace(f"[MessageProcessor] Message sent: {msg.get('action', 'unknown')}") # type: ignore # noqa: E501
|
||||
logger.trace(f"[MessageProcessor] Message sent: {msg.get('action', 'unknown')}") # type: ignore # noqa: E501
|
||||
except Exception as e:
|
||||
logger.error(f"[MessageProcessor] Failed to send message: {str(e)}")
|
||||
logger.error(traceback.format_exc())
|
||||
@@ -627,16 +548,18 @@ class MessageProcessor:
|
||||
|
||||
except asyncio.CancelledError:
|
||||
logger.debug("[MessageProcessor] Send handler cancelled")
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"[MessageProcessor] Fatal error in send handler: {str(e)}")
|
||||
logger.error(traceback.format_exc())
|
||||
finally:
|
||||
logger.debug("[MessageProcessor] Send handler stopped")
|
||||
|
||||
async def _process_message(self, message_type: str, message_data: Dict[str, Any]):
|
||||
async def _process_message(self, data: Dict[str, Any]):
|
||||
"""处理收到的消息"""
|
||||
logger.trace(f"[MessageProcessor] Processing message: {message_type}")
|
||||
message_type = data.get("action", "")
|
||||
message_data = data.get("data")
|
||||
|
||||
logger.debug(f"[MessageProcessor] Processing message: {message_type}")
|
||||
|
||||
try:
|
||||
if message_type == "pong":
|
||||
@@ -648,23 +571,16 @@ class MessageProcessor:
|
||||
elif message_type == "cancel_action" or message_type == "cancel_task":
|
||||
await self._handle_cancel_action(message_data)
|
||||
elif message_type == "add_material":
|
||||
# noinspection PyTypeChecker
|
||||
await self._handle_resource_tree_update(message_data, "add")
|
||||
elif message_type == "update_material":
|
||||
# noinspection PyTypeChecker
|
||||
await self._handle_resource_tree_update(message_data, "update")
|
||||
elif message_type == "remove_material":
|
||||
# noinspection PyTypeChecker
|
||||
await self._handle_resource_tree_update(message_data, "remove")
|
||||
# elif message_type == "session_id":
|
||||
# self.session_id = message_data.get("session_id")
|
||||
# logger.info(f"[MessageProcessor] Session ID: {self.session_id}")
|
||||
elif message_type == "add_device":
|
||||
await self._handle_device_manage(message_data, "add")
|
||||
elif message_type == "remove_device":
|
||||
await self._handle_device_manage(message_data, "remove")
|
||||
elif message_type == "request_restart":
|
||||
await self._handle_request_restart(message_data)
|
||||
elif message_type == "session_id":
|
||||
self.session_id = message_data.get("session_id")
|
||||
logger.info(f"[MessageProcessor] Session ID: {self.session_id}")
|
||||
elif message_type == "request_reload":
|
||||
await self._handle_request_reload(message_data)
|
||||
else:
|
||||
logger.debug(f"[MessageProcessor] Unknown message type: {message_type}")
|
||||
|
||||
@@ -678,24 +594,6 @@ class MessageProcessor:
|
||||
if host_node:
|
||||
host_node.handle_pong_response(pong_data)
|
||||
|
||||
def _check_action_always_free(self, device_id: str, action_name: str) -> bool:
|
||||
"""检查该action是否标记为always_free,通过HostNode统一的_action_value_mappings查找"""
|
||||
try:
|
||||
host_node = HostNode.get_instance(0)
|
||||
if not host_node:
|
||||
return False
|
||||
# noinspection PyProtectedMember
|
||||
action_mappings = host_node._action_value_mappings.get(device_id)
|
||||
if not action_mappings:
|
||||
return False
|
||||
# 尝试直接匹配或 auto- 前缀匹配
|
||||
for key in [action_name, f"auto-{action_name}"]:
|
||||
if key in action_mappings:
|
||||
return action_mappings[key].get("always_free", False)
|
||||
return False
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
async def _handle_query_action_state(self, data: Dict[str, Any]):
|
||||
"""处理query_action_state消息"""
|
||||
device_id = data.get("device_id", "")
|
||||
@@ -710,9 +608,6 @@ class MessageProcessor:
|
||||
|
||||
device_action_key = f"/devices/{device_id}/{action_name}"
|
||||
|
||||
# 检查action是否为always_free
|
||||
action_always_free = self._check_action_always_free(device_id, action_name)
|
||||
|
||||
# 创建任务信息
|
||||
job_info = JobInfo(
|
||||
job_id=job_id,
|
||||
@@ -722,7 +617,6 @@ class MessageProcessor:
|
||||
device_action_key=device_action_key,
|
||||
status=JobStatus.QUEUE,
|
||||
start_time=time.time(),
|
||||
always_free=action_always_free,
|
||||
)
|
||||
|
||||
# 添加到设备管理器
|
||||
@@ -734,13 +628,13 @@ class MessageProcessor:
|
||||
await self._send_action_state_response(
|
||||
device_id, action_name, task_id, job_id, "query_action_status", True, 0
|
||||
)
|
||||
logger.trace(f"[MessageProcessor] Job {job_log} can start immediately")
|
||||
logger.info(f"[MessageProcessor] Job {job_log} can start immediately")
|
||||
else:
|
||||
# 需要排队
|
||||
await self._send_action_state_response(
|
||||
device_id, action_name, task_id, job_id, "query_action_status", False, 10
|
||||
)
|
||||
logger.trace(f"[MessageProcessor] Job {job_log} queued")
|
||||
logger.info(f"[MessageProcessor] Job {job_log} queued")
|
||||
|
||||
# 通知QueueProcessor有新的队列更新
|
||||
if self.queue_processor:
|
||||
@@ -749,37 +643,9 @@ class MessageProcessor:
|
||||
async def _handle_job_start(self, data: Dict[str, Any]):
|
||||
"""处理job_start消息"""
|
||||
try:
|
||||
if not data.get("sample_material"):
|
||||
data["sample_material"] = {}
|
||||
req = JobAddReq(**data)
|
||||
|
||||
job_log = format_job_log(req.job_id, req.task_id, req.device_id, req.action)
|
||||
|
||||
# 服务端对always_free动作可能跳过query_action_state直接发job_start,
|
||||
# 此时job尚未注册,需要自动补注册
|
||||
existing_job = self.device_manager.get_job_info(req.job_id)
|
||||
if not existing_job:
|
||||
action_name = req.action
|
||||
device_action_key = f"/devices/{req.device_id}/{action_name}"
|
||||
action_always_free = self._check_action_always_free(req.device_id, action_name)
|
||||
|
||||
if action_always_free:
|
||||
job_info = JobInfo(
|
||||
job_id=req.job_id,
|
||||
task_id=req.task_id,
|
||||
device_id=req.device_id,
|
||||
action_name=action_name,
|
||||
device_action_key=device_action_key,
|
||||
status=JobStatus.QUEUE,
|
||||
start_time=time.time(),
|
||||
always_free=True,
|
||||
)
|
||||
self.device_manager.add_queue_request(job_info)
|
||||
logger.info(f"[MessageProcessor] Job {job_log} always_free, auto-registered from direct job_start")
|
||||
else:
|
||||
logger.error(f"[MessageProcessor] Job {job_log} not registered (missing query_action_state)")
|
||||
return
|
||||
|
||||
success = self.device_manager.start_job(req.job_id)
|
||||
if not success:
|
||||
logger.error(f"[MessageProcessor] Failed to start job {job_log}")
|
||||
@@ -808,7 +674,6 @@ class MessageProcessor:
|
||||
queue_item,
|
||||
action_type=req.action_type,
|
||||
action_kwargs=req.action_args,
|
||||
sample_material=req.sample_material,
|
||||
server_info=req.server_info,
|
||||
)
|
||||
|
||||
@@ -973,7 +838,9 @@ class MessageProcessor:
|
||||
device_action_groups[key_add] = []
|
||||
device_action_groups[key_add].append(item["uuid"])
|
||||
|
||||
logger.info(f"[资源同步] 跨站Transfer: {item['uuid'][:8]} from {device_old_id} to {device_id}")
|
||||
logger.info(
|
||||
f"[MessageProcessor] Resource migrated: {item['uuid'][:8]} from {device_old_id} to {device_id}"
|
||||
)
|
||||
else:
|
||||
# 正常update
|
||||
key = (device_id, "update")
|
||||
@@ -987,13 +854,11 @@ class MessageProcessor:
|
||||
device_action_groups[key] = []
|
||||
device_action_groups[key].append(item["uuid"])
|
||||
|
||||
logger.trace(
|
||||
f"[资源同步] 动作 {action} 分组数量: {len(device_action_groups)}, 总数量: {len(resource_uuid_list)}"
|
||||
)
|
||||
logger.info(f"触发物料更新 {action} 分组数量: {len(device_action_groups)}, 总数量: {len(resource_uuid_list)}")
|
||||
|
||||
# 为每个(device_id, action)创建独立的更新线程
|
||||
for (device_id, actual_action), items in device_action_groups.items():
|
||||
logger.trace(f"[资源同步] {device_id} 物料动作 {actual_action} 数量: {len(items)}")
|
||||
logger.info(f"设备 {device_id} 物料更新 {actual_action} 数量: {len(items)}")
|
||||
|
||||
def _notify_resource_tree(dev_id, act, item_list):
|
||||
try:
|
||||
@@ -1025,80 +890,19 @@ class MessageProcessor:
|
||||
)
|
||||
thread.start()
|
||||
|
||||
async def _handle_device_manage(self, device_list: list[ResourceDictType], action: str):
|
||||
"""Handle add_device / remove_device from LabGo server."""
|
||||
if not device_list:
|
||||
return
|
||||
|
||||
for item in device_list:
|
||||
target_node_id = item.get("target_node_id", "host_node")
|
||||
|
||||
def _notify(target_id: str, act: str, cfg: ResourceDictType):
|
||||
try:
|
||||
host_node = HostNode.get_instance(timeout=5)
|
||||
if not host_node:
|
||||
logger.error(f"[DeviceManage] HostNode not available for {act}_device")
|
||||
return
|
||||
success = host_node.notify_device_manage(target_id, act, cfg)
|
||||
if success:
|
||||
logger.info(f"[DeviceManage] {act}_device completed on {target_id}")
|
||||
else:
|
||||
logger.warning(f"[DeviceManage] {act}_device failed on {target_id}")
|
||||
except Exception as e:
|
||||
logger.error(f"[DeviceManage] Error in {act}_device: {e}")
|
||||
logger.error(traceback.format_exc())
|
||||
|
||||
thread = threading.Thread(
|
||||
target=_notify,
|
||||
args=(target_node_id, action, item),
|
||||
daemon=True,
|
||||
name=f"DeviceManage-{action}-{item.get('id', '')}",
|
||||
)
|
||||
thread.start()
|
||||
|
||||
async def _handle_request_restart(self, data: Dict[str, Any]):
|
||||
async def _handle_request_reload(self, data: Dict[str, Any]):
|
||||
"""
|
||||
处理重启请求
|
||||
处理重载请求
|
||||
|
||||
当LabGo发送request_restart时,执行清理并触发重启
|
||||
当LabGo发送request_reload时,重新发送设备注册信息
|
||||
"""
|
||||
reason = data.get("reason", "unknown")
|
||||
delay = data.get("delay", 2) # 默认延迟2秒
|
||||
logger.info(f"[MessageProcessor] Received restart request, reason: {reason}, delay: {delay}s")
|
||||
logger.info(f"[MessageProcessor] Received reload request, reason: {reason}")
|
||||
|
||||
# 发送确认消息
|
||||
self.send_message(
|
||||
{"action": "restart_acknowledged", "data": {"reason": reason, "delay": delay}}
|
||||
)
|
||||
|
||||
# 设置全局重启标志
|
||||
import unilabos.app.main as main_module
|
||||
|
||||
main_module._restart_requested = True
|
||||
main_module._restart_reason = reason
|
||||
|
||||
# 延迟后执行清理
|
||||
await asyncio.sleep(delay)
|
||||
|
||||
# 在新线程中执行清理,避免阻塞当前事件循环
|
||||
def do_cleanup():
|
||||
import time
|
||||
|
||||
time.sleep(0.5) # 给当前消息处理完成的时间
|
||||
logger.info(f"[MessageProcessor] Starting cleanup for restart, reason: {reason}")
|
||||
try:
|
||||
from unilabos.app.utils import cleanup_for_restart
|
||||
|
||||
if cleanup_for_restart():
|
||||
logger.info("[MessageProcessor] Cleanup successful, main() will restart")
|
||||
else:
|
||||
logger.error("[MessageProcessor] Cleanup failed")
|
||||
except Exception as e:
|
||||
logger.error(f"[MessageProcessor] Error during cleanup: {e}")
|
||||
|
||||
cleanup_thread = threading.Thread(target=do_cleanup, name="RestartCleanupThread", daemon=True)
|
||||
cleanup_thread.start()
|
||||
logger.info(f"[MessageProcessor] Restart cleanup scheduled")
|
||||
# 重新发送host_node_ready信息
|
||||
if self.websocket_client:
|
||||
self.websocket_client.publish_host_ready()
|
||||
logger.info("[MessageProcessor] Re-sent host_node_ready after reload request")
|
||||
|
||||
async def _send_action_state_response(
|
||||
self, device_id: str, action_name: str, task_id: str, job_id: str, typ: str, free: bool, need_more: int
|
||||
@@ -1171,7 +975,6 @@ class QueueProcessor:
|
||||
def stop(self) -> None:
|
||||
"""停止队列处理线程"""
|
||||
self.is_running = False
|
||||
self.queue_update_event.set() # 立即唤醒等待中的线程
|
||||
if self.thread and self.thread.is_alive():
|
||||
self.thread.join(timeout=2)
|
||||
logger.info("[QueueProcessor] Stopped")
|
||||
@@ -1272,11 +1075,6 @@ class QueueProcessor:
|
||||
logger.debug(f"[QueueProcessor] Sending busy status for {len(queued_jobs)} queued jobs")
|
||||
|
||||
for job_info in queued_jobs:
|
||||
# 快照可能已过期:在遍历过程中 end_job() 可能已将此 job 移至 READY,
|
||||
# 此时不应再发送 busy/need_more,否则会覆盖已发出的 free=True 通知
|
||||
if job_info.status != JobStatus.QUEUE:
|
||||
continue
|
||||
|
||||
message = {
|
||||
"action": "report_action_state",
|
||||
"data": {
|
||||
@@ -1292,7 +1090,7 @@ class QueueProcessor:
|
||||
success = self.message_processor.send_message(message)
|
||||
job_log = format_job_log(job_info.job_id, job_info.task_id, job_info.device_id, job_info.action_name)
|
||||
if success:
|
||||
logger.trace(f"[QueueProcessor] Sent busy/need_more for queued job {job_log}")
|
||||
logger.debug(f"[QueueProcessor] Sent busy/need_more for queued job {job_log}")
|
||||
else:
|
||||
logger.warning(f"[QueueProcessor] Failed to send busy status for job {job_log}")
|
||||
|
||||
@@ -1315,7 +1113,7 @@ class QueueProcessor:
|
||||
job_info.action_name,
|
||||
)
|
||||
|
||||
logger.trace(f"[QueueProcessor] Job {job_log} completed with status: {status}")
|
||||
logger.info(f"[QueueProcessor] Job {job_log} completed with status: {status}")
|
||||
|
||||
# 结束任务,获取下一个可执行的任务
|
||||
next_job = self.device_manager.end_job(job_id)
|
||||
@@ -1335,8 +1133,8 @@ class QueueProcessor:
|
||||
},
|
||||
}
|
||||
self.message_processor.send_message(message)
|
||||
# next_job_log = format_job_log(next_job.job_id, next_job.task_id, next_job.device_id, next_job.action_name)
|
||||
# logger.debug(f"[QueueProcessor] Notified next job {next_job_log} can start")
|
||||
next_job_log = format_job_log(next_job.job_id, next_job.task_id, next_job.device_id, next_job.action_name)
|
||||
logger.info(f"[QueueProcessor] Notified next job {next_job_log} can start")
|
||||
|
||||
# 立即触发下一轮状态检查
|
||||
self.notify_queue_update()
|
||||
@@ -1425,8 +1223,8 @@ class WebSocketClient(BaseCommunicationClient):
|
||||
message = {"action": "normal_exit", "data": {"session_id": session_id}}
|
||||
self.message_processor.send_message(message)
|
||||
logger.info(f"[WebSocketClient] Sent normal_exit message with session_id: {session_id}")
|
||||
# send_handler 每100ms检查一次队列,等300ms足以让消息发出
|
||||
time.sleep(0.3)
|
||||
# 给一点时间让消息发送出去
|
||||
time.sleep(1)
|
||||
except Exception as e:
|
||||
logger.warning(f"[WebSocketClient] Failed to send normal_exit message: {str(e)}")
|
||||
|
||||
@@ -1458,7 +1256,7 @@ class WebSocketClient(BaseCommunicationClient):
|
||||
},
|
||||
}
|
||||
self.message_processor.send_message(message)
|
||||
# logger.trace(f"[WebSocketClient] Device status published: {device_id}.{property_name}")
|
||||
logger.trace(f"[WebSocketClient] Device status published: {device_id}.{property_name}")
|
||||
|
||||
def publish_job_status(
|
||||
self, feedback_data: dict, item: QueueItem, status: str, return_info: Optional[dict] = None
|
||||
@@ -1478,7 +1276,7 @@ class WebSocketClient(BaseCommunicationClient):
|
||||
except (KeyError, AttributeError):
|
||||
logger.warning(f"[WebSocketClient] Failed to remove job {item.job_id} from HostNode status")
|
||||
|
||||
# logger.debug(f"[WebSocketClient] Intercepting final status for job_id: {item.job_id} - {status}")
|
||||
logger.info(f"[WebSocketClient] Intercepting final status for job_id: {item.job_id} - {status}")
|
||||
|
||||
# 通知队列处理器job完成(包括timeout的job)
|
||||
self.queue_processor.handle_job_completed(item.job_id, status)
|
||||
@@ -1545,9 +1343,7 @@ class WebSocketClient(BaseCommunicationClient):
|
||||
if host_node:
|
||||
# 获取设备信息
|
||||
for device_id, namespace in host_node.devices_names.items():
|
||||
device_key = (
|
||||
f"{namespace}/{device_id}" if namespace.startswith("/") else f"/{namespace}/{device_id}"
|
||||
)
|
||||
device_key = f"{namespace}/{device_id}" if namespace.startswith("/") else f"/{namespace}/{device_id}"
|
||||
is_online = device_key in host_node._online_devices
|
||||
|
||||
# 获取设备的动作信息
|
||||
@@ -1561,16 +1357,14 @@ class WebSocketClient(BaseCommunicationClient):
|
||||
"action_type": str(type(client).__name__),
|
||||
}
|
||||
|
||||
devices.append(
|
||||
{
|
||||
devices.append({
|
||||
"device_id": device_id,
|
||||
"namespace": namespace,
|
||||
"device_key": device_key,
|
||||
"is_online": is_online,
|
||||
"machine_name": host_node.device_machine_names.get(device_id, machine_name),
|
||||
"actions": actions,
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
logger.info(f"[WebSocketClient] Collected {len(devices)} devices for host_ready")
|
||||
except Exception as e:
|
||||
|
||||
@@ -95,29 +95,8 @@ def get_vessel_liquid_volume(G: nx.DiGraph, vessel: str) -> float:
|
||||
return total_volume
|
||||
|
||||
|
||||
def is_integrated_pump(node_class: str, node_name: str = "") -> bool:
|
||||
"""
|
||||
判断是否为泵阀一体设备
|
||||
"""
|
||||
class_lower = (node_class or "").lower()
|
||||
name_lower = (node_name or "").lower()
|
||||
|
||||
if "pump" not in class_lower and "pump" not in name_lower:
|
||||
return False
|
||||
|
||||
integrated_markers = [
|
||||
"valve",
|
||||
"pump_valve",
|
||||
"pumpvalve",
|
||||
"integrated",
|
||||
"transfer_pump",
|
||||
]
|
||||
|
||||
for marker in integrated_markers:
|
||||
if marker in class_lower or marker in name_lower:
|
||||
return True
|
||||
|
||||
return False
|
||||
def is_integrated_pump(node_name):
|
||||
return "pump" in node_name and "valve" in node_name
|
||||
|
||||
|
||||
def find_connected_pump(G, valve_node):
|
||||
@@ -207,9 +186,7 @@ def build_pump_valve_maps(G, pump_backbone):
|
||||
debug_print(f"🔧 过滤后的骨架: {filtered_backbone}")
|
||||
|
||||
for node in filtered_backbone:
|
||||
node_data = G.nodes.get(node, {})
|
||||
node_class = node_data.get("class", "") or ""
|
||||
if is_integrated_pump(node_class, node):
|
||||
if is_integrated_pump(G.nodes[node]["class"]):
|
||||
pumps_from_node[node] = node
|
||||
valve_from_node[node] = node
|
||||
debug_print(f" - 集成泵-阀: {node}")
|
||||
|
||||
@@ -16,15 +16,11 @@ class BasicConfig:
|
||||
upload_registry = False
|
||||
machine_name = "undefined"
|
||||
vis_2d_enable = False
|
||||
no_update_feedback = False
|
||||
enable_resource_load = True
|
||||
communication_protocol = "websocket"
|
||||
startup_json_path = None # 填写绝对路径
|
||||
disable_browser = False # 禁止浏览器自动打开
|
||||
port = 8002 # 本地HTTP服务
|
||||
check_mode = False # CI 检查模式,用于验证 registry 导入和文件一致性
|
||||
test_mode = False # 测试模式,所有动作不实际执行,返回模拟结果
|
||||
extra_resource = False # 是否加载lab_开头的额外资源
|
||||
# 'TRACE', 'DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'
|
||||
log_level: Literal["TRACE", "DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] = "DEBUG"
|
||||
|
||||
@@ -41,7 +37,7 @@ class BasicConfig:
|
||||
class WSConfig:
|
||||
reconnect_interval = 5 # 重连间隔(秒)
|
||||
max_reconnect_attempts = 999 # 最大重连次数
|
||||
ping_interval = 20 # ping间隔(秒)
|
||||
ping_interval = 30 # ping间隔(秒)
|
||||
|
||||
|
||||
# HTTP配置
|
||||
@@ -147,5 +143,5 @@ def load_config(config_path=None):
|
||||
traceback.print_exc()
|
||||
exit(1)
|
||||
else:
|
||||
config_path = os.path.join(os.path.dirname(__file__), "example_config.py")
|
||||
config_path = os.path.join(os.path.dirname(__file__), "local_config.py")
|
||||
load_config(config_path)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
|
||||
from abc import abstractmethod
|
||||
from functools import wraps
|
||||
import inspect
|
||||
|
||||
@@ -201,42 +201,17 @@ class ResourceVisualization:
|
||||
self.moveit_controllers_yaml['moveit_simple_controller_manager'][f"{name}_{controller_name}"] = moveit_dict['moveit_simple_controller_manager'][controller_name]
|
||||
|
||||
|
||||
@staticmethod
|
||||
def _ensure_ros2_env() -> dict:
|
||||
"""确保 ROS2 环境变量正确设置,返回可用于子进程的 env dict"""
|
||||
import sys
|
||||
env = dict(os.environ)
|
||||
conda_prefix = os.path.dirname(os.path.dirname(sys.executable))
|
||||
|
||||
if "AMENT_PREFIX_PATH" not in env or not env["AMENT_PREFIX_PATH"].strip():
|
||||
candidate = os.pathsep.join([conda_prefix, os.path.join(conda_prefix, "Library")])
|
||||
env["AMENT_PREFIX_PATH"] = candidate
|
||||
os.environ["AMENT_PREFIX_PATH"] = candidate
|
||||
|
||||
extra_bin_dirs = [
|
||||
os.path.join(conda_prefix, "Library", "bin"),
|
||||
os.path.join(conda_prefix, "Library", "lib"),
|
||||
os.path.join(conda_prefix, "Scripts"),
|
||||
conda_prefix,
|
||||
]
|
||||
current_path = env.get("PATH", "")
|
||||
for d in extra_bin_dirs:
|
||||
if d not in current_path:
|
||||
current_path = d + os.pathsep + current_path
|
||||
env["PATH"] = current_path
|
||||
os.environ["PATH"] = current_path
|
||||
|
||||
return env
|
||||
|
||||
def create_launch_description(self) -> LaunchDescription:
|
||||
"""
|
||||
创建launch描述,包含robot_state_publisher和move_group节点
|
||||
|
||||
Args:
|
||||
urdf_str: URDF文本
|
||||
|
||||
Returns:
|
||||
LaunchDescription: launch描述对象
|
||||
"""
|
||||
launch_env = self._ensure_ros2_env()
|
||||
|
||||
# 检查ROS 2环境变量
|
||||
if "AMENT_PREFIX_PATH" not in os.environ:
|
||||
raise OSError(
|
||||
"ROS 2环境未正确设置。需要设置 AMENT_PREFIX_PATH 环境变量。\n"
|
||||
@@ -315,7 +290,7 @@ class ResourceVisualization:
|
||||
{"robot_description": robot_description},
|
||||
ros2_controllers,
|
||||
],
|
||||
env=launch_env,
|
||||
env=dict(os.environ)
|
||||
)
|
||||
)
|
||||
for controller in self.moveit_controllers_yaml['moveit_simple_controller_manager']['controller_names']:
|
||||
@@ -325,7 +300,7 @@ class ResourceVisualization:
|
||||
executable="spawner",
|
||||
arguments=[f"{controller}", "--controller-manager", f"controller_manager"],
|
||||
output="screen",
|
||||
env=launch_env,
|
||||
env=dict(os.environ)
|
||||
)
|
||||
)
|
||||
controllers.append(
|
||||
@@ -334,7 +309,7 @@ class ResourceVisualization:
|
||||
executable="spawner",
|
||||
arguments=["joint_state_broadcaster", "--controller-manager", f"controller_manager"],
|
||||
output="screen",
|
||||
env=launch_env,
|
||||
env=dict(os.environ)
|
||||
)
|
||||
)
|
||||
for i in controllers:
|
||||
@@ -342,6 +317,7 @@ class ResourceVisualization:
|
||||
else:
|
||||
ros2_controllers = None
|
||||
|
||||
# 创建robot_state_publisher节点
|
||||
robot_state_publisher = nd(
|
||||
package='robot_state_publisher',
|
||||
executable='robot_state_publisher',
|
||||
@@ -351,8 +327,9 @@ class ResourceVisualization:
|
||||
'robot_description': robot_description,
|
||||
'use_sim_time': False
|
||||
},
|
||||
# kinematics_dict
|
||||
],
|
||||
env=launch_env,
|
||||
env=dict(os.environ)
|
||||
)
|
||||
|
||||
|
||||
@@ -384,7 +361,7 @@ class ResourceVisualization:
|
||||
executable='move_group',
|
||||
output='screen',
|
||||
parameters=moveit_params,
|
||||
env=launch_env,
|
||||
env=dict(os.environ)
|
||||
)
|
||||
|
||||
|
||||
@@ -402,11 +379,13 @@ class ResourceVisualization:
|
||||
arguments=['-d', f"{str(self.mesh_path)}/view_robot.rviz"],
|
||||
output='screen',
|
||||
parameters=[
|
||||
{'robot_description_kinematics': kinematics_dict},
|
||||
{'robot_description_kinematics': kinematics_dict,
|
||||
},
|
||||
robot_description_planning,
|
||||
planning_pipelines,
|
||||
|
||||
],
|
||||
env=launch_env,
|
||||
env=dict(os.environ)
|
||||
)
|
||||
self.launch_description.add_action(rviz_node)
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,150 +0,0 @@
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from .prcxi import PRCXI9300ModuleSite
|
||||
|
||||
|
||||
class PRCXI9300FunctionalModule(PRCXI9300ModuleSite):
|
||||
"""
|
||||
PRCXI 9300 功能模块基类(加热/冷却/震荡/加热震荡/磁吸等)。
|
||||
|
||||
设计目标:
|
||||
- 作为一个可以在工作台上拖拽摆放的实体资源(继承自 PRCXI9300ModuleSite -> ItemizedCarrier)。
|
||||
- 顶面存在一个站点(site),可吸附标准板类资源(plate / tip_rack / tube_rack 等)。
|
||||
- 支持注入 `material_info` (UUID 等),并且在 serialize_state 时做安全过滤。
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name: str,
|
||||
size_x: float,
|
||||
size_y: float,
|
||||
size_z: float,
|
||||
module_type: Optional[str] = None,
|
||||
category: str = "module",
|
||||
model: Optional[str] = None,
|
||||
material_info: Optional[Dict[str, Any]] = None,
|
||||
**kwargs: Any,
|
||||
):
|
||||
super().__init__(
|
||||
name=name,
|
||||
size_x=size_x,
|
||||
size_y=size_y,
|
||||
size_z=size_z,
|
||||
material_info=material_info,
|
||||
model=model,
|
||||
category=category,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
# 记录模块类型(加热 / 冷却 / 震荡 / 加热震荡 / 磁吸)
|
||||
self.module_type = module_type or "generic"
|
||||
|
||||
# 与 PRCXI9300PlateAdapter 一致,使用 _unilabos_state 保存扩展信息
|
||||
if not hasattr(self, "_unilabos_state") or self._unilabos_state is None:
|
||||
self._unilabos_state = {}
|
||||
|
||||
# super().__init__ 已经在有 material_info 时写入 "Material",这里仅确保存在
|
||||
if material_info is not None and "Material" not in self._unilabos_state:
|
||||
self._unilabos_state["Material"] = material_info
|
||||
|
||||
# 额外标记 category 和模块类型,便于前端或上层逻辑区分
|
||||
self._unilabos_state.setdefault("category", category)
|
||||
self._unilabos_state["module_type"] = module_type
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# 具体功能模块定义
|
||||
# 这里的尺寸和 material_info 目前为占位参数,后续可根据实际测量/JSON 配置进行更新。
|
||||
# 顶面站点尺寸与模块外形一致,保证可以吸附标准 96 板/储液槽等。
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def PRCXI_Heating_Module(name: str) -> PRCXI9300FunctionalModule:
|
||||
"""加热模块(顶面可吸附标准板)。"""
|
||||
return PRCXI9300FunctionalModule(
|
||||
name=name,
|
||||
size_x=127.76,
|
||||
size_y=85.48,
|
||||
size_z=40.0,
|
||||
module_type="heating",
|
||||
model="PRCXI_Heating_Module",
|
||||
material_info={
|
||||
"uuid": "TODO-HEATING-MODULE-UUID",
|
||||
"Code": "HEAT-MOD",
|
||||
"Name": "PRCXI 加热模块",
|
||||
"SupplyType": 3,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def PRCXI_MetalCooling_Module(name: str) -> PRCXI9300FunctionalModule:
|
||||
"""金属冷却模块(顶面可吸附标准板)。"""
|
||||
return PRCXI9300FunctionalModule(
|
||||
name=name,
|
||||
size_x=127.76,
|
||||
size_y=85.48,
|
||||
size_z=40.0,
|
||||
module_type="metal_cooling",
|
||||
model="PRCXI_MetalCooling_Module",
|
||||
material_info={
|
||||
"uuid": "TODO-METAL-COOLING-MODULE-UUID",
|
||||
"Code": "METAL-COOL-MOD",
|
||||
"Name": "PRCXI 金属冷却模块",
|
||||
"SupplyType": 3,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def PRCXI_Shaking_Module(name: str) -> PRCXI9300FunctionalModule:
|
||||
"""震荡模块(顶面可吸附标准板)。"""
|
||||
return PRCXI9300FunctionalModule(
|
||||
name=name,
|
||||
size_x=127.76,
|
||||
size_y=85.48,
|
||||
size_z=50.0,
|
||||
module_type="shaking",
|
||||
model="PRCXI_Shaking_Module",
|
||||
material_info={
|
||||
"uuid": "TODO-SHAKING-MODULE-UUID",
|
||||
"Code": "SHAKE-MOD",
|
||||
"Name": "PRCXI 震荡模块",
|
||||
"SupplyType": 3,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def PRCXI_Heating_Shaking_Module(name: str) -> PRCXI9300FunctionalModule:
|
||||
"""加热震荡模块(顶面可吸附标准板)。"""
|
||||
return PRCXI9300FunctionalModule(
|
||||
name=name,
|
||||
size_x=127.76,
|
||||
size_y=85.48,
|
||||
size_z=55.0,
|
||||
module_type="heating_shaking",
|
||||
model="PRCXI_Heating_Shaking_Module",
|
||||
material_info={
|
||||
"uuid": "TODO-HEATING-SHAKING-MODULE-UUID",
|
||||
"Code": "HEAT-SHAKE-MOD",
|
||||
"Name": "PRCXI 加热震荡模块",
|
||||
"SupplyType": 3,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def PRCXI_Magnetic_Module(name: str) -> PRCXI9300FunctionalModule:
|
||||
"""磁吸模块(顶面可吸附标准板)。"""
|
||||
return PRCXI9300FunctionalModule(
|
||||
name=name,
|
||||
size_x=127.76,
|
||||
size_y=85.48,
|
||||
size_z=30.0,
|
||||
module_type="magnetic",
|
||||
model="PRCXI_Magnetic_Module",
|
||||
material_info={
|
||||
"uuid": "TODO-MAGNETIC-MODULE-UUID",
|
||||
"Code": "MAG-MOD",
|
||||
"Name": "PRCXI 磁吸模块",
|
||||
"SupplyType": 3,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -59,7 +59,6 @@ class UniLiquidHandlerRvizBackend(LiquidHandlerBackend):
|
||||
self.total_height = total_height
|
||||
self.joint_config = kwargs.get("joint_config", None)
|
||||
self.lh_device_id = kwargs.get("lh_device_id", "lh_joint_publisher")
|
||||
self.simulate_rviz = kwargs.get("simulate_rviz", False)
|
||||
if not rclpy.ok():
|
||||
rclpy.init()
|
||||
self.joint_state_publisher = None
|
||||
@@ -70,7 +69,7 @@ class UniLiquidHandlerRvizBackend(LiquidHandlerBackend):
|
||||
self.joint_state_publisher = LiquidHandlerJointPublisher(
|
||||
joint_config=self.joint_config,
|
||||
lh_device_id=self.lh_device_id,
|
||||
simulate_rviz=self.simulate_rviz)
|
||||
simulate_rviz=True)
|
||||
|
||||
# 启动ROS executor
|
||||
self.executor = rclpy.executors.MultiThreadedExecutor()
|
||||
|
||||
@@ -19,11 +19,10 @@ from rclpy.node import Node
|
||||
import re
|
||||
|
||||
class LiquidHandlerJointPublisher(BaseROS2DeviceNode):
|
||||
def __init__(self,resources_config:list, resource_tracker, rate=50, device_id:str = "lh_joint_publisher", registry_name: str = "lh_joint_publisher", **kwargs):
|
||||
def __init__(self,resources_config:list, resource_tracker, rate=50, device_id:str = "lh_joint_publisher", **kwargs):
|
||||
super().__init__(
|
||||
driver_instance=self,
|
||||
device_id=device_id,
|
||||
registry_name=registry_name,
|
||||
status_types={},
|
||||
action_value_mappings={},
|
||||
hardware_interface={},
|
||||
|
||||
@@ -42,7 +42,6 @@ class LiquidHandlerJointPublisher(Node):
|
||||
while self.resource_action is None:
|
||||
self.resource_action = self.check_tf_update_actions()
|
||||
time.sleep(1)
|
||||
self.get_logger().info(f'Waiting for TfUpdate server: {self.resource_action}')
|
||||
|
||||
self.resource_action_client = ActionClient(self, SendCmd, self.resource_action)
|
||||
while not self.resource_action_client.wait_for_server(timeout_sec=1.0):
|
||||
|
||||
@@ -1,88 +0,0 @@
|
||||
"""虚拟样品演示设备 — 用于前端 sample tracking 功能的极简 demo"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import random
|
||||
import time
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
|
||||
class VirtualSampleDemo:
|
||||
"""虚拟样品追踪演示设备,提供两种典型返回模式:
|
||||
- measure_samples: 等长输入输出 (前端按 index 自动对齐)
|
||||
- split_and_measure: 输出比输入长,附带 samples 列标注归属
|
||||
"""
|
||||
|
||||
def __init__(self, device_id: Optional[str] = None, config: Optional[Dict[str, Any]] = None, **kwargs):
|
||||
if device_id is None and "id" in kwargs:
|
||||
device_id = kwargs.pop("id")
|
||||
if config is None and "config" in kwargs:
|
||||
config = kwargs.pop("config")
|
||||
|
||||
self.device_id = device_id or "unknown_sample_demo"
|
||||
self.config = config or {}
|
||||
self.logger = logging.getLogger(f"VirtualSampleDemo.{self.device_id}")
|
||||
self.data: Dict[str, Any] = {"status": "Idle"}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Action 1: 等长输入输出,无 samples 列
|
||||
# ------------------------------------------------------------------
|
||||
async def measure_samples(self, concentrations: List[float]) -> Dict[str, Any]:
|
||||
"""模拟光度测量。absorbance = concentration * 0.05 + noise
|
||||
|
||||
入参和出参 list 长度相等,前端按 index 自动对齐。
|
||||
"""
|
||||
self.logger.info(f"measure_samples: concentrations={concentrations}")
|
||||
absorbance = [round(c * 0.05 + random.gauss(0, 0.005), 4) for c in concentrations]
|
||||
return {"concentrations": concentrations, "absorbance": absorbance}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Action 2: 输出比输入长,带 samples 列
|
||||
# ------------------------------------------------------------------
|
||||
async def split_and_measure(self, volumes: List[float], split_count: int = 3) -> Dict[str, Any]:
|
||||
"""将每个样品均分为 split_count 份后逐份测量。
|
||||
|
||||
返回的 list 长度 = len(volumes) * split_count,
|
||||
附带 samples 列标注每行属于第几个输入样品 (0-based index)。
|
||||
"""
|
||||
self.logger.info(f"split_and_measure: volumes={volumes}, split_count={split_count}")
|
||||
out_volumes: List[float] = []
|
||||
readings: List[float] = []
|
||||
samples: List[int] = []
|
||||
|
||||
for idx, vol in enumerate(volumes):
|
||||
split_vol = round(vol / split_count, 2)
|
||||
for _ in range(split_count):
|
||||
out_volumes.append(split_vol)
|
||||
readings.append(round(random.uniform(0.1, 1.0), 4))
|
||||
samples.append(idx)
|
||||
|
||||
return {"volumes": out_volumes, "readings": readings, "unilabos_samples": samples}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Action 3: 入参和出参都带 samples 列(不等长)
|
||||
# ------------------------------------------------------------------
|
||||
async def analyze_readings(self, readings: List[float], samples: List[int]) -> Dict[str, Any]:
|
||||
"""对 split_and_measure 的输出做二次分析。
|
||||
|
||||
入参 readings/samples 长度相同但 > 原始样品数,
|
||||
出参同样带 samples 列,长度与入参一致。
|
||||
"""
|
||||
self.logger.info(f"analyze_readings: readings={readings}, samples={samples}")
|
||||
scores: List[float] = []
|
||||
passed: List[bool] = []
|
||||
threshold = 0.4
|
||||
|
||||
for r in readings:
|
||||
score = round(r * 100 + random.gauss(0, 2), 2)
|
||||
scores.append(score)
|
||||
passed.append(r >= threshold)
|
||||
|
||||
return {"scores": scores, "passed": passed, "unilabos_samples": samples}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 状态属性
|
||||
# ------------------------------------------------------------------
|
||||
@property
|
||||
def status(self) -> str:
|
||||
return self.data.get("status", "Idle")
|
||||
@@ -31,14 +31,14 @@ class VirtualTransferPump:
|
||||
|
||||
# 从config或kwargs中获取参数,确保类型正确
|
||||
if config:
|
||||
self.max_volume = float(config.get("max_volume", 25.0))
|
||||
self.port = config.get("port", "VIRTUAL")
|
||||
self.max_volume = float(config.get('max_volume', 25.0))
|
||||
self.port = config.get('port', 'VIRTUAL')
|
||||
else:
|
||||
self.max_volume = float(kwargs.get("max_volume", 25.0))
|
||||
self.port = kwargs.get("port", "VIRTUAL")
|
||||
self.max_volume = float(kwargs.get('max_volume', 25.0))
|
||||
self.port = kwargs.get('port', 'VIRTUAL')
|
||||
|
||||
self._transfer_rate = float(kwargs.get("transfer_rate", 0))
|
||||
self.mode = kwargs.get("mode", VirtualPumpMode.Normal)
|
||||
self._transfer_rate = float(kwargs.get('transfer_rate', 0))
|
||||
self.mode = kwargs.get('mode', VirtualPumpMode.Normal)
|
||||
|
||||
# 状态变量 - 确保都是正确类型
|
||||
self._status = "Idle"
|
||||
@@ -54,9 +54,7 @@ class VirtualTransferPump:
|
||||
self.logger = logging.getLogger(f"VirtualTransferPump.{self.device_id}")
|
||||
|
||||
print(f"🚰 === 虚拟转移泵 {self.device_id} 已创建 === ✨")
|
||||
print(
|
||||
f"💨 快速模式: {'启用' if self._fast_mode else '禁用'} | 移动时间: {self._fast_move_time}s | 喷射时间: {self._fast_dispense_time}s"
|
||||
)
|
||||
print(f"💨 快速模式: {'启用' if self._fast_mode else '禁用'} | 移动时间: {self._fast_move_time}s | 喷射时间: {self._fast_dispense_time}s")
|
||||
print(f"📊 最大容量: {self.max_volume}mL | 端口: {self.port}")
|
||||
|
||||
def post_init(self, ros_node: BaseROS2DeviceNode):
|
||||
@@ -191,9 +189,7 @@ class VirtualTransferPump:
|
||||
operation_emoji = "📍"
|
||||
|
||||
self.logger.info(f"🎯 SET_POSITION: {operation_type} {operation_emoji}")
|
||||
self.logger.info(
|
||||
f" 📍 位置: {self._position:.2f}mL → {target_position:.2f}mL (移动 {volume_to_move:.2f}mL)"
|
||||
)
|
||||
self.logger.info(f" 📍 位置: {self._position:.2f}mL → {target_position:.2f}mL (移动 {volume_to_move:.2f}mL)")
|
||||
self.logger.info(f" 🌊 速度: {velocity:.2f} mL/s")
|
||||
self.logger.info(f" ⏰ 预计时间: {display_duration:.2f}s")
|
||||
|
||||
@@ -211,11 +207,7 @@ class VirtualTransferPump:
|
||||
for i in range(steps + 1):
|
||||
# 计算当前位置和进度
|
||||
progress = (i / steps) * 100 if steps > 0 else 100
|
||||
current_pos = (
|
||||
start_position + (target_position - start_position) * (i / steps)
|
||||
if steps > 0
|
||||
else target_position
|
||||
)
|
||||
current_pos = start_position + (target_position - start_position) * (i / steps) if steps > 0 else target_position
|
||||
|
||||
# 更新状态
|
||||
if i < steps:
|
||||
@@ -252,9 +244,7 @@ class VirtualTransferPump:
|
||||
|
||||
# 📊 最终状态日志
|
||||
if volume_to_move > 0.01:
|
||||
self.logger.info(
|
||||
f"🎉 SET_POSITION 完成! 📍 最终位置: {self._position:.2f}mL | 💧 当前体积: {self._current_volume:.2f}mL"
|
||||
)
|
||||
self.logger.info(f"🎉 SET_POSITION 完成! 📍 最终位置: {self._position:.2f}mL | 💧 当前体积: {self._current_volume:.2f}mL")
|
||||
|
||||
# 返回符合action定义的结果
|
||||
return {
|
||||
@@ -262,7 +252,7 @@ class VirtualTransferPump:
|
||||
"message": f"✅ 成功移动到位置 {self._position:.2f}mL ({operation_type})",
|
||||
"final_position": self._position,
|
||||
"final_volume": self._current_volume,
|
||||
"operation_type": operation_type,
|
||||
"operation_type": operation_type
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
@@ -272,7 +262,7 @@ class VirtualTransferPump:
|
||||
"success": False,
|
||||
"message": error_msg,
|
||||
"final_position": self._position,
|
||||
"final_volume": self._current_volume,
|
||||
"final_volume": self._current_volume
|
||||
}
|
||||
|
||||
# 其他泵操作方法
|
||||
@@ -398,9 +388,7 @@ class VirtualTransferPump:
|
||||
return self._current_volume >= (self.max_volume - 0.01) # 允许小量误差
|
||||
|
||||
def __str__(self):
|
||||
return (
|
||||
f"VirtualTransferPump({self.device_id}: {self._current_volume:.2f}/{self.max_volume} ml, {self._status})"
|
||||
)
|
||||
return f"VirtualTransferPump({self.device_id}: {self._current_volume:.2f}/{self.max_volume} ml, {self._status})"
|
||||
|
||||
def __repr__(self):
|
||||
return self.__str__()
|
||||
|
||||
@@ -1,874 +0,0 @@
|
||||
"""
|
||||
Virtual Workbench Device - 模拟工作台设备
|
||||
包含:
|
||||
- 1个机械臂 (每次操作3s, 独占锁)
|
||||
- 3个加热台 (每次加热10s, 可并行)
|
||||
|
||||
工作流程:
|
||||
1. A1-A5 物料同时启动, 竞争机械臂
|
||||
2. 机械臂将物料移动到空闲加热台
|
||||
3. 加热完成后, 机械臂将物料移动到C1-C5
|
||||
|
||||
注意: 调用来自线程池, 使用 threading.Lock 进行同步
|
||||
"""
|
||||
|
||||
import logging
|
||||
import time
|
||||
from typing import Dict, Any, Optional, List
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from threading import Lock, RLock
|
||||
|
||||
from typing_extensions import TypedDict
|
||||
|
||||
from unilabos.registry.decorators import (
|
||||
device, action, ActionInputHandle, ActionOutputHandle, DataSource, topic_config, not_action
|
||||
)
|
||||
from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode
|
||||
from unilabos.resources.resource_tracker import SampleUUIDsType, LabSample
|
||||
|
||||
|
||||
# ============ TypedDict 返回类型定义 ============
|
||||
|
||||
|
||||
class MoveToHeatingStationResult(TypedDict):
|
||||
"""move_to_heating_station 返回类型"""
|
||||
|
||||
success: bool
|
||||
station_id: int
|
||||
material_id: str
|
||||
material_number: int
|
||||
message: str
|
||||
unilabos_samples: List[LabSample]
|
||||
|
||||
|
||||
class StartHeatingResult(TypedDict):
|
||||
"""start_heating 返回类型"""
|
||||
|
||||
success: bool
|
||||
station_id: int
|
||||
material_id: str
|
||||
material_number: int
|
||||
message: str
|
||||
unilabos_samples: List[LabSample]
|
||||
|
||||
|
||||
class MoveToOutputResult(TypedDict):
|
||||
"""move_to_output 返回类型"""
|
||||
|
||||
success: bool
|
||||
station_id: int
|
||||
material_id: str
|
||||
output_position: str
|
||||
message: str
|
||||
unilabos_samples: List[LabSample]
|
||||
|
||||
|
||||
class PrepareMaterialsResult(TypedDict):
|
||||
"""prepare_materials 返回类型 - 批量准备物料"""
|
||||
|
||||
success: bool
|
||||
count: int
|
||||
material_1: int # 物料编号1
|
||||
material_2: int # 物料编号2
|
||||
material_3: int # 物料编号3
|
||||
material_4: int # 物料编号4
|
||||
material_5: int # 物料编号5
|
||||
message: str
|
||||
unilabos_samples: List[LabSample]
|
||||
|
||||
|
||||
# ============ 状态枚举 ============
|
||||
|
||||
|
||||
class HeatingStationState(Enum):
|
||||
"""加热台状态枚举"""
|
||||
|
||||
IDLE = "idle" # 空闲
|
||||
OCCUPIED = "occupied" # 已放置物料, 等待加热
|
||||
HEATING = "heating" # 加热中
|
||||
COMPLETED = "completed" # 加热完成, 等待取走
|
||||
|
||||
|
||||
class ArmState(Enum):
|
||||
"""机械臂状态枚举"""
|
||||
|
||||
IDLE = "idle" # 空闲
|
||||
BUSY = "busy" # 工作中
|
||||
|
||||
|
||||
@dataclass
|
||||
class HeatingStation:
|
||||
"""加热台数据结构"""
|
||||
|
||||
station_id: int
|
||||
state: HeatingStationState = HeatingStationState.IDLE
|
||||
current_material: Optional[str] = None # 当前物料 (如 "A1", "A2")
|
||||
material_number: Optional[int] = None # 物料编号 (1-5)
|
||||
heating_start_time: Optional[float] = None
|
||||
heating_progress: float = 0.0
|
||||
|
||||
|
||||
@device(
|
||||
id="virtual_workbench",
|
||||
category=["virtual_device"],
|
||||
description="Virtual Workbench with 1 robotic arm and 3 heating stations for concurrent material processing",
|
||||
)
|
||||
class VirtualWorkbench:
|
||||
"""
|
||||
Virtual Workbench Device - 虚拟工作台设备
|
||||
|
||||
模拟一个包含1个机械臂和3个加热台的工作站
|
||||
- 机械臂操作耗时3秒, 同一时间只能执行一个操作
|
||||
- 加热台加热耗时10秒, 3个加热台可并行工作
|
||||
|
||||
工作流:
|
||||
1. 物料A1-A5并发启动(线程池), 竞争机械臂使用权
|
||||
2. 获取机械臂后, 查找空闲加热台
|
||||
3. 机械臂将物料放入加热台, 开始加热
|
||||
4. 加热完成后, 机械臂将物料移动到目标位置Cn
|
||||
"""
|
||||
|
||||
_ros_node: BaseROS2DeviceNode
|
||||
|
||||
# 配置常量
|
||||
ARM_OPERATION_TIME: float = 2 # 机械臂操作时间(秒)
|
||||
HEATING_TIME: float = 60.0 # 加热时间(秒)
|
||||
NUM_HEATING_STATIONS: int = 3 # 加热台数量
|
||||
|
||||
def __init__(self, device_id: Optional[str] = None, config: Optional[Dict[str, Any]] = None, **kwargs):
|
||||
# 处理可能的不同调用方式
|
||||
if device_id is None and "id" in kwargs:
|
||||
device_id = kwargs.pop("id")
|
||||
if config is None and "config" in kwargs:
|
||||
config = kwargs.pop("config")
|
||||
|
||||
self.device_id = device_id or "virtual_workbench"
|
||||
self.config = config or {}
|
||||
|
||||
self.logger = logging.getLogger(f"VirtualWorkbench.{self.device_id}")
|
||||
self.data: Dict[str, Any] = {}
|
||||
|
||||
# 从config中获取可配置参数
|
||||
self.ARM_OPERATION_TIME = float(self.config.get("arm_operation_time", self.ARM_OPERATION_TIME))
|
||||
self.HEATING_TIME = float(self.config.get("heating_time", self.HEATING_TIME))
|
||||
self.NUM_HEATING_STATIONS = int(self.config.get("num_heating_stations", self.NUM_HEATING_STATIONS))
|
||||
|
||||
# 机械臂状态和锁
|
||||
self._arm_lock = Lock()
|
||||
self._arm_state = ArmState.IDLE
|
||||
self._arm_current_task: Optional[str] = None
|
||||
|
||||
# 加热台状态
|
||||
self._heating_stations: Dict[int, HeatingStation] = {
|
||||
i: HeatingStation(station_id=i) for i in range(1, self.NUM_HEATING_STATIONS + 1)
|
||||
}
|
||||
self._stations_lock = RLock()
|
||||
|
||||
# 任务追踪
|
||||
self._active_tasks: Dict[str, Dict[str, Any]] = {}
|
||||
self._tasks_lock = Lock()
|
||||
|
||||
# 处理其他kwargs参数
|
||||
skip_keys = {"arm_operation_time", "heating_time", "num_heating_stations"}
|
||||
for key, value in kwargs.items():
|
||||
if key not in skip_keys and not hasattr(self, key):
|
||||
setattr(self, key, value)
|
||||
|
||||
self.logger.info(f"=== 虚拟工作台 {self.device_id} 已创建 ===")
|
||||
self.logger.info(
|
||||
f"机械臂操作时间: {self.ARM_OPERATION_TIME}s | "
|
||||
f"加热时间: {self.HEATING_TIME}s | "
|
||||
f"加热台数量: {self.NUM_HEATING_STATIONS}"
|
||||
)
|
||||
|
||||
@not_action
|
||||
def post_init(self, ros_node: BaseROS2DeviceNode):
|
||||
"""ROS节点初始化后回调"""
|
||||
self._ros_node = ros_node
|
||||
|
||||
@not_action
|
||||
def initialize(self) -> bool:
|
||||
"""初始化虚拟工作台"""
|
||||
self.logger.info(f"初始化虚拟工作台 {self.device_id}")
|
||||
|
||||
with self._stations_lock:
|
||||
for station in self._heating_stations.values():
|
||||
station.state = HeatingStationState.IDLE
|
||||
station.current_material = None
|
||||
station.material_number = None
|
||||
station.heating_progress = 0.0
|
||||
|
||||
self.data.update(
|
||||
{
|
||||
"status": "Ready",
|
||||
"arm_state": ArmState.IDLE.value,
|
||||
"arm_current_task": None,
|
||||
"heating_stations": self._get_stations_status(),
|
||||
"active_tasks_count": 0,
|
||||
"message": "工作台就绪",
|
||||
}
|
||||
)
|
||||
|
||||
self.logger.info(f"工作台初始化完成: {self.NUM_HEATING_STATIONS}个加热台就绪")
|
||||
return True
|
||||
|
||||
@not_action
|
||||
def cleanup(self) -> bool:
|
||||
"""清理虚拟工作台"""
|
||||
self.logger.info(f"清理虚拟工作台 {self.device_id}")
|
||||
|
||||
self._arm_state = ArmState.IDLE
|
||||
self._arm_current_task = None
|
||||
|
||||
with self._stations_lock:
|
||||
self._heating_stations.clear()
|
||||
|
||||
with self._tasks_lock:
|
||||
self._active_tasks.clear()
|
||||
|
||||
self.data.update(
|
||||
{
|
||||
"status": "Offline",
|
||||
"arm_state": ArmState.IDLE.value,
|
||||
"heating_stations": {},
|
||||
"message": "工作台已关闭",
|
||||
}
|
||||
)
|
||||
return True
|
||||
|
||||
def _get_stations_status(self) -> Dict[int, Dict[str, Any]]:
|
||||
"""获取所有加热台状态"""
|
||||
with self._stations_lock:
|
||||
return {
|
||||
station_id: {
|
||||
"state": station.state.value,
|
||||
"current_material": station.current_material,
|
||||
"material_number": station.material_number,
|
||||
"heating_progress": station.heating_progress,
|
||||
}
|
||||
for station_id, station in self._heating_stations.items()
|
||||
}
|
||||
|
||||
def _update_data_status(self, message: Optional[str] = None):
|
||||
"""更新状态数据"""
|
||||
self.data.update(
|
||||
{
|
||||
"arm_state": self._arm_state.value,
|
||||
"arm_current_task": self._arm_current_task,
|
||||
"heating_stations": self._get_stations_status(),
|
||||
"active_tasks_count": len(self._active_tasks),
|
||||
}
|
||||
)
|
||||
if message:
|
||||
self.data["message"] = message
|
||||
|
||||
def _find_available_heating_station(self) -> Optional[int]:
|
||||
"""查找空闲的加热台"""
|
||||
with self._stations_lock:
|
||||
for station_id, station in self._heating_stations.items():
|
||||
if station.state == HeatingStationState.IDLE:
|
||||
return station_id
|
||||
return None
|
||||
|
||||
def _acquire_arm(self, task_description: str) -> bool:
|
||||
"""获取机械臂使用权(阻塞直到获取)"""
|
||||
self.logger.info(f"[{task_description}] 等待获取机械臂...")
|
||||
self._arm_lock.acquire()
|
||||
self._arm_state = ArmState.BUSY
|
||||
self._arm_current_task = task_description
|
||||
self._update_data_status(f"机械臂执行: {task_description}")
|
||||
self.logger.info(f"[{task_description}] 成功获取机械臂使用权")
|
||||
return True
|
||||
|
||||
def _release_arm(self):
|
||||
"""释放机械臂"""
|
||||
task = self._arm_current_task
|
||||
self._arm_state = ArmState.IDLE
|
||||
self._arm_current_task = None
|
||||
self._arm_lock.release()
|
||||
self._update_data_status(f"机械臂已释放 (完成: {task})")
|
||||
self.logger.info(f"机械臂已释放 (完成: {task})")
|
||||
|
||||
@action(
|
||||
auto_prefix=True,
|
||||
description="批量准备物料 - 虚拟起始节点, 生成A1-A5物料, 输出5个handle供后续节点使用",
|
||||
handles=[
|
||||
ActionOutputHandle(key="channel_1", data_type="workbench_material",
|
||||
label="实验1", data_key="material_1", data_source=DataSource.EXECUTOR),
|
||||
ActionOutputHandle(key="channel_2", data_type="workbench_material",
|
||||
label="实验2", data_key="material_2", data_source=DataSource.EXECUTOR),
|
||||
ActionOutputHandle(key="channel_3", data_type="workbench_material",
|
||||
label="实验3", data_key="material_3", data_source=DataSource.EXECUTOR),
|
||||
ActionOutputHandle(key="channel_4", data_type="workbench_material",
|
||||
label="实验4", data_key="material_4", data_source=DataSource.EXECUTOR),
|
||||
ActionOutputHandle(key="channel_5", data_type="workbench_material",
|
||||
label="实验5", data_key="material_5", data_source=DataSource.EXECUTOR),
|
||||
],
|
||||
)
|
||||
def prepare_materials(
|
||||
self,
|
||||
sample_uuids: SampleUUIDsType,
|
||||
count: int = 5,
|
||||
) -> PrepareMaterialsResult:
|
||||
"""
|
||||
批量准备物料 - 虚拟起始节点
|
||||
|
||||
作为工作流的起始节点, 生成指定数量的物料编号供后续节点使用。
|
||||
输出5个handle (material_1 ~ material_5), 分别对应实验1~5。
|
||||
"""
|
||||
materials = [i for i in range(1, count + 1)]
|
||||
|
||||
self.logger.info(
|
||||
f"[准备物料] 生成 {count} 个物料: A1-A{count} -> material_1~material_{count}"
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"count": count,
|
||||
"material_1": materials[0] if len(materials) > 0 else 0,
|
||||
"material_2": materials[1] if len(materials) > 1 else 0,
|
||||
"material_3": materials[2] if len(materials) > 2 else 0,
|
||||
"material_4": materials[3] if len(materials) > 3 else 0,
|
||||
"material_5": materials[4] if len(materials) > 4 else 0,
|
||||
"message": f"已准备 {count} 个物料: A1-A{count}",
|
||||
"unilabos_samples": [
|
||||
LabSample(
|
||||
sample_uuid=sample_uuid,
|
||||
oss_path="",
|
||||
extra={"material_uuid": content} if isinstance(content, str) else (content.serialize() if content else {}),
|
||||
)
|
||||
for sample_uuid, content in sample_uuids.items()
|
||||
],
|
||||
}
|
||||
|
||||
@action(
|
||||
auto_prefix=True,
|
||||
description="将物料从An位置移动到空闲加热台, 返回分配的加热台ID",
|
||||
handles=[
|
||||
ActionInputHandle(key="material_input", data_type="workbench_material",
|
||||
label="物料编号", data_key="material_number", data_source=DataSource.HANDLE),
|
||||
ActionOutputHandle(key="heating_station_output", data_type="workbench_station",
|
||||
label="加热台ID", data_key="station_id", data_source=DataSource.EXECUTOR),
|
||||
ActionOutputHandle(key="material_number_output", data_type="workbench_material",
|
||||
label="物料编号", data_key="material_number", data_source=DataSource.EXECUTOR),
|
||||
],
|
||||
)
|
||||
def move_to_heating_station(
|
||||
self,
|
||||
sample_uuids: SampleUUIDsType,
|
||||
material_number: int,
|
||||
) -> MoveToHeatingStationResult:
|
||||
"""
|
||||
将物料从An位置移动到加热台
|
||||
|
||||
多线程并发调用时, 会竞争机械臂使用权, 并自动查找空闲加热台
|
||||
"""
|
||||
material_id = f"A{material_number}"
|
||||
task_desc = f"移动{material_id}到加热台"
|
||||
self.logger.info(f"[任务] {task_desc} - 开始执行")
|
||||
|
||||
with self._tasks_lock:
|
||||
self._active_tasks[material_id] = {
|
||||
"status": "waiting_for_arm",
|
||||
"start_time": time.time(),
|
||||
}
|
||||
|
||||
try:
|
||||
with self._tasks_lock:
|
||||
self._active_tasks[material_id]["status"] = "waiting_for_arm"
|
||||
self._acquire_arm(task_desc)
|
||||
|
||||
with self._tasks_lock:
|
||||
self._active_tasks[material_id]["status"] = "finding_station"
|
||||
station_id = None
|
||||
|
||||
while station_id is None:
|
||||
station_id = self._find_available_heating_station()
|
||||
if station_id is None:
|
||||
self.logger.info(f"[{material_id}] 没有空闲加热台, 等待中...")
|
||||
self._release_arm()
|
||||
time.sleep(0.5)
|
||||
self._acquire_arm(task_desc)
|
||||
|
||||
with self._stations_lock:
|
||||
self._heating_stations[station_id].state = HeatingStationState.OCCUPIED
|
||||
self._heating_stations[station_id].current_material = material_id
|
||||
self._heating_stations[station_id].material_number = material_number
|
||||
|
||||
with self._tasks_lock:
|
||||
self._active_tasks[material_id]["status"] = "arm_moving"
|
||||
self._active_tasks[material_id]["assigned_station"] = station_id
|
||||
self.logger.info(f"[{material_id}] 机械臂正在移动到加热台{station_id}...")
|
||||
|
||||
time.sleep(self.ARM_OPERATION_TIME)
|
||||
|
||||
self._update_data_status(f"{material_id}已放入加热台{station_id}")
|
||||
self.logger.info(
|
||||
f"[{material_id}] 已放入加热台{station_id} (用时{self.ARM_OPERATION_TIME}s)"
|
||||
)
|
||||
|
||||
self._release_arm()
|
||||
|
||||
with self._tasks_lock:
|
||||
self._active_tasks[material_id]["status"] = "placed_on_station"
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"station_id": station_id,
|
||||
"material_id": material_id,
|
||||
"material_number": material_number,
|
||||
"message": f"{material_id}已成功移动到加热台{station_id}",
|
||||
"unilabos_samples": [
|
||||
LabSample(
|
||||
sample_uuid=sample_uuid,
|
||||
oss_path="",
|
||||
extra=(
|
||||
{"material_uuid": content}
|
||||
if isinstance(content, str) else (content.serialize() if content else {})
|
||||
),
|
||||
)
|
||||
for sample_uuid, content in sample_uuids.items()
|
||||
],
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"[{material_id}] 移动失败: {str(e)}")
|
||||
if self._arm_lock.locked():
|
||||
self._release_arm()
|
||||
return {
|
||||
"success": False,
|
||||
"station_id": -1,
|
||||
"material_id": material_id,
|
||||
"material_number": material_number,
|
||||
"message": f"移动失败: {str(e)}",
|
||||
"unilabos_samples": [
|
||||
LabSample(
|
||||
sample_uuid=sample_uuid,
|
||||
oss_path="",
|
||||
extra=(
|
||||
{"material_uuid": content}
|
||||
if isinstance(content, str) else (content.serialize() if content else {})
|
||||
),
|
||||
)
|
||||
for sample_uuid, content in sample_uuids.items()
|
||||
],
|
||||
}
|
||||
|
||||
@action(
|
||||
auto_prefix=True,
|
||||
always_free=True,
|
||||
description="启动指定加热台的加热程序",
|
||||
handles=[
|
||||
ActionInputHandle(key="station_id_input", data_type="workbench_station",
|
||||
label="加热台ID", data_key="station_id", data_source=DataSource.HANDLE),
|
||||
ActionInputHandle(key="material_number_input", data_type="workbench_material",
|
||||
label="物料编号", data_key="material_number", data_source=DataSource.HANDLE),
|
||||
ActionOutputHandle(key="heating_done_station", data_type="workbench_station",
|
||||
label="加热完成-加热台ID", data_key="station_id", data_source=DataSource.EXECUTOR),
|
||||
ActionOutputHandle(key="heating_done_material", data_type="workbench_material",
|
||||
label="加热完成-物料编号", data_key="material_number", data_source=DataSource.EXECUTOR),
|
||||
],
|
||||
)
|
||||
def start_heating(
|
||||
self,
|
||||
sample_uuids: SampleUUIDsType,
|
||||
station_id: int,
|
||||
material_number: int,
|
||||
) -> StartHeatingResult:
|
||||
"""
|
||||
启动指定加热台的加热程序
|
||||
"""
|
||||
self.logger.info(f"[加热台{station_id}] 开始加热")
|
||||
|
||||
if station_id not in self._heating_stations:
|
||||
return {
|
||||
"success": False,
|
||||
"station_id": station_id,
|
||||
"material_id": "",
|
||||
"material_number": material_number,
|
||||
"message": f"无效的加热台ID: {station_id}",
|
||||
"unilabos_samples": [
|
||||
LabSample(
|
||||
sample_uuid=sample_uuid,
|
||||
oss_path="",
|
||||
extra=(
|
||||
{"material_uuid": content}
|
||||
if isinstance(content, str) else (content.serialize() if content else {})
|
||||
),
|
||||
)
|
||||
for sample_uuid, content in sample_uuids.items()
|
||||
],
|
||||
}
|
||||
|
||||
with self._stations_lock:
|
||||
station = self._heating_stations[station_id]
|
||||
|
||||
if station.current_material is None:
|
||||
return {
|
||||
"success": False,
|
||||
"station_id": station_id,
|
||||
"material_id": "",
|
||||
"material_number": material_number,
|
||||
"message": f"加热台{station_id}上没有物料",
|
||||
"unilabos_samples": [
|
||||
LabSample(
|
||||
sample_uuid=sample_uuid,
|
||||
oss_path="",
|
||||
extra=(
|
||||
{"material_uuid": content}
|
||||
if isinstance(content, str) else (content.serialize() if content else {})
|
||||
),
|
||||
)
|
||||
for sample_uuid, content in sample_uuids.items()
|
||||
],
|
||||
}
|
||||
|
||||
if station.state == HeatingStationState.HEATING:
|
||||
return {
|
||||
"success": False,
|
||||
"station_id": station_id,
|
||||
"material_id": station.current_material,
|
||||
"material_number": material_number,
|
||||
"message": f"加热台{station_id}已经在加热中",
|
||||
"unilabos_samples": [
|
||||
LabSample(
|
||||
sample_uuid=sample_uuid,
|
||||
oss_path="",
|
||||
extra=(
|
||||
{"material_uuid": content}
|
||||
if isinstance(content, str) else (content.serialize() if content else {})
|
||||
),
|
||||
)
|
||||
for sample_uuid, content in sample_uuids.items()
|
||||
],
|
||||
}
|
||||
|
||||
material_id = station.current_material
|
||||
|
||||
station.state = HeatingStationState.HEATING
|
||||
station.heating_start_time = time.time()
|
||||
station.heating_progress = 0.0
|
||||
|
||||
with self._tasks_lock:
|
||||
if material_id in self._active_tasks:
|
||||
self._active_tasks[material_id]["status"] = "heating"
|
||||
|
||||
self._update_data_status(f"加热台{station_id}开始加热{material_id}")
|
||||
|
||||
with self._stations_lock:
|
||||
heating_list = [
|
||||
f"加热台{sid}:{s.current_material}"
|
||||
for sid, s in self._heating_stations.items()
|
||||
if s.state == HeatingStationState.HEATING and s.current_material
|
||||
]
|
||||
self.logger.info(f"[并行加热] 当前同时加热中: {', '.join(heating_list)}")
|
||||
|
||||
start_time = time.time()
|
||||
last_countdown_log = start_time
|
||||
while True:
|
||||
elapsed = time.time() - start_time
|
||||
remaining = max(0.0, self.HEATING_TIME - elapsed)
|
||||
progress = min(100.0, (elapsed / self.HEATING_TIME) * 100)
|
||||
|
||||
with self._stations_lock:
|
||||
self._heating_stations[station_id].heating_progress = progress
|
||||
|
||||
self._update_data_status(f"加热台{station_id}加热中: {progress:.1f}%")
|
||||
|
||||
if time.time() - last_countdown_log >= 5.0:
|
||||
self.logger.info(f"[加热台{station_id}] {material_id} 剩余 {remaining:.1f}s")
|
||||
last_countdown_log = time.time()
|
||||
|
||||
if elapsed >= self.HEATING_TIME:
|
||||
break
|
||||
|
||||
time.sleep(1.0)
|
||||
|
||||
with self._stations_lock:
|
||||
self._heating_stations[station_id].state = HeatingStationState.COMPLETED
|
||||
self._heating_stations[station_id].heating_progress = 100.0
|
||||
|
||||
with self._tasks_lock:
|
||||
if material_id in self._active_tasks:
|
||||
self._active_tasks[material_id]["status"] = "heating_completed"
|
||||
|
||||
self._update_data_status(f"加热台{station_id}加热完成")
|
||||
self.logger.info(f"[加热台{station_id}] {material_id}加热完成 (用时{self.HEATING_TIME}s)")
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"station_id": station_id,
|
||||
"material_id": material_id,
|
||||
"material_number": material_number,
|
||||
"message": f"加热台{station_id}加热完成",
|
||||
"unilabos_samples": [
|
||||
LabSample(
|
||||
sample_uuid=sample_uuid,
|
||||
oss_path="",
|
||||
extra=(
|
||||
{"material_uuid": content}
|
||||
if isinstance(content, str) else (content.serialize() if content else {})
|
||||
),
|
||||
)
|
||||
for sample_uuid, content in sample_uuids.items()
|
||||
],
|
||||
}
|
||||
|
||||
@action(
|
||||
auto_prefix=True,
|
||||
description="将物料从加热台移动到输出位置Cn",
|
||||
handles=[
|
||||
ActionInputHandle(key="output_station_input", data_type="workbench_station",
|
||||
label="加热台ID", data_key="station_id", data_source=DataSource.HANDLE),
|
||||
ActionInputHandle(key="output_material_input", data_type="workbench_material",
|
||||
label="物料编号", data_key="material_number", data_source=DataSource.HANDLE),
|
||||
],
|
||||
)
|
||||
def move_to_output(
|
||||
self,
|
||||
sample_uuids: SampleUUIDsType,
|
||||
station_id: int,
|
||||
material_number: int,
|
||||
) -> MoveToOutputResult:
|
||||
"""
|
||||
将物料从加热台移动到输出位置Cn
|
||||
"""
|
||||
output_number = material_number
|
||||
|
||||
if station_id not in self._heating_stations:
|
||||
return {
|
||||
"success": False,
|
||||
"station_id": station_id,
|
||||
"material_id": "",
|
||||
"output_position": f"C{output_number}",
|
||||
"message": f"无效的加热台ID: {station_id}",
|
||||
"unilabos_samples": [
|
||||
LabSample(
|
||||
sample_uuid=sample_uuid,
|
||||
oss_path="",
|
||||
extra=(
|
||||
{"material_uuid": content}
|
||||
if isinstance(content, str) else (content.serialize() if content else {})
|
||||
),
|
||||
)
|
||||
for sample_uuid, content in sample_uuids.items()
|
||||
],
|
||||
}
|
||||
|
||||
with self._stations_lock:
|
||||
station = self._heating_stations[station_id]
|
||||
material_id = station.current_material
|
||||
|
||||
if material_id is None:
|
||||
return {
|
||||
"success": False,
|
||||
"station_id": station_id,
|
||||
"material_id": "",
|
||||
"output_position": f"C{output_number}",
|
||||
"message": f"加热台{station_id}上没有物料",
|
||||
"unilabos_samples": [
|
||||
LabSample(
|
||||
sample_uuid=sample_uuid,
|
||||
oss_path="",
|
||||
extra=(
|
||||
{"material_uuid": content}
|
||||
if isinstance(content, str) else (content.serialize() if content else {})
|
||||
),
|
||||
)
|
||||
for sample_uuid, content in sample_uuids.items()
|
||||
],
|
||||
}
|
||||
|
||||
if station.state != HeatingStationState.COMPLETED:
|
||||
return {
|
||||
"success": False,
|
||||
"station_id": station_id,
|
||||
"material_id": material_id,
|
||||
"output_position": f"C{output_number}",
|
||||
"message": f"加热台{station_id}尚未完成加热 (当前状态: {station.state.value})",
|
||||
"unilabos_samples": [
|
||||
LabSample(
|
||||
sample_uuid=sample_uuid,
|
||||
oss_path="",
|
||||
extra=(
|
||||
{"material_uuid": content}
|
||||
if isinstance(content, str) else (content.serialize() if content else {})
|
||||
),
|
||||
)
|
||||
for sample_uuid, content in sample_uuids.items()
|
||||
],
|
||||
}
|
||||
|
||||
output_position = f"C{output_number}"
|
||||
task_desc = f"从加热台{station_id}移动{material_id}到{output_position}"
|
||||
self.logger.info(f"[任务] {task_desc}")
|
||||
|
||||
try:
|
||||
with self._tasks_lock:
|
||||
if material_id in self._active_tasks:
|
||||
self._active_tasks[material_id]["status"] = "waiting_for_arm_output"
|
||||
|
||||
self._acquire_arm(task_desc)
|
||||
|
||||
with self._tasks_lock:
|
||||
if material_id in self._active_tasks:
|
||||
self._active_tasks[material_id]["status"] = "arm_moving_to_output"
|
||||
|
||||
self.logger.info(
|
||||
f"[{material_id}] 机械臂正在从加热台{station_id}取出并移动到{output_position}..."
|
||||
)
|
||||
time.sleep(self.ARM_OPERATION_TIME)
|
||||
|
||||
with self._stations_lock:
|
||||
self._heating_stations[station_id].state = HeatingStationState.IDLE
|
||||
self._heating_stations[station_id].current_material = None
|
||||
self._heating_stations[station_id].material_number = None
|
||||
self._heating_stations[station_id].heating_progress = 0.0
|
||||
self._heating_stations[station_id].heating_start_time = None
|
||||
|
||||
self._release_arm()
|
||||
|
||||
with self._tasks_lock:
|
||||
if material_id in self._active_tasks:
|
||||
self._active_tasks[material_id]["status"] = "completed"
|
||||
self._active_tasks[material_id]["end_time"] = time.time()
|
||||
|
||||
self._update_data_status(f"{material_id}已移动到{output_position}")
|
||||
self.logger.info(
|
||||
f"[{material_id}] 已成功移动到{output_position} (用时{self.ARM_OPERATION_TIME}s)"
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"station_id": station_id,
|
||||
"material_id": material_id,
|
||||
"output_position": output_position,
|
||||
"message": f"{material_id}已成功移动到{output_position}",
|
||||
"unilabos_samples": [
|
||||
LabSample(
|
||||
sample_uuid=sample_uuid,
|
||||
oss_path="",
|
||||
extra=(
|
||||
{"material_uuid": content}
|
||||
if isinstance(content, str)
|
||||
else (content.serialize() if content is not None else {})
|
||||
),
|
||||
)
|
||||
for sample_uuid, content in sample_uuids.items()
|
||||
],
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"移动到输出位置失败: {str(e)}")
|
||||
if self._arm_lock.locked():
|
||||
self._release_arm()
|
||||
return {
|
||||
"success": False,
|
||||
"station_id": station_id,
|
||||
"material_id": "",
|
||||
"output_position": output_position,
|
||||
"message": f"移动失败: {str(e)}",
|
||||
"unilabos_samples": [
|
||||
LabSample(
|
||||
sample_uuid=sample_uuid,
|
||||
oss_path="",
|
||||
extra=(
|
||||
{"material_uuid": content}
|
||||
if isinstance(content, str) else (content.serialize() if content else {})
|
||||
),
|
||||
)
|
||||
for sample_uuid, content in sample_uuids.items()
|
||||
],
|
||||
}
|
||||
|
||||
# ============ 状态属性 ============
|
||||
|
||||
@property
|
||||
@topic_config()
|
||||
def status(self) -> str:
|
||||
return self.data.get("status", "Unknown")
|
||||
|
||||
@property
|
||||
@topic_config()
|
||||
def arm_state(self) -> str:
|
||||
return self._arm_state.value
|
||||
|
||||
@property
|
||||
@topic_config()
|
||||
def arm_current_task(self) -> str:
|
||||
return self._arm_current_task or ""
|
||||
|
||||
@property
|
||||
@topic_config()
|
||||
def heating_station_1_state(self) -> str:
|
||||
with self._stations_lock:
|
||||
station = self._heating_stations.get(1)
|
||||
return station.state.value if station else "unknown"
|
||||
|
||||
@property
|
||||
@topic_config()
|
||||
def heating_station_1_material(self) -> str:
|
||||
with self._stations_lock:
|
||||
station = self._heating_stations.get(1)
|
||||
return station.current_material or "" if station else ""
|
||||
|
||||
@property
|
||||
@topic_config()
|
||||
def heating_station_1_progress(self) -> float:
|
||||
with self._stations_lock:
|
||||
station = self._heating_stations.get(1)
|
||||
return station.heating_progress if station else 0.0
|
||||
|
||||
@property
|
||||
@topic_config()
|
||||
def heating_station_2_state(self) -> str:
|
||||
with self._stations_lock:
|
||||
station = self._heating_stations.get(2)
|
||||
return station.state.value if station else "unknown"
|
||||
|
||||
@property
|
||||
@topic_config()
|
||||
def heating_station_2_material(self) -> str:
|
||||
with self._stations_lock:
|
||||
station = self._heating_stations.get(2)
|
||||
return station.current_material or "" if station else ""
|
||||
|
||||
@property
|
||||
@topic_config()
|
||||
def heating_station_2_progress(self) -> float:
|
||||
with self._stations_lock:
|
||||
station = self._heating_stations.get(2)
|
||||
return station.heating_progress if station else 0.0
|
||||
|
||||
@property
|
||||
@topic_config()
|
||||
def heating_station_3_state(self) -> str:
|
||||
with self._stations_lock:
|
||||
station = self._heating_stations.get(3)
|
||||
return station.state.value if station else "unknown"
|
||||
|
||||
@property
|
||||
@topic_config()
|
||||
def heating_station_3_material(self) -> str:
|
||||
with self._stations_lock:
|
||||
station = self._heating_stations.get(3)
|
||||
return station.current_material or "" if station else ""
|
||||
|
||||
@property
|
||||
@topic_config()
|
||||
def heating_station_3_progress(self) -> float:
|
||||
with self._stations_lock:
|
||||
station = self._heating_stations.get(3)
|
||||
return station.heating_progress if station else 0.0
|
||||
|
||||
@property
|
||||
@topic_config()
|
||||
def active_tasks_count(self) -> int:
|
||||
with self._tasks_lock:
|
||||
return len(self._active_tasks)
|
||||
|
||||
@property
|
||||
@topic_config()
|
||||
def message(self) -> str:
|
||||
return self.data.get("message", "")
|
||||
@@ -1 +0,0 @@
|
||||
# PRCXI 耗材管理 Web 应用
|
||||
@@ -1,4 +0,0 @@
|
||||
"""启动入口: python -m unilabos.labware_manager"""
|
||||
from unilabos.labware_manager.app import main
|
||||
|
||||
main()
|
||||
@@ -1,196 +0,0 @@
|
||||
"""FastAPI 应用 + CRUD API + 启动入口。
|
||||
|
||||
用法: python -m unilabos.labware_manager.app
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import List, Optional
|
||||
|
||||
from fastapi import FastAPI, HTTPException, Query, Request
|
||||
from fastapi.responses import HTMLResponse, JSONResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
from unilabos.labware_manager.models import LabwareDB, LabwareItem
|
||||
|
||||
_HERE = Path(__file__).resolve().parent
|
||||
_DB_PATH = _HERE / "labware_db.json"
|
||||
|
||||
app = FastAPI(title="PRCXI 耗材管理", version="1.0")
|
||||
|
||||
# 静态文件 + 模板
|
||||
app.mount("/static", StaticFiles(directory=str(_HERE / "static")), name="static")
|
||||
templates = Jinja2Templates(directory=str(_HERE / "templates"))
|
||||
|
||||
|
||||
# ---------- DB 读写 ----------
|
||||
|
||||
def _load_db() -> LabwareDB:
|
||||
if not _DB_PATH.exists():
|
||||
return LabwareDB()
|
||||
with open(_DB_PATH, "r", encoding="utf-8") as f:
|
||||
return LabwareDB(**json.load(f))
|
||||
|
||||
|
||||
def _save_db(db: LabwareDB) -> None:
|
||||
with open(_DB_PATH, "w", encoding="utf-8") as f:
|
||||
json.dump(db.model_dump(), f, ensure_ascii=False, indent=2)
|
||||
|
||||
|
||||
# ---------- 页面路由 ----------
|
||||
|
||||
@app.get("/", response_class=HTMLResponse)
|
||||
async def index_page(request: Request):
|
||||
db = _load_db()
|
||||
# 按 type 分组
|
||||
groups = {}
|
||||
for item in db.items:
|
||||
groups.setdefault(item.type, []).append(item)
|
||||
return templates.TemplateResponse("index.html", {
|
||||
"request": request,
|
||||
"groups": groups,
|
||||
"total": len(db.items),
|
||||
})
|
||||
|
||||
|
||||
@app.get("/labware/new", response_class=HTMLResponse)
|
||||
async def new_page(request: Request, type: str = "plate"):
|
||||
return templates.TemplateResponse("edit.html", {
|
||||
"request": request,
|
||||
"item": None,
|
||||
"labware_type": type,
|
||||
"is_new": True,
|
||||
})
|
||||
|
||||
|
||||
@app.get("/labware/{item_id}", response_class=HTMLResponse)
|
||||
async def detail_page(request: Request, item_id: str):
|
||||
db = _load_db()
|
||||
item = _find_item(db, item_id)
|
||||
if not item:
|
||||
raise HTTPException(404, "耗材不存在")
|
||||
return templates.TemplateResponse("detail.html", {
|
||||
"request": request,
|
||||
"item": item,
|
||||
})
|
||||
|
||||
|
||||
@app.get("/labware/{item_id}/edit", response_class=HTMLResponse)
|
||||
async def edit_page(request: Request, item_id: str):
|
||||
db = _load_db()
|
||||
item = _find_item(db, item_id)
|
||||
if not item:
|
||||
raise HTTPException(404, "耗材不存在")
|
||||
return templates.TemplateResponse("edit.html", {
|
||||
"request": request,
|
||||
"item": item,
|
||||
"labware_type": item.type,
|
||||
"is_new": False,
|
||||
})
|
||||
|
||||
|
||||
# ---------- API 端点 ----------
|
||||
|
||||
@app.get("/api/labware")
|
||||
async def api_list_labware():
|
||||
db = _load_db()
|
||||
return {"items": [item.model_dump() for item in db.items]}
|
||||
|
||||
|
||||
@app.post("/api/labware")
|
||||
async def api_create_labware(request: Request):
|
||||
data = await request.json()
|
||||
db = _load_db()
|
||||
item = LabwareItem(**data)
|
||||
# 确保 id 唯一
|
||||
existing_ids = {it.id for it in db.items}
|
||||
while item.id in existing_ids:
|
||||
import uuid
|
||||
item.id = uuid.uuid4().hex[:8]
|
||||
db.items.append(item)
|
||||
_save_db(db)
|
||||
return {"status": "ok", "id": item.id}
|
||||
|
||||
|
||||
@app.put("/api/labware/{item_id}")
|
||||
async def api_update_labware(item_id: str, request: Request):
|
||||
data = await request.json()
|
||||
db = _load_db()
|
||||
for i, it in enumerate(db.items):
|
||||
if it.id == item_id or it.function_name == item_id:
|
||||
updated = LabwareItem(**{**it.model_dump(), **data, "id": it.id})
|
||||
db.items[i] = updated
|
||||
_save_db(db)
|
||||
return {"status": "ok", "id": it.id}
|
||||
raise HTTPException(404, "耗材不存在")
|
||||
|
||||
|
||||
@app.delete("/api/labware/{item_id}")
|
||||
async def api_delete_labware(item_id: str):
|
||||
db = _load_db()
|
||||
original_len = len(db.items)
|
||||
db.items = [it for it in db.items if it.id != item_id and it.function_name != item_id]
|
||||
if len(db.items) == original_len:
|
||||
raise HTTPException(404, "耗材不存在")
|
||||
_save_db(db)
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@app.post("/api/generate-code")
|
||||
async def api_generate_code(request: Request):
|
||||
body = await request.json() if await request.body() else {}
|
||||
test_mode = body.get("test_mode", True)
|
||||
db = _load_db()
|
||||
if not db.items:
|
||||
raise HTTPException(400, "数据库为空,请先导入")
|
||||
|
||||
from unilabos.labware_manager.codegen import generate_code
|
||||
from unilabos.labware_manager.yaml_gen import generate_yaml
|
||||
|
||||
py_path = generate_code(db, test_mode=test_mode)
|
||||
yaml_paths = generate_yaml(db, test_mode=test_mode)
|
||||
|
||||
return {
|
||||
"status": "ok",
|
||||
"python_file": str(py_path),
|
||||
"yaml_files": [str(p) for p in yaml_paths],
|
||||
"test_mode": test_mode,
|
||||
}
|
||||
|
||||
|
||||
@app.post("/api/import-from-code")
|
||||
async def api_import_from_code():
|
||||
from unilabos.labware_manager.importer import import_from_code, save_db
|
||||
db = import_from_code()
|
||||
save_db(db)
|
||||
return {
|
||||
"status": "ok",
|
||||
"count": len(db.items),
|
||||
"items": [{"function_name": it.function_name, "type": it.type} for it in db.items],
|
||||
}
|
||||
|
||||
|
||||
# ---------- 辅助函数 ----------
|
||||
|
||||
def _find_item(db: LabwareDB, item_id: str) -> Optional[LabwareItem]:
|
||||
for item in db.items:
|
||||
if item.id == item_id or item.function_name == item_id:
|
||||
return item
|
||||
return None
|
||||
|
||||
|
||||
# ---------- 启动入口 ----------
|
||||
|
||||
def main():
|
||||
import uvicorn
|
||||
port = int(os.environ.get("LABWARE_PORT", "8010"))
|
||||
print(f"PRCXI 耗材管理 → http://localhost:{port}")
|
||||
uvicorn.run(app, host="0.0.0.0", port=port)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,451 +0,0 @@
|
||||
"""JSON → prcxi_labware.py 代码生成。
|
||||
|
||||
读取 labware_db.json,输出完整的 prcxi_labware.py(或 prcxi_labware_test.py)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from typing import List, Optional
|
||||
|
||||
from unilabos.labware_manager.models import LabwareDB, LabwareItem
|
||||
|
||||
_TARGET_DIR = Path(__file__).resolve().parents[1] / "devices" / "liquid_handling" / "prcxi"
|
||||
|
||||
# ---------- 固定头部 ----------
|
||||
_HEADER = '''\
|
||||
from typing import Any, Callable, Dict, List, Optional, Tuple
|
||||
from pylabrobot.resources import Tube, Coordinate
|
||||
from pylabrobot.resources.well import Well, WellBottomType, CrossSectionType
|
||||
from pylabrobot.resources.tip import Tip, TipCreator
|
||||
from pylabrobot.resources.tip_rack import TipRack, TipSpot
|
||||
from pylabrobot.resources.utils import create_ordered_items_2d
|
||||
from pylabrobot.resources.height_volume_functions import (
|
||||
compute_height_from_volume_rectangle,
|
||||
compute_volume_from_height_rectangle,
|
||||
)
|
||||
|
||||
from .prcxi import PRCXI9300Plate, PRCXI9300TipRack, PRCXI9300Trash, PRCXI9300TubeRack, PRCXI9300PlateAdapter
|
||||
|
||||
def _make_tip_helper(volume: float, length: float, depth: float) -> Tip:
|
||||
"""
|
||||
PLR 的 Tip 类参数名为: maximal_volume, total_tip_length, fitting_depth
|
||||
"""
|
||||
return Tip(
|
||||
has_filter=False, # 默认无滤芯
|
||||
maximal_volume=volume,
|
||||
total_tip_length=length,
|
||||
fitting_depth=depth
|
||||
)
|
||||
|
||||
'''
|
||||
|
||||
|
||||
def _gen_plate(item: LabwareItem) -> str:
|
||||
"""生成 Plate 类型的工厂函数代码。"""
|
||||
lines = []
|
||||
fn = item.function_name
|
||||
doc = item.docstring or f"Code: {item.material_info.Code}"
|
||||
|
||||
has_vf = item.volume_functions is not None
|
||||
|
||||
if has_vf:
|
||||
# 有 volume_functions 时需要 well_kwargs 方式
|
||||
vf = item.volume_functions
|
||||
well = item.well
|
||||
grid = item.grid
|
||||
|
||||
lines.append(f'def {fn}(name: str) -> PRCXI9300Plate:')
|
||||
lines.append(f' """')
|
||||
for dl in doc.split('\n'):
|
||||
lines.append(f' {dl}')
|
||||
lines.append(f' """')
|
||||
|
||||
# 计算 well_size 变量
|
||||
lines.append(f' well_size_x = {well.size_x}')
|
||||
lines.append(f' well_size_y = {well.size_y}')
|
||||
|
||||
lines.append(f' well_kwargs = {{')
|
||||
lines.append(f' "size_x": well_size_x,')
|
||||
lines.append(f' "size_y": well_size_y,')
|
||||
lines.append(f' "size_z": {well.size_z},')
|
||||
lines.append(f' "bottom_type": WellBottomType.{well.bottom_type},')
|
||||
if well.cross_section_type and well.cross_section_type != "CIRCLE":
|
||||
lines.append(f' "cross_section_type": CrossSectionType.{well.cross_section_type},')
|
||||
lines.append(f' "compute_height_from_volume": lambda liquid_volume: compute_height_from_volume_rectangle(')
|
||||
lines.append(f' liquid_volume=liquid_volume, well_length=well_size_x, well_width=well_size_y')
|
||||
lines.append(f' ),')
|
||||
lines.append(f' "compute_volume_from_height": lambda liquid_height: compute_volume_from_height_rectangle(')
|
||||
lines.append(f' liquid_height=liquid_height, well_length=well_size_x, well_width=well_size_y')
|
||||
lines.append(f' ),')
|
||||
if well.material_z_thickness is not None:
|
||||
lines.append(f' "material_z_thickness": {well.material_z_thickness},')
|
||||
lines.append(f' }}')
|
||||
lines.append(f'')
|
||||
lines.append(f' return PRCXI9300Plate(')
|
||||
lines.append(f' name=name,')
|
||||
lines.append(f' size_x={item.size_x},')
|
||||
lines.append(f' size_y={item.size_y},')
|
||||
lines.append(f' size_z={item.size_z},')
|
||||
lines.append(f' lid=None,')
|
||||
lines.append(f' model="{item.model}",')
|
||||
lines.append(f' category="plate",')
|
||||
lines.append(f' material_info={_fmt_dict(item.material_info.model_dump(exclude_none=True))},')
|
||||
lines.append(f' ordered_items=create_ordered_items_2d(')
|
||||
lines.append(f' Well,')
|
||||
lines.append(f' num_items_x={grid.num_items_x},')
|
||||
lines.append(f' num_items_y={grid.num_items_y},')
|
||||
lines.append(f' dx={grid.dx},')
|
||||
lines.append(f' dy={grid.dy},')
|
||||
lines.append(f' dz={grid.dz},')
|
||||
lines.append(f' item_dx={grid.item_dx},')
|
||||
lines.append(f' item_dy={grid.item_dy},')
|
||||
lines.append(f' **well_kwargs,')
|
||||
lines.append(f' ),')
|
||||
lines.append(f' )')
|
||||
else:
|
||||
# 普通 plate
|
||||
well = item.well
|
||||
grid = item.grid
|
||||
|
||||
lines.append(f'def {fn}(name: str) -> PRCXI9300Plate:')
|
||||
lines.append(f' """')
|
||||
for dl in doc.split('\n'):
|
||||
lines.append(f' {dl}')
|
||||
lines.append(f' """')
|
||||
lines.append(f' return PRCXI9300Plate(')
|
||||
lines.append(f' name=name,')
|
||||
lines.append(f' size_x={item.size_x},')
|
||||
lines.append(f' size_y={item.size_y},')
|
||||
lines.append(f' size_z={item.size_z},')
|
||||
if item.plate_type:
|
||||
lines.append(f' plate_type="{item.plate_type}",')
|
||||
lines.append(f' model="{item.model}",')
|
||||
lines.append(f' category="plate",')
|
||||
lines.append(f' material_info={_fmt_dict(item.material_info.model_dump(exclude_none=True))},')
|
||||
if grid and well:
|
||||
lines.append(f' ordered_items=create_ordered_items_2d(')
|
||||
lines.append(f' Well,')
|
||||
lines.append(f' num_items_x={grid.num_items_x},')
|
||||
lines.append(f' num_items_y={grid.num_items_y},')
|
||||
lines.append(f' dx={grid.dx},')
|
||||
lines.append(f' dy={grid.dy},')
|
||||
lines.append(f' dz={grid.dz},')
|
||||
lines.append(f' item_dx={grid.item_dx},')
|
||||
lines.append(f' item_dy={grid.item_dy},')
|
||||
lines.append(f' size_x={well.size_x},')
|
||||
lines.append(f' size_y={well.size_y},')
|
||||
lines.append(f' size_z={well.size_z},')
|
||||
if well.max_volume is not None:
|
||||
lines.append(f' max_volume={well.max_volume},')
|
||||
if well.material_z_thickness is not None:
|
||||
lines.append(f' material_z_thickness={well.material_z_thickness},')
|
||||
if well.bottom_type and well.bottom_type != "FLAT":
|
||||
lines.append(f' bottom_type=WellBottomType.{well.bottom_type},')
|
||||
if well.cross_section_type:
|
||||
lines.append(f' cross_section_type=CrossSectionType.{well.cross_section_type},')
|
||||
lines.append(f' ),')
|
||||
lines.append(f' )')
|
||||
|
||||
return '\n'.join(lines)
|
||||
|
||||
|
||||
def _gen_tip_rack(item: LabwareItem) -> str:
|
||||
"""生成 TipRack 工厂函数代码。"""
|
||||
lines = []
|
||||
fn = item.function_name
|
||||
doc = item.docstring or f"Code: {item.material_info.Code}"
|
||||
grid = item.grid
|
||||
tip = item.tip
|
||||
|
||||
lines.append(f'def {fn}(name: str) -> PRCXI9300TipRack:')
|
||||
lines.append(f' """')
|
||||
for dl in doc.split('\n'):
|
||||
lines.append(f' {dl}')
|
||||
lines.append(f' """')
|
||||
lines.append(f' return PRCXI9300TipRack(')
|
||||
lines.append(f' name=name,')
|
||||
lines.append(f' size_x={item.size_x},')
|
||||
lines.append(f' size_y={item.size_y},')
|
||||
lines.append(f' size_z={item.size_z},')
|
||||
lines.append(f' model="{item.model}",')
|
||||
lines.append(f' material_info={_fmt_dict(item.material_info.model_dump(exclude_none=True))},')
|
||||
if grid and tip:
|
||||
lines.append(f' ordered_items=create_ordered_items_2d(')
|
||||
lines.append(f' TipSpot,')
|
||||
lines.append(f' num_items_x={grid.num_items_x},')
|
||||
lines.append(f' num_items_y={grid.num_items_y},')
|
||||
lines.append(f' dx={grid.dx},')
|
||||
lines.append(f' dy={grid.dy},')
|
||||
lines.append(f' dz={grid.dz},')
|
||||
lines.append(f' item_dx={grid.item_dx},')
|
||||
lines.append(f' item_dy={grid.item_dy},')
|
||||
lines.append(f' size_x={tip.spot_size_x},')
|
||||
lines.append(f' size_y={tip.spot_size_y},')
|
||||
lines.append(f' size_z={tip.spot_size_z},')
|
||||
lines.append(f' make_tip=lambda: _make_tip_helper(volume={tip.tip_volume}, length={tip.tip_length}, depth={tip.tip_fitting_depth})')
|
||||
lines.append(f' )')
|
||||
lines.append(f' )')
|
||||
|
||||
return '\n'.join(lines)
|
||||
|
||||
|
||||
def _gen_trash(item: LabwareItem) -> str:
|
||||
"""生成 Trash 工厂函数代码。"""
|
||||
lines = []
|
||||
fn = item.function_name
|
||||
doc = item.docstring or f"Code: {item.material_info.Code}"
|
||||
|
||||
lines.append(f'def {fn}(name: str = "trash") -> PRCXI9300Trash:')
|
||||
lines.append(f' """')
|
||||
for dl in doc.split('\n'):
|
||||
lines.append(f' {dl}')
|
||||
lines.append(f' """')
|
||||
lines.append(f' return PRCXI9300Trash(')
|
||||
lines.append(f' name="trash",')
|
||||
lines.append(f' size_x={item.size_x},')
|
||||
lines.append(f' size_y={item.size_y},')
|
||||
lines.append(f' size_z={item.size_z},')
|
||||
lines.append(f' category="trash",')
|
||||
lines.append(f' model="{item.model}",')
|
||||
lines.append(f' material_info={_fmt_dict(item.material_info.model_dump(exclude_none=True))}')
|
||||
lines.append(f' )')
|
||||
|
||||
return '\n'.join(lines)
|
||||
|
||||
|
||||
def _gen_tube_rack(item: LabwareItem) -> str:
|
||||
"""生成 TubeRack 工厂函数代码。"""
|
||||
lines = []
|
||||
fn = item.function_name
|
||||
doc = item.docstring or f"Code: {item.material_info.Code}"
|
||||
grid = item.grid
|
||||
tube = item.tube
|
||||
|
||||
lines.append(f'def {fn}(name: str) -> PRCXI9300TubeRack:')
|
||||
lines.append(f' """')
|
||||
for dl in doc.split('\n'):
|
||||
lines.append(f' {dl}')
|
||||
lines.append(f' """')
|
||||
lines.append(f' return PRCXI9300TubeRack(')
|
||||
lines.append(f' name=name,')
|
||||
lines.append(f' size_x={item.size_x},')
|
||||
lines.append(f' size_y={item.size_y},')
|
||||
lines.append(f' size_z={item.size_z},')
|
||||
lines.append(f' model="{item.model}",')
|
||||
lines.append(f' category="tube_rack",')
|
||||
lines.append(f' material_info={_fmt_dict(item.material_info.model_dump(exclude_none=True))},')
|
||||
if grid and tube:
|
||||
lines.append(f' ordered_items=create_ordered_items_2d(')
|
||||
lines.append(f' Tube,')
|
||||
lines.append(f' num_items_x={grid.num_items_x},')
|
||||
lines.append(f' num_items_y={grid.num_items_y},')
|
||||
lines.append(f' dx={grid.dx},')
|
||||
lines.append(f' dy={grid.dy},')
|
||||
lines.append(f' dz={grid.dz},')
|
||||
lines.append(f' item_dx={grid.item_dx},')
|
||||
lines.append(f' item_dy={grid.item_dy},')
|
||||
lines.append(f' size_x={tube.size_x},')
|
||||
lines.append(f' size_y={tube.size_y},')
|
||||
lines.append(f' size_z={tube.size_z},')
|
||||
lines.append(f' max_volume={tube.max_volume}')
|
||||
lines.append(f' )')
|
||||
lines.append(f' )')
|
||||
|
||||
return '\n'.join(lines)
|
||||
|
||||
|
||||
def _gen_plate_adapter(item: LabwareItem) -> str:
|
||||
"""生成 PlateAdapter 工厂函数代码。"""
|
||||
lines = []
|
||||
fn = item.function_name
|
||||
doc = item.docstring or f"Code: {item.material_info.Code}"
|
||||
|
||||
lines.append(f'def {fn}(name: str) -> PRCXI9300PlateAdapter:')
|
||||
lines.append(f' """ {doc} """')
|
||||
lines.append(f' return PRCXI9300PlateAdapter(')
|
||||
lines.append(f' name=name,')
|
||||
lines.append(f' size_x={item.size_x},')
|
||||
lines.append(f' size_y={item.size_y},')
|
||||
lines.append(f' size_z={item.size_z},')
|
||||
if item.model:
|
||||
lines.append(f' model="{item.model}",')
|
||||
lines.append(f' material_info={_fmt_dict(item.material_info.model_dump(exclude_none=True))}')
|
||||
lines.append(f' )')
|
||||
|
||||
return '\n'.join(lines)
|
||||
|
||||
|
||||
def _fmt_dict(d: dict) -> str:
|
||||
"""格式化字典为 Python 代码片段。"""
|
||||
parts = []
|
||||
for k, v in d.items():
|
||||
if isinstance(v, str):
|
||||
parts.append(f'"{k}": "{v}"')
|
||||
elif v is None:
|
||||
continue
|
||||
else:
|
||||
parts.append(f'"{k}": {v}')
|
||||
return '{' + ', '.join(parts) + '}'
|
||||
|
||||
|
||||
def _gen_template_factory_kinds(items: List[LabwareItem]) -> str:
|
||||
"""生成 PRCXI_TEMPLATE_FACTORY_KINDS 列表。"""
|
||||
lines = ['PRCXI_TEMPLATE_FACTORY_KINDS: List[Tuple[Callable[..., Any], str]] = [']
|
||||
for item in items:
|
||||
if item.include_in_template_matching and item.template_kind:
|
||||
lines.append(f' ({item.function_name}, "{item.template_kind}"),')
|
||||
lines.append(']')
|
||||
return '\n'.join(lines)
|
||||
|
||||
|
||||
def _gen_footer() -> str:
|
||||
"""生成文件尾部的模板相关代码。"""
|
||||
return '''
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 协议上传 / workflow 用:与设备端耗材字典字段对齐的模板描述(供 common 自动匹配)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_PRCXI_TEMPLATE_SPECS_CACHE: Optional[List[Dict[str, Any]]] = None
|
||||
|
||||
|
||||
def _probe_prcxi_resource(factory: Callable[..., Any]) -> Any:
|
||||
probe = "__unilab_template_probe__"
|
||||
if factory.__name__ == "PRCXI_trash":
|
||||
return factory()
|
||||
return factory(probe)
|
||||
|
||||
|
||||
def _first_child_capacity_for_match(resource: Any) -> float:
|
||||
"""Well max_volume 或 Tip 的 maximal_volume,用于与设备端 Volume 类似的打分。"""
|
||||
ch = getattr(resource, "children", None) or []
|
||||
if not ch:
|
||||
return 0.0
|
||||
c0 = ch[0]
|
||||
mv = getattr(c0, "max_volume", None)
|
||||
if mv is not None:
|
||||
return float(mv)
|
||||
tip = getattr(c0, "tip", None)
|
||||
if tip is not None:
|
||||
mv2 = getattr(tip, "maximal_volume", None)
|
||||
if mv2 is not None:
|
||||
return float(mv2)
|
||||
return 0.0
|
||||
|
||||
|
||||
def get_prcxi_labware_template_specs() -> List[Dict[str, Any]]:
|
||||
"""返回与 ``prcxi._match_and_create_matrix`` 中耗材字段兼容的模板列表,用于按孔数+容量打分。"""
|
||||
global _PRCXI_TEMPLATE_SPECS_CACHE
|
||||
if _PRCXI_TEMPLATE_SPECS_CACHE is not None:
|
||||
return _PRCXI_TEMPLATE_SPECS_CACHE
|
||||
|
||||
out: List[Dict[str, Any]] = []
|
||||
for factory, kind in PRCXI_TEMPLATE_FACTORY_KINDS:
|
||||
try:
|
||||
r = _probe_prcxi_resource(factory)
|
||||
except Exception:
|
||||
continue
|
||||
nx = int(getattr(r, "num_items_x", None) or 0)
|
||||
ny = int(getattr(r, "num_items_y", None) or 0)
|
||||
nchild = len(getattr(r, "children", []) or [])
|
||||
hole_count = nx * ny if nx > 0 and ny > 0 else nchild
|
||||
hole_row = ny if nx > 0 and ny > 0 else 0
|
||||
hole_col = nx if nx > 0 and ny > 0 else 0
|
||||
mi = getattr(r, "material_info", None) or {}
|
||||
vol = _first_child_capacity_for_match(r)
|
||||
menum = mi.get("materialEnum")
|
||||
if menum is None and kind == "tip_rack":
|
||||
menum = 1
|
||||
elif menum is None and kind == "trash":
|
||||
menum = 6
|
||||
out.append(
|
||||
{
|
||||
"class_name": factory.__name__,
|
||||
"kind": kind,
|
||||
"materialEnum": menum,
|
||||
"HoleRow": hole_row,
|
||||
"HoleColum": hole_col,
|
||||
"Volume": vol,
|
||||
"hole_count": hole_count,
|
||||
"material_uuid": mi.get("uuid"),
|
||||
"material_code": mi.get("Code"),
|
||||
}
|
||||
)
|
||||
|
||||
_PRCXI_TEMPLATE_SPECS_CACHE = out
|
||||
return out
|
||||
'''
|
||||
|
||||
|
||||
def generate_code(db: LabwareDB, test_mode: bool = True) -> Path:
|
||||
"""生成 prcxi_labware.py (或 _test.py),返回输出文件路径。"""
|
||||
suffix = "_test" if test_mode else ""
|
||||
out_path = _TARGET_DIR / f"prcxi_labware{suffix}.py"
|
||||
|
||||
# 备份
|
||||
if out_path.exists():
|
||||
bak = out_path.with_suffix(".py.bak")
|
||||
shutil.copy2(out_path, bak)
|
||||
|
||||
# 按类型分组的生成器
|
||||
generators = {
|
||||
"plate": _gen_plate,
|
||||
"tip_rack": _gen_tip_rack,
|
||||
"trash": _gen_trash,
|
||||
"tube_rack": _gen_tube_rack,
|
||||
"plate_adapter": _gen_plate_adapter,
|
||||
}
|
||||
|
||||
# 按 type 分段
|
||||
sections = {
|
||||
"plate": [],
|
||||
"tip_rack": [],
|
||||
"trash": [],
|
||||
"tube_rack": [],
|
||||
"plate_adapter": [],
|
||||
}
|
||||
|
||||
for item in db.items:
|
||||
gen = generators.get(item.type)
|
||||
if gen:
|
||||
sections[item.type].append(gen(item))
|
||||
|
||||
# 组装完整文件
|
||||
parts = [_HEADER]
|
||||
|
||||
section_titles = {
|
||||
"plate": "# =========================================================================\n# Plates\n# =========================================================================",
|
||||
"tip_rack": "# =========================================================================\n# Tip Racks\n# =========================================================================",
|
||||
"trash": "# =========================================================================\n# Trash\n# =========================================================================",
|
||||
"tube_rack": "# =========================================================================\n# Tube Racks\n# =========================================================================",
|
||||
"plate_adapter": "# =========================================================================\n# Plate Adapters\n# =========================================================================",
|
||||
}
|
||||
|
||||
for type_key in ["plate", "tip_rack", "trash", "tube_rack", "plate_adapter"]:
|
||||
if sections[type_key]:
|
||||
parts.append(section_titles[type_key])
|
||||
for code in sections[type_key]:
|
||||
parts.append(code)
|
||||
|
||||
# Template factory kinds
|
||||
parts.append("")
|
||||
parts.append(_gen_template_factory_kinds(db.items))
|
||||
|
||||
# Footer
|
||||
parts.append(_gen_footer())
|
||||
|
||||
content = '\n'.join(parts)
|
||||
out_path.write_text(content, encoding="utf-8")
|
||||
return out_path
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
from unilabos.labware_manager.importer import load_db
|
||||
db = load_db()
|
||||
if not db.items:
|
||||
print("labware_db.json 为空,请先运行 importer.py")
|
||||
else:
|
||||
out = generate_code(db, test_mode=True)
|
||||
print(f"已生成 {out} ({len(db.items)} 个工厂函数)")
|
||||
@@ -1,474 +0,0 @@
|
||||
"""从现有 prcxi_labware.py + registry YAML 导入耗材数据到 labware_db.json。
|
||||
|
||||
策略:
|
||||
1. 实例化每个工厂函数 → 提取物理尺寸、material_info、children
|
||||
2. AST 解析源码 → 提取 docstring、volume_function 参数、plate_type
|
||||
3. 从 children[0].location 反推 dx/dy/dz,相邻位置差推 item_dx/item_dy
|
||||
4. 同时读取现有 YAML → 提取 registry_category / description
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
import yaml
|
||||
|
||||
# 将项目根目录加入 sys.path 以便 import
|
||||
_PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
if str(_PROJECT_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(_PROJECT_ROOT))
|
||||
|
||||
from unilabos.labware_manager.models import (
|
||||
AdapterInfo,
|
||||
GridInfo,
|
||||
LabwareDB,
|
||||
LabwareItem,
|
||||
MaterialInfo,
|
||||
TipInfo,
|
||||
TubeInfo,
|
||||
VolumeFunctions,
|
||||
WellInfo,
|
||||
)
|
||||
|
||||
# ---------- 路径常量 ----------
|
||||
_LABWARE_PY = Path(__file__).resolve().parents[1] / "devices" / "liquid_handling" / "prcxi" / "prcxi_labware.py"
|
||||
_REGISTRY_DIR = Path(__file__).resolve().parents[1] / "registry" / "resources" / "prcxi"
|
||||
_DB_PATH = Path(__file__).resolve().parent / "labware_db.json"
|
||||
|
||||
# YAML 文件名 → type 映射
|
||||
_YAML_MAP: Dict[str, str] = {
|
||||
"plates.yaml": "plate",
|
||||
"tip_racks.yaml": "tip_rack",
|
||||
"trash.yaml": "trash",
|
||||
"tube_racks.yaml": "tube_rack",
|
||||
"plate_adapters.yaml": "plate_adapter",
|
||||
}
|
||||
|
||||
# PRCXI_TEMPLATE_FACTORY_KINDS 中列出的函数名(include_in_template_matching=True)
|
||||
_TEMPLATE_FACTORY_NAMES = {
|
||||
"PRCXI_BioER_96_wellplate", "PRCXI_nest_1_troughplate",
|
||||
"PRCXI_BioRad_384_wellplate", "PRCXI_AGenBio_4_troughplate",
|
||||
"PRCXI_nest_12_troughplate", "PRCXI_CellTreat_96_wellplate",
|
||||
"PRCXI_10ul_eTips", "PRCXI_300ul_Tips",
|
||||
"PRCXI_PCR_Plate_200uL_nonskirted", "PRCXI_PCR_Plate_200uL_semiskirted",
|
||||
"PRCXI_PCR_Plate_200uL_skirted", "PRCXI_trash",
|
||||
"PRCXI_96_DeepWell", "PRCXI_EP_Adapter",
|
||||
"PRCXI_1250uL_Tips", "PRCXI_10uL_Tips",
|
||||
"PRCXI_1000uL_Tips", "PRCXI_200uL_Tips",
|
||||
"PRCXI_48_DeepWell",
|
||||
}
|
||||
|
||||
# template_kind 对应
|
||||
_TEMPLATE_KINDS: Dict[str, str] = {
|
||||
"PRCXI_BioER_96_wellplate": "plate",
|
||||
"PRCXI_nest_1_troughplate": "plate",
|
||||
"PRCXI_BioRad_384_wellplate": "plate",
|
||||
"PRCXI_AGenBio_4_troughplate": "plate",
|
||||
"PRCXI_nest_12_troughplate": "plate",
|
||||
"PRCXI_CellTreat_96_wellplate": "plate",
|
||||
"PRCXI_10ul_eTips": "tip_rack",
|
||||
"PRCXI_300ul_Tips": "tip_rack",
|
||||
"PRCXI_PCR_Plate_200uL_nonskirted": "plate",
|
||||
"PRCXI_PCR_Plate_200uL_semiskirted": "plate",
|
||||
"PRCXI_PCR_Plate_200uL_skirted": "plate",
|
||||
"PRCXI_trash": "trash",
|
||||
"PRCXI_96_DeepWell": "plate",
|
||||
"PRCXI_EP_Adapter": "tube_rack",
|
||||
"PRCXI_1250uL_Tips": "tip_rack",
|
||||
"PRCXI_10uL_Tips": "tip_rack",
|
||||
"PRCXI_1000uL_Tips": "tip_rack",
|
||||
"PRCXI_200uL_Tips": "tip_rack",
|
||||
"PRCXI_48_DeepWell": "plate",
|
||||
}
|
||||
|
||||
|
||||
def _load_registry_info() -> Dict[str, Dict[str, Any]]:
|
||||
"""读取所有 registry YAML 文件,返回 {function_name: {category, description}} 映射。"""
|
||||
info: Dict[str, Dict[str, Any]] = {}
|
||||
for fname, ltype in _YAML_MAP.items():
|
||||
fpath = _REGISTRY_DIR / fname
|
||||
if not fpath.exists():
|
||||
continue
|
||||
with open(fpath, "r", encoding="utf-8") as f:
|
||||
data = yaml.safe_load(f) or {}
|
||||
for func_name, entry in data.items():
|
||||
info[func_name] = {
|
||||
"registry_category": entry.get("category", ["prcxi", ltype.replace("plate_adapter", "plate_adapters")]),
|
||||
"registry_description": entry.get("description", ""),
|
||||
}
|
||||
return info
|
||||
|
||||
|
||||
def _parse_ast_info() -> Dict[str, Dict[str, Any]]:
|
||||
"""AST 解析 prcxi_labware.py,提取每个工厂函数的 docstring 和 volume_function 参数。"""
|
||||
source = _LABWARE_PY.read_text(encoding="utf-8")
|
||||
tree = ast.parse(source)
|
||||
result: Dict[str, Dict[str, Any]] = {}
|
||||
|
||||
for node in ast.walk(tree):
|
||||
if not isinstance(node, ast.FunctionDef):
|
||||
continue
|
||||
fname = node.name
|
||||
if not fname.startswith("PRCXI_"):
|
||||
continue
|
||||
if fname.startswith("_"):
|
||||
continue
|
||||
|
||||
info: Dict[str, Any] = {"docstring": "", "volume_functions": None, "plate_type": None}
|
||||
|
||||
# docstring
|
||||
doc = ast.get_docstring(node)
|
||||
if doc:
|
||||
info["docstring"] = doc.strip()
|
||||
|
||||
# 搜索函数体中的 plate_type 赋值和 volume_function 参数
|
||||
func_source = ast.get_source_segment(source, node) or ""
|
||||
|
||||
# plate_type
|
||||
m = re.search(r'plate_type\s*=\s*["\']([^"\']+)["\']', func_source)
|
||||
if m:
|
||||
info["plate_type"] = m.group(1)
|
||||
|
||||
# volume_functions: 检查 compute_height_from_volume_rectangle
|
||||
if "compute_height_from_volume_rectangle" in func_source:
|
||||
# 提取 well_length 和 well_width
|
||||
vf: Dict[str, Any] = {"type": "rectangle"}
|
||||
# 尝试从 lambda 中提取
|
||||
wl_match = re.search(r'well_length\s*=\s*([\w_.]+)', func_source)
|
||||
ww_match = re.search(r'well_width\s*=\s*([\w_.]+)', func_source)
|
||||
if wl_match:
|
||||
vf["well_length_var"] = wl_match.group(1)
|
||||
if ww_match:
|
||||
vf["well_width_var"] = ww_match.group(1)
|
||||
info["volume_functions"] = vf
|
||||
|
||||
result[fname] = info
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def _probe_factory(factory_func) -> Any:
|
||||
"""实例化工厂函数获取 resource 对象。"""
|
||||
if factory_func.__name__ == "PRCXI_trash":
|
||||
return factory_func()
|
||||
return factory_func("__probe__")
|
||||
|
||||
|
||||
def _get_size(resource, attr: str) -> float:
|
||||
"""获取 PLR Resource 的尺寸(兼容 size_x 和 _size_x)。"""
|
||||
val = getattr(resource, attr, None)
|
||||
if val is None:
|
||||
val = getattr(resource, f"_{attr}", None)
|
||||
if val is None:
|
||||
val = getattr(resource, f"get_{attr}", lambda: 0)()
|
||||
return float(val or 0)
|
||||
|
||||
|
||||
def _extract_grid_from_children(resource) -> Optional[Dict[str, Any]]:
|
||||
"""从 resource.children 提取网格信息。"""
|
||||
children = getattr(resource, "children", None) or []
|
||||
if not children:
|
||||
return None
|
||||
|
||||
# 获取 num_items_x, num_items_y
|
||||
num_x = getattr(resource, "num_items_x", None)
|
||||
num_y = getattr(resource, "num_items_y", None)
|
||||
if num_x is None or num_y is None:
|
||||
return None
|
||||
|
||||
c0 = children[0]
|
||||
loc0 = getattr(c0, "location", None)
|
||||
dx = loc0.x if loc0 else 0.0
|
||||
dy_raw = loc0.y if loc0 else 0.0 # 这是 PLR 布局后的位置,不是输入参数
|
||||
dz = loc0.z if loc0 else 0.0
|
||||
|
||||
# 推算 item_dx, item_dy
|
||||
item_dx = 9.0
|
||||
item_dy = 9.0
|
||||
if len(children) > 1:
|
||||
c1 = children[1]
|
||||
loc1 = getattr(c1, "location", None)
|
||||
if loc1 and loc0:
|
||||
diff_x = abs(loc1.x - loc0.x)
|
||||
diff_y = abs(loc1.y - loc0.y)
|
||||
if diff_x > 0.1:
|
||||
item_dx = diff_x
|
||||
if diff_y > 0.1:
|
||||
item_dy = diff_y
|
||||
|
||||
# 如果 num_items_y > 1 且 num_items_x > 1, 找列间距
|
||||
if int(num_y) > 1 and int(num_x) > 1 and len(children) >= int(num_y) + 1:
|
||||
cn = children[int(num_y)]
|
||||
locn = getattr(cn, "location", None)
|
||||
if locn and loc0:
|
||||
col_diff = abs(locn.x - loc0.x)
|
||||
row_diff = abs(children[1].location.y - loc0.y) if len(children) > 1 else item_dy
|
||||
if col_diff > 0.1:
|
||||
item_dx = col_diff
|
||||
if row_diff > 0.1:
|
||||
item_dy = row_diff
|
||||
|
||||
# PLR create_ordered_items_2d 的 Y 轴排列是倒序的:
|
||||
# child[0].y = dy_param + (num_y - 1) * item_dy (最上面一行)
|
||||
# 因此反推原始 dy 参数:
|
||||
dy = dy_raw - (int(num_y) - 1) * item_dy
|
||||
|
||||
return {
|
||||
"num_items_x": int(num_x),
|
||||
"num_items_y": int(num_y),
|
||||
"dx": round(dx, 4),
|
||||
"dy": round(dy, 4),
|
||||
"dz": round(dz, 4),
|
||||
"item_dx": round(item_dx, 4),
|
||||
"item_dy": round(item_dy, 4),
|
||||
}
|
||||
|
||||
|
||||
def _extract_well_info(child) -> Dict[str, Any]:
|
||||
"""从 Well/TipSpot/Tube 子对象提取信息。"""
|
||||
# material_z_thickness 在 PLR 中如果未设置会抛 NotImplementedError
|
||||
mzt = None
|
||||
try:
|
||||
mzt = child.material_z_thickness
|
||||
except (NotImplementedError, AttributeError):
|
||||
mzt = getattr(child, "_material_z_thickness", None)
|
||||
|
||||
return {
|
||||
"size_x": round(_get_size(child, "size_x"), 4),
|
||||
"size_y": round(_get_size(child, "size_y"), 4),
|
||||
"size_z": round(_get_size(child, "size_z"), 4),
|
||||
"max_volume": getattr(child, "max_volume", None),
|
||||
"bottom_type": getattr(child, "bottom_type", None),
|
||||
"cross_section_type": getattr(child, "cross_section_type", None),
|
||||
"material_z_thickness": mzt,
|
||||
}
|
||||
|
||||
|
||||
def import_from_code() -> LabwareDB:
|
||||
"""执行完整的导入流程,返回 LabwareDB 对象。"""
|
||||
# 1. 加载 registry 信息
|
||||
reg_info = _load_registry_info()
|
||||
|
||||
# 2. AST 解析源码
|
||||
ast_info = _parse_ast_info()
|
||||
|
||||
# 3. 导入工厂模块(通过包路径避免 relative import 问题)
|
||||
import importlib
|
||||
mod = importlib.import_module("unilabos.devices.liquid_handling.prcxi.prcxi_labware")
|
||||
|
||||
# 4. 获取 PRCXI_TEMPLATE_FACTORY_KINDS 列出的函数
|
||||
factory_kinds = getattr(mod, "PRCXI_TEMPLATE_FACTORY_KINDS", [])
|
||||
template_func_names = {f.__name__ for f, _k in factory_kinds}
|
||||
|
||||
# 5. 收集所有 PRCXI_ 开头的工厂函数
|
||||
all_factories: List[Tuple[str, Any]] = []
|
||||
for attr_name in dir(mod):
|
||||
if attr_name.startswith("PRCXI_") and not attr_name.startswith("_"):
|
||||
obj = getattr(mod, attr_name)
|
||||
if callable(obj) and not isinstance(obj, type):
|
||||
all_factories.append((attr_name, obj))
|
||||
|
||||
# 按源码行号排序
|
||||
all_factories.sort(key=lambda x: getattr(x[1], "__code__", None) and x[1].__code__.co_firstlineno or 0)
|
||||
|
||||
items: List[LabwareItem] = []
|
||||
|
||||
for func_name, factory in all_factories:
|
||||
try:
|
||||
resource = _probe_factory(factory)
|
||||
except Exception as e:
|
||||
print(f"跳过 {func_name}: {e}")
|
||||
continue
|
||||
|
||||
# 确定类型
|
||||
type_name = "plate"
|
||||
class_name = type(resource).__name__
|
||||
if "TipRack" in class_name:
|
||||
type_name = "tip_rack"
|
||||
elif "Trash" in class_name:
|
||||
type_name = "trash"
|
||||
elif "TubeRack" in class_name:
|
||||
type_name = "tube_rack"
|
||||
elif "PlateAdapter" in class_name:
|
||||
type_name = "plate_adapter"
|
||||
|
||||
# material_info
|
||||
state = getattr(resource, "_unilabos_state", {}) or {}
|
||||
mat = state.get("Material", {})
|
||||
mat_info = MaterialInfo(
|
||||
uuid=mat.get("uuid", ""),
|
||||
Code=mat.get("Code", ""),
|
||||
Name=mat.get("Name", ""),
|
||||
materialEnum=mat.get("materialEnum"),
|
||||
SupplyType=mat.get("SupplyType"),
|
||||
)
|
||||
|
||||
# AST 信息
|
||||
ast_data = ast_info.get(func_name, {})
|
||||
docstring = ast_data.get("docstring", "")
|
||||
plate_type = ast_data.get("plate_type")
|
||||
|
||||
# Registry 信息
|
||||
reg = reg_info.get(func_name, {})
|
||||
registry_category = reg.get("registry_category", ["prcxi", _type_to_yaml_subcategory(type_name)])
|
||||
registry_description = reg.get("registry_description", f'{mat_info.Name} (Code: {mat_info.Code})')
|
||||
|
||||
# 构建 item
|
||||
item = LabwareItem(
|
||||
id=func_name.lower().replace("prcxi_", "")[:8] or func_name[:8],
|
||||
type=type_name,
|
||||
function_name=func_name,
|
||||
docstring=docstring,
|
||||
size_x=round(_get_size(resource, "size_x"), 4),
|
||||
size_y=round(_get_size(resource, "size_y"), 4),
|
||||
size_z=round(_get_size(resource, "size_z"), 4),
|
||||
model=getattr(resource, "model", None),
|
||||
category=getattr(resource, "category", type_name),
|
||||
plate_type=plate_type,
|
||||
material_info=mat_info,
|
||||
registry_category=registry_category,
|
||||
registry_description=registry_description,
|
||||
include_in_template_matching=func_name in template_func_names,
|
||||
template_kind=_TEMPLATE_KINDS.get(func_name),
|
||||
)
|
||||
|
||||
# 提取子项信息
|
||||
children = getattr(resource, "children", None) or []
|
||||
grid_data = _extract_grid_from_children(resource)
|
||||
|
||||
if type_name == "plate" and children:
|
||||
if grid_data:
|
||||
item.grid = GridInfo(**grid_data)
|
||||
c0 = children[0]
|
||||
well_data = _extract_well_info(c0)
|
||||
bt = well_data.get("bottom_type")
|
||||
if bt is not None:
|
||||
bt = bt.name if hasattr(bt, "name") else str(bt)
|
||||
else:
|
||||
bt = "FLAT"
|
||||
cst = well_data.get("cross_section_type")
|
||||
if cst is not None:
|
||||
cst = cst.name if hasattr(cst, "name") else str(cst)
|
||||
else:
|
||||
cst = "CIRCLE"
|
||||
item.well = WellInfo(
|
||||
size_x=well_data["size_x"],
|
||||
size_y=well_data["size_y"],
|
||||
size_z=well_data["size_z"],
|
||||
max_volume=well_data.get("max_volume"),
|
||||
bottom_type=bt,
|
||||
cross_section_type=cst,
|
||||
material_z_thickness=well_data.get("material_z_thickness"),
|
||||
)
|
||||
# volume_functions
|
||||
vf = ast_data.get("volume_functions")
|
||||
if vf:
|
||||
# 需要实际获取 well 尺寸作为 volume_function 参数
|
||||
item.volume_functions = VolumeFunctions(
|
||||
type="rectangle",
|
||||
well_length=well_data["size_x"],
|
||||
well_width=well_data["size_y"],
|
||||
)
|
||||
|
||||
elif type_name == "tip_rack" and children:
|
||||
if grid_data:
|
||||
item.grid = GridInfo(**grid_data)
|
||||
c0 = children[0]
|
||||
tip_obj = getattr(c0, "tip", None)
|
||||
tip_volume = 300.0
|
||||
tip_length = 60.0
|
||||
tip_depth = 51.0
|
||||
tip_filter = False
|
||||
if tip_obj:
|
||||
tip_volume = getattr(tip_obj, "maximal_volume", 300.0)
|
||||
tip_length = getattr(tip_obj, "total_tip_length", 60.0)
|
||||
tip_depth = getattr(tip_obj, "fitting_depth", 51.0)
|
||||
tip_filter = getattr(tip_obj, "has_filter", False)
|
||||
item.tip = TipInfo(
|
||||
spot_size_x=round(_get_size(c0, "size_x"), 4),
|
||||
spot_size_y=round(_get_size(c0, "size_y"), 4),
|
||||
spot_size_z=round(_get_size(c0, "size_z"), 4),
|
||||
tip_volume=tip_volume,
|
||||
tip_length=tip_length,
|
||||
tip_fitting_depth=tip_depth,
|
||||
has_filter=tip_filter,
|
||||
)
|
||||
# 计算 tip_above_rack_length = tip_length - (size_z - dz)
|
||||
if grid_data:
|
||||
_dz = grid_data.get("dz", 0.0)
|
||||
_above = tip_length - (item.size_z - _dz)
|
||||
item.tip.tip_above_rack_length = round(_above, 4) if _above > 0 else None
|
||||
|
||||
elif type_name == "tube_rack" and children:
|
||||
if grid_data:
|
||||
item.grid = GridInfo(**grid_data)
|
||||
c0 = children[0]
|
||||
item.tube = TubeInfo(
|
||||
size_x=round(_get_size(c0, "size_x"), 4),
|
||||
size_y=round(_get_size(c0, "size_y"), 4),
|
||||
size_z=round(_get_size(c0, "size_z"), 4),
|
||||
max_volume=getattr(c0, "max_volume", 1500.0) or 1500.0,
|
||||
)
|
||||
|
||||
elif type_name == "plate_adapter":
|
||||
# 提取 adapter 参数
|
||||
ahx = getattr(resource, "adapter_hole_size_x", 127.76)
|
||||
ahy = getattr(resource, "adapter_hole_size_y", 85.48)
|
||||
ahz = getattr(resource, "adapter_hole_size_z", 10.0)
|
||||
adx = getattr(resource, "dx", None)
|
||||
ady = getattr(resource, "dy", None)
|
||||
adz = getattr(resource, "dz", 0.0)
|
||||
item.adapter = AdapterInfo(
|
||||
adapter_hole_size_x=ahx,
|
||||
adapter_hole_size_y=ahy,
|
||||
adapter_hole_size_z=ahz,
|
||||
dx=adx,
|
||||
dy=ady,
|
||||
dz=adz,
|
||||
)
|
||||
|
||||
items.append(item)
|
||||
|
||||
return LabwareDB(items=items)
|
||||
|
||||
|
||||
def _type_to_yaml_subcategory(type_name: str) -> str:
|
||||
mapping = {
|
||||
"plate": "plates",
|
||||
"tip_rack": "tip_racks",
|
||||
"trash": "trash",
|
||||
"tube_rack": "tube_racks",
|
||||
"plate_adapter": "plate_adapters",
|
||||
}
|
||||
return mapping.get(type_name, type_name)
|
||||
|
||||
|
||||
def save_db(db: LabwareDB, path: Optional[Path] = None) -> Path:
|
||||
"""保存 LabwareDB 到 JSON 文件。"""
|
||||
out = path or _DB_PATH
|
||||
with open(out, "w", encoding="utf-8") as f:
|
||||
json.dump(db.model_dump(), f, ensure_ascii=False, indent=2)
|
||||
return out
|
||||
|
||||
|
||||
def load_db(path: Optional[Path] = None) -> LabwareDB:
|
||||
"""从 JSON 文件加载 LabwareDB。"""
|
||||
src = path or _DB_PATH
|
||||
if not src.exists():
|
||||
return LabwareDB()
|
||||
with open(src, "r", encoding="utf-8") as f:
|
||||
return LabwareDB(**json.load(f))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
db = import_from_code()
|
||||
out = save_db(db)
|
||||
print(f"已导入 {len(db.items)} 个耗材 → {out}")
|
||||
for item in db.items:
|
||||
print(f" [{item.type:14s}] {item.function_name}")
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,126 +0,0 @@
|
||||
"""Pydantic 数据模型,描述所有 PRCXI 耗材类型的 JSON 结构。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid as _uuid
|
||||
from typing import Any, Dict, List, Literal, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class MaterialInfo(BaseModel):
|
||||
uuid: str = ""
|
||||
Code: str = ""
|
||||
Name: str = ""
|
||||
materialEnum: Optional[int] = None
|
||||
SupplyType: Optional[int] = None
|
||||
|
||||
|
||||
class GridInfo(BaseModel):
|
||||
"""孔位网格排列参数"""
|
||||
num_items_x: int = 12
|
||||
num_items_y: int = 8
|
||||
dx: float = 0.0
|
||||
dy: float = 0.0
|
||||
dz: float = 0.0
|
||||
item_dx: float = 9.0
|
||||
item_dy: float = 9.0
|
||||
|
||||
|
||||
class WellInfo(BaseModel):
|
||||
"""孔参数 (Plate)"""
|
||||
size_x: float = 8.0
|
||||
size_y: float = 8.0
|
||||
size_z: float = 10.0
|
||||
max_volume: Optional[float] = None
|
||||
bottom_type: str = "FLAT" # V / U / FLAT
|
||||
cross_section_type: str = "CIRCLE" # CIRCLE / RECTANGLE
|
||||
material_z_thickness: Optional[float] = None
|
||||
|
||||
|
||||
class VolumeFunctions(BaseModel):
|
||||
"""体积-高度计算函数参数 (矩形 well)"""
|
||||
type: str = "rectangle"
|
||||
well_length: float = 0.0
|
||||
well_width: float = 0.0
|
||||
|
||||
|
||||
class TipInfo(BaseModel):
|
||||
"""枪头参数 (TipRack)"""
|
||||
spot_size_x: float = 7.0
|
||||
spot_size_y: float = 7.0
|
||||
spot_size_z: float = 0.0
|
||||
tip_volume: float = 300.0
|
||||
tip_length: float = 60.0
|
||||
tip_fitting_depth: float = 51.0
|
||||
tip_above_rack_length: Optional[float] = None
|
||||
has_filter: bool = False
|
||||
|
||||
|
||||
class TubeInfo(BaseModel):
|
||||
"""管参数 (TubeRack)"""
|
||||
size_x: float = 10.6
|
||||
size_y: float = 10.6
|
||||
size_z: float = 40.0
|
||||
max_volume: float = 1500.0
|
||||
|
||||
|
||||
class AdapterInfo(BaseModel):
|
||||
"""适配器参数 (PlateAdapter)"""
|
||||
adapter_hole_size_x: float = 127.76
|
||||
adapter_hole_size_y: float = 85.48
|
||||
adapter_hole_size_z: float = 10.0
|
||||
dx: Optional[float] = None
|
||||
dy: Optional[float] = None
|
||||
dz: float = 0.0
|
||||
|
||||
|
||||
LabwareType = Literal["plate", "tip_rack", "trash", "tube_rack", "plate_adapter"]
|
||||
|
||||
|
||||
class LabwareItem(BaseModel):
|
||||
"""一个耗材条目的完整 JSON 表示"""
|
||||
|
||||
id: str = Field(default_factory=lambda: _uuid.uuid4().hex[:8])
|
||||
type: LabwareType = "plate"
|
||||
function_name: str = ""
|
||||
docstring: str = ""
|
||||
|
||||
# 物理尺寸
|
||||
size_x: float = 127.0
|
||||
size_y: float = 85.0
|
||||
size_z: float = 20.0
|
||||
model: Optional[str] = None
|
||||
category: Optional[str] = None
|
||||
plate_type: Optional[str] = None # non-skirted / semi-skirted / skirted
|
||||
|
||||
# 材料信息
|
||||
material_info: MaterialInfo = Field(default_factory=MaterialInfo)
|
||||
|
||||
# Registry 字段
|
||||
registry_category: List[str] = Field(default_factory=lambda: ["prcxi", "plates"])
|
||||
registry_description: str = ""
|
||||
|
||||
# Plate 特有
|
||||
grid: Optional[GridInfo] = None
|
||||
well: Optional[WellInfo] = None
|
||||
volume_functions: Optional[VolumeFunctions] = None
|
||||
|
||||
# TipRack 特有
|
||||
tip: Optional[TipInfo] = None
|
||||
|
||||
# TubeRack 特有
|
||||
tube: Optional[TubeInfo] = None
|
||||
|
||||
# PlateAdapter 特有
|
||||
adapter: Optional[AdapterInfo] = None
|
||||
|
||||
# 模板匹配
|
||||
include_in_template_matching: bool = False
|
||||
template_kind: Optional[str] = None
|
||||
|
||||
|
||||
class LabwareDB(BaseModel):
|
||||
"""整个 labware_db.json 的结构"""
|
||||
version: str = "1.0"
|
||||
items: List[LabwareItem] = Field(default_factory=list)
|
||||
@@ -1,292 +0,0 @@
|
||||
/**
|
||||
* form_handler.js — 动态表单逻辑 + 实时预览
|
||||
*/
|
||||
|
||||
// 根据类型显示/隐藏对应的表单段
|
||||
function onTypeChange() {
|
||||
const type = document.getElementById('f-type').value;
|
||||
const sections = {
|
||||
grid: ['plate', 'tip_rack', 'tube_rack'],
|
||||
well: ['plate'],
|
||||
tip: ['tip_rack'],
|
||||
tube: ['tube_rack'],
|
||||
adapter: ['plate_adapter'],
|
||||
};
|
||||
|
||||
for (const [sec, types] of Object.entries(sections)) {
|
||||
const el = document.getElementById('section-' + sec);
|
||||
if (el) el.style.display = types.includes(type) ? 'block' : 'none';
|
||||
}
|
||||
|
||||
// plate_type 行只对 plate 显示
|
||||
const ptRow = document.getElementById('row-plate_type');
|
||||
if (ptRow) ptRow.style.display = type === 'plate' ? 'block' : 'none';
|
||||
|
||||
updatePreview();
|
||||
}
|
||||
|
||||
// 从表单收集数据
|
||||
function collectFormData() {
|
||||
const g = id => {
|
||||
const el = document.getElementById(id);
|
||||
if (!el) return null;
|
||||
if (el.type === 'checkbox') return el.checked;
|
||||
if (el.type === 'number') return el.value === '' ? null : parseFloat(el.value);
|
||||
return el.value || null;
|
||||
};
|
||||
|
||||
const type = g('f-type');
|
||||
|
||||
const data = {
|
||||
type: type,
|
||||
function_name: g('f-function_name') || 'PRCXI_new',
|
||||
model: g('f-model'),
|
||||
docstring: g('f-docstring') || '',
|
||||
plate_type: type === 'plate' ? g('f-plate_type') : null,
|
||||
size_x: g('f-size_x') || 127,
|
||||
size_y: g('f-size_y') || 85,
|
||||
size_z: g('f-size_z') || 20,
|
||||
material_info: {
|
||||
uuid: g('f-mi_uuid') || '',
|
||||
Code: g('f-mi_code') || '',
|
||||
Name: g('f-mi_name') || '',
|
||||
materialEnum: g('f-mi_menum'),
|
||||
SupplyType: g('f-mi_stype'),
|
||||
},
|
||||
registry_category: (g('f-reg_cat') || 'prcxi,plates').split(',').map(s => s.trim()),
|
||||
registry_description: g('f-reg_desc') || '',
|
||||
include_in_template_matching: g('f-in_tpl') || false,
|
||||
template_kind: g('f-tpl_kind') || null,
|
||||
grid: null,
|
||||
well: null,
|
||||
tip: null,
|
||||
tube: null,
|
||||
adapter: null,
|
||||
volume_functions: null,
|
||||
};
|
||||
|
||||
// Grid
|
||||
if (['plate', 'tip_rack', 'tube_rack'].includes(type)) {
|
||||
data.grid = {
|
||||
num_items_x: g('f-grid_nx') || 12,
|
||||
num_items_y: g('f-grid_ny') || 8,
|
||||
dx: g('f-grid_dx') || 0,
|
||||
dy: g('f-grid_dy') || 0,
|
||||
dz: g('f-grid_dz') || 0,
|
||||
item_dx: g('f-grid_idx') || 9,
|
||||
item_dy: g('f-grid_idy') || 9,
|
||||
};
|
||||
}
|
||||
|
||||
// Well
|
||||
if (type === 'plate') {
|
||||
data.well = {
|
||||
size_x: g('f-well_sx') || 8,
|
||||
size_y: g('f-well_sy') || 8,
|
||||
size_z: g('f-well_sz') || 10,
|
||||
max_volume: g('f-well_vol'),
|
||||
material_z_thickness: g('f-well_mzt'),
|
||||
bottom_type: g('f-well_bt') || 'FLAT',
|
||||
cross_section_type: g('f-well_cs') || 'CIRCLE',
|
||||
};
|
||||
if (g('f-has_vf')) {
|
||||
data.volume_functions = {
|
||||
type: 'rectangle',
|
||||
well_length: data.well.size_x,
|
||||
well_width: data.well.size_y,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Tip
|
||||
if (type === 'tip_rack') {
|
||||
data.tip = {
|
||||
spot_size_x: g('f-tip_sx') || 7,
|
||||
spot_size_y: g('f-tip_sy') || 7,
|
||||
spot_size_z: g('f-tip_sz') || 0,
|
||||
tip_volume: g('f-tip_vol') || 300,
|
||||
tip_length: g('f-tip_len') || 60,
|
||||
tip_fitting_depth: g('f-tip_dep') || 51,
|
||||
tip_above_rack_length: g('f-tip_above'),
|
||||
has_filter: g('f-tip_filter') || false,
|
||||
};
|
||||
}
|
||||
|
||||
// Tube
|
||||
if (type === 'tube_rack') {
|
||||
data.tube = {
|
||||
size_x: g('f-tube_sx') || 10.6,
|
||||
size_y: g('f-tube_sy') || 10.6,
|
||||
size_z: g('f-tube_sz') || 40,
|
||||
max_volume: g('f-tube_vol') || 1500,
|
||||
};
|
||||
}
|
||||
|
||||
// Adapter
|
||||
if (type === 'plate_adapter') {
|
||||
data.adapter = {
|
||||
adapter_hole_size_x: g('f-adp_hsx') || 127.76,
|
||||
adapter_hole_size_y: g('f-adp_hsy') || 85.48,
|
||||
adapter_hole_size_z: g('f-adp_hsz') || 10,
|
||||
dx: g('f-adp_dx'),
|
||||
dy: g('f-adp_dy'),
|
||||
dz: g('f-adp_dz') || 0,
|
||||
};
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
// 实时预览 (debounce)
|
||||
let _previewTimer = null;
|
||||
function updatePreview() {
|
||||
if (_previewTimer) clearTimeout(_previewTimer);
|
||||
_previewTimer = setTimeout(() => {
|
||||
const data = collectFormData();
|
||||
const topEl = document.getElementById('svg-topdown');
|
||||
const sideEl = document.getElementById('svg-side');
|
||||
if (topEl) renderTopDown(topEl, data);
|
||||
if (sideEl) renderSideProfile(sideEl, data);
|
||||
}, 200);
|
||||
}
|
||||
|
||||
// 给所有表单元素绑定 input 事件
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const form = document.getElementById('labware-form');
|
||||
if (!form) return;
|
||||
form.addEventListener('input', updatePreview);
|
||||
form.addEventListener('change', updatePreview);
|
||||
|
||||
// tip_above_rack_length 与 dz 互算
|
||||
// 公式: tip_length = tip_above_rack_length + size_z - dz
|
||||
// 规则: 填 tip_above → 自动算 dz;填 dz → 自动算 tip_above
|
||||
// 改 size_z / tip_length → 优先重算 tip_above(若有值),否则算 dz
|
||||
|
||||
function _getVal(id) {
|
||||
const el = document.getElementById(id);
|
||||
return (el && el.value !== '') ? parseFloat(el.value) : null;
|
||||
}
|
||||
function _setVal(id, v) {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.value = Math.round(v * 1000) / 1000;
|
||||
}
|
||||
|
||||
function autoCalcTipAbove(changedId) {
|
||||
const typeEl = document.getElementById('f-type');
|
||||
if (!typeEl || typeEl.value !== 'tip_rack') return;
|
||||
|
||||
const tipLen = _getVal('f-tip_len');
|
||||
const sizeZ = _getVal('f-size_z');
|
||||
const dz = _getVal('f-grid_dz');
|
||||
const above = _getVal('f-tip_above');
|
||||
|
||||
// 需要 tip_length 和 size_z 才能计算
|
||||
if (tipLen == null || sizeZ == null) return;
|
||||
|
||||
if (changedId === 'f-tip_above') {
|
||||
// 用户填了 tip_above → 算 dz
|
||||
if (above != null) _setVal('f-grid_dz', above + sizeZ - tipLen);
|
||||
} else if (changedId === 'f-grid_dz') {
|
||||
// 用户填了 dz → 算 tip_above
|
||||
if (dz != null) _setVal('f-tip_above', tipLen - sizeZ + dz);
|
||||
} else {
|
||||
// size_z 或 tip_length 变了 → 优先重算 tip_above(若已有值或 dz 已有值)
|
||||
if (dz != null) {
|
||||
_setVal('f-tip_above', tipLen - sizeZ + dz);
|
||||
} else if (above != null) {
|
||||
_setVal('f-grid_dz', above + sizeZ - tipLen);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 绑定 input 事件
|
||||
for (const id of ['f-tip_len', 'f-size_z', 'f-grid_dz', 'f-tip_above']) {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.addEventListener('input', () => autoCalcTipAbove(id));
|
||||
}
|
||||
|
||||
// 编辑已有 tip_rack 条目时自动补算 tip_above_rack_length
|
||||
const typeEl = document.getElementById('f-type');
|
||||
if (typeEl && typeEl.value === 'tip_rack') {
|
||||
autoCalcTipAbove('f-grid_dz');
|
||||
}
|
||||
});
|
||||
|
||||
// 自动居中:根据板尺寸和孔阵列参数计算 dx/dy
|
||||
function autoCenter() {
|
||||
const g = id => { const el = document.getElementById(id); return el && el.value !== '' ? parseFloat(el.value) : 0; };
|
||||
|
||||
const sizeX = g('f-size_x') || 127;
|
||||
const sizeY = g('f-size_y') || 85;
|
||||
const nx = g('f-grid_nx') || 1;
|
||||
const ny = g('f-grid_ny') || 1;
|
||||
const itemDx = g('f-grid_idx') || 9;
|
||||
const itemDy = g('f-grid_idy') || 9;
|
||||
|
||||
// 根据当前耗材类型确定子元素尺寸
|
||||
const type = document.getElementById('f-type').value;
|
||||
let childSx = 0, childSy = 0;
|
||||
if (type === 'plate') {
|
||||
childSx = g('f-well_sx') || 8;
|
||||
childSy = g('f-well_sy') || 8;
|
||||
} else if (type === 'tip_rack') {
|
||||
childSx = g('f-tip_sx') || 7;
|
||||
childSy = g('f-tip_sy') || 7;
|
||||
} else if (type === 'tube_rack') {
|
||||
childSx = g('f-tube_sx') || 10.6;
|
||||
childSy = g('f-tube_sy') || 10.6;
|
||||
}
|
||||
|
||||
// dx = (板宽 - 孔阵列总占宽) / 2
|
||||
const dx = (sizeX - (nx - 1) * itemDx - childSx) / 2;
|
||||
const dy = (sizeY - (ny - 1) * itemDy - childSy) / 2;
|
||||
|
||||
const elDx = document.getElementById('f-grid_dx');
|
||||
const elDy = document.getElementById('f-grid_dy');
|
||||
if (elDx) elDx.value = Math.round(dx * 100) / 100;
|
||||
if (elDy) elDy.value = Math.round(dy * 100) / 100;
|
||||
|
||||
updatePreview();
|
||||
}
|
||||
|
||||
// 保存
|
||||
function showMsg(text, ok) {
|
||||
const el = document.getElementById('status-msg');
|
||||
if (!el) return;
|
||||
el.textContent = text;
|
||||
el.className = 'status-msg ' + (ok ? 'msg-ok' : 'msg-err');
|
||||
el.style.display = 'block';
|
||||
setTimeout(() => el.style.display = 'none', 4000);
|
||||
}
|
||||
|
||||
async function saveForm() {
|
||||
const data = collectFormData();
|
||||
|
||||
let url, method;
|
||||
if (typeof IS_NEW !== 'undefined' && IS_NEW) {
|
||||
url = '/api/labware';
|
||||
method = 'POST';
|
||||
} else {
|
||||
url = '/api/labware/' + (typeof ITEM_ID !== 'undefined' ? ITEM_ID : '');
|
||||
method = 'PUT';
|
||||
}
|
||||
|
||||
try {
|
||||
const r = await fetch(url, {
|
||||
method: method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
const d = await r.json();
|
||||
if (d.status === 'ok') {
|
||||
showMsg('保存成功', true);
|
||||
if (IS_NEW) {
|
||||
setTimeout(() => location.href = '/labware/' + data.function_name, 500);
|
||||
}
|
||||
} else {
|
||||
showMsg('保存失败: ' + JSON.stringify(d), false);
|
||||
}
|
||||
} catch (e) {
|
||||
showMsg('请求错误: ' + e.message, false);
|
||||
}
|
||||
}
|
||||
@@ -1,450 +0,0 @@
|
||||
/**
|
||||
* labware_viz.js — PRCXI 耗材 SVG 2D 可视化渲染引擎
|
||||
*
|
||||
* renderTopDown(container, itemData) — 俯视图
|
||||
* renderSideProfile(container, itemData) — 侧面截面图
|
||||
*/
|
||||
|
||||
const TYPE_COLORS = {
|
||||
plate: '#3b82f6',
|
||||
tip_rack: '#10b981',
|
||||
tube_rack: '#f59e0b',
|
||||
trash: '#ef4444',
|
||||
plate_adapter: '#8b5cf6',
|
||||
};
|
||||
|
||||
function _svgNS() { return 'http://www.w3.org/2000/svg'; }
|
||||
|
||||
function _makeSVG(w, h) {
|
||||
const svg = document.createElementNS(_svgNS(), 'svg');
|
||||
svg.setAttribute('viewBox', `0 0 ${w} ${h}`);
|
||||
svg.setAttribute('width', '100%');
|
||||
svg.setAttribute('preserveAspectRatio', 'xMidYMid meet');
|
||||
svg.style.background = '#fff';
|
||||
return svg;
|
||||
}
|
||||
|
||||
function _rect(svg, x, y, w, h, fill, stroke, rx) {
|
||||
const r = document.createElementNS(_svgNS(), 'rect');
|
||||
r.setAttribute('x', x); r.setAttribute('y', y);
|
||||
r.setAttribute('width', w); r.setAttribute('height', h);
|
||||
r.setAttribute('fill', fill || 'none');
|
||||
r.setAttribute('stroke', stroke || '#333');
|
||||
r.setAttribute('stroke-width', '0.5');
|
||||
if (rx) r.setAttribute('rx', rx);
|
||||
svg.appendChild(r);
|
||||
return r;
|
||||
}
|
||||
|
||||
function _circle(svg, cx, cy, r, fill, stroke) {
|
||||
const c = document.createElementNS(_svgNS(), 'circle');
|
||||
c.setAttribute('cx', cx); c.setAttribute('cy', cy);
|
||||
c.setAttribute('r', r);
|
||||
c.setAttribute('fill', fill || 'none');
|
||||
c.setAttribute('stroke', stroke || '#333');
|
||||
c.setAttribute('stroke-width', '0.4');
|
||||
svg.appendChild(c);
|
||||
return c;
|
||||
}
|
||||
|
||||
function _text(svg, x, y, txt, size, anchor, fill) {
|
||||
const t = document.createElementNS(_svgNS(), 'text');
|
||||
t.setAttribute('x', x); t.setAttribute('y', y);
|
||||
t.setAttribute('font-size', size || '3');
|
||||
t.setAttribute('text-anchor', anchor || 'middle');
|
||||
t.setAttribute('fill', fill || '#666');
|
||||
t.setAttribute('font-family', 'sans-serif');
|
||||
t.textContent = txt;
|
||||
svg.appendChild(t);
|
||||
return t;
|
||||
}
|
||||
|
||||
function _line(svg, x1, y1, x2, y2, stroke, dash) {
|
||||
const l = document.createElementNS(_svgNS(), 'line');
|
||||
l.setAttribute('x1', x1); l.setAttribute('y1', y1);
|
||||
l.setAttribute('x2', x2); l.setAttribute('y2', y2);
|
||||
l.setAttribute('stroke', stroke || '#999');
|
||||
l.setAttribute('stroke-width', '0.3');
|
||||
if (dash) l.setAttribute('stroke-dasharray', dash);
|
||||
svg.appendChild(l);
|
||||
return l;
|
||||
}
|
||||
|
||||
function _title(el, txt) {
|
||||
const t = document.createElementNS(_svgNS(), 'title');
|
||||
t.textContent = txt;
|
||||
el.appendChild(t);
|
||||
}
|
||||
|
||||
// ==================== 俯视图 ====================
|
||||
function renderTopDown(container, data) {
|
||||
container.innerHTML = '';
|
||||
if (!data) return;
|
||||
|
||||
const pad = 18;
|
||||
const sx = data.size_x || 127;
|
||||
const sy = data.size_y || 85;
|
||||
const w = sx + pad * 2;
|
||||
const h = sy + pad * 2;
|
||||
const svg = _makeSVG(w, h);
|
||||
|
||||
const color = TYPE_COLORS[data.type] || '#3b82f6';
|
||||
const lightColor = color + '22';
|
||||
|
||||
// 板子外轮廓
|
||||
_rect(svg, pad, pad, sx, sy, lightColor, color, 3);
|
||||
|
||||
// 尺寸标注
|
||||
_text(svg, pad + sx / 2, pad - 4, `${sx} mm`, '3.5', 'middle', '#333');
|
||||
// Y 尺寸 (竖直)
|
||||
const yt = document.createElementNS(_svgNS(), 'text');
|
||||
yt.setAttribute('x', pad - 5);
|
||||
yt.setAttribute('y', pad + sy / 2);
|
||||
yt.setAttribute('font-size', '3.5');
|
||||
yt.setAttribute('text-anchor', 'middle');
|
||||
yt.setAttribute('fill', '#333');
|
||||
yt.setAttribute('font-family', 'sans-serif');
|
||||
yt.setAttribute('transform', `rotate(-90, ${pad - 5}, ${pad + sy / 2})`);
|
||||
yt.textContent = `${sy} mm`;
|
||||
svg.appendChild(yt);
|
||||
|
||||
const grid = data.grid;
|
||||
const well = data.well;
|
||||
const tip = data.tip;
|
||||
const tube = data.tube;
|
||||
|
||||
if (grid && (well || tip || tube)) {
|
||||
const nx = grid.num_items_x || 1;
|
||||
const ny = grid.num_items_y || 1;
|
||||
const dx = grid.dx || 0;
|
||||
const dy = grid.dy || 0;
|
||||
const idx = grid.item_dx || 9;
|
||||
const idy = grid.item_dy || 9;
|
||||
|
||||
const child = well || tip || tube;
|
||||
const csx = child.size_x || child.spot_size_x || 8;
|
||||
const csy = child.size_y || child.spot_size_y || 8;
|
||||
|
||||
const isCircle = well ? (well.cross_section_type === 'CIRCLE') : (!!tip);
|
||||
|
||||
// 行列标签
|
||||
for (let col = 0; col < nx; col++) {
|
||||
const cx = pad + dx + csx / 2 + col * idx;
|
||||
_text(svg, cx, pad + sy + 5, String(col + 1), '2.5', 'middle', '#999');
|
||||
}
|
||||
const rowLabels = 'ABCDEFGHIJKLMNOP';
|
||||
for (let row = 0; row < ny; row++) {
|
||||
const cy = pad + dy + csy / 2 + row * idy;
|
||||
_text(svg, pad - 4, cy + 1, rowLabels[row] || String(row), '2.5', 'middle', '#999');
|
||||
}
|
||||
|
||||
// 绘制孔位
|
||||
for (let col = 0; col < nx; col++) {
|
||||
for (let row = 0; row < ny; row++) {
|
||||
const cx = pad + dx + csx / 2 + col * idx;
|
||||
const cy = pad + dy + csy / 2 + row * idy;
|
||||
|
||||
let el;
|
||||
if (isCircle) {
|
||||
const r = Math.min(csx, csy) / 2;
|
||||
el = _circle(svg, cx, cy, r, '#fff', color);
|
||||
} else {
|
||||
el = _rect(svg, cx - csx / 2, cy - csy / 2, csx, csy, '#fff', color);
|
||||
}
|
||||
|
||||
const label = (rowLabels[row] || '') + String(col + 1);
|
||||
_title(el, `${label}: ${csx}x${csy} mm`);
|
||||
|
||||
// hover 效果
|
||||
el.style.cursor = 'pointer';
|
||||
el.addEventListener('mouseenter', () => el.setAttribute('fill', color + '44'));
|
||||
el.addEventListener('mouseleave', () => el.setAttribute('fill', '#fff'));
|
||||
}
|
||||
}
|
||||
|
||||
// dx / dy 标注(板边到首个子元素左上角)
|
||||
const dimColor = '#e67e22';
|
||||
const firstLeft = pad + dx; // 首列子元素左边 X
|
||||
const firstTop = pad + dy; // 首行子元素上边 Y
|
||||
if (dx > 0.1) {
|
||||
// dx: 板左边 → 首列子元素左边,画在第一行子元素中心高度
|
||||
const annY = firstTop + csy / 2;
|
||||
_line(svg, pad, annY, firstLeft, annY, dimColor, '1,1');
|
||||
_line(svg, pad, annY - 2, pad, annY + 2, dimColor);
|
||||
_line(svg, firstLeft, annY - 2, firstLeft, annY + 2, dimColor);
|
||||
_text(svg, pad + dx / 2, annY - 2, `dx=${dx}`, '2.5', 'middle', dimColor);
|
||||
}
|
||||
if (dy > 0.1) {
|
||||
// dy: 板上边 → 首行子元素上边,画在第一列子元素中心宽度
|
||||
const annX = firstLeft + csx / 2;
|
||||
_line(svg, annX, pad, annX, firstTop, dimColor, '1,1');
|
||||
_line(svg, annX - 2, pad, annX + 2, pad, dimColor);
|
||||
_line(svg, annX - 2, firstTop, annX + 2, firstTop, dimColor);
|
||||
_text(svg, annX + 4, pad + dy / 2 + 1, `dy=${dy}`, '2.5', 'start', dimColor);
|
||||
}
|
||||
} else if (data.type === 'plate_adapter' && data.adapter) {
|
||||
// 绘制适配器凹槽
|
||||
const adp = data.adapter;
|
||||
const ahx = adp.adapter_hole_size_x || 127;
|
||||
const ahy = adp.adapter_hole_size_y || 85;
|
||||
const adx = adp.dx != null ? adp.dx : (sx - ahx) / 2;
|
||||
const ady = adp.dy != null ? adp.dy : (sy - ahy) / 2;
|
||||
_rect(svg, pad + adx, pad + ady, ahx, ahy, '#f0f0ff', '#8b5cf6', 2);
|
||||
_text(svg, pad + adx + ahx / 2, pad + ady + ahy / 2, `${ahx}x${ahy}`, '4', 'middle', '#8b5cf6');
|
||||
} else if (data.type === 'trash') {
|
||||
// 简单标记
|
||||
_text(svg, pad + sx / 2, pad + sy / 2, 'TRASH', '8', 'middle', '#ef4444');
|
||||
}
|
||||
|
||||
container.appendChild(svg);
|
||||
_enableZoomPan(svg, `0 0 ${w} ${h}`);
|
||||
}
|
||||
|
||||
// ==================== 侧面截面图 ====================
|
||||
function renderSideProfile(container, data) {
|
||||
container.innerHTML = '';
|
||||
if (!data) return;
|
||||
|
||||
const pad = 20;
|
||||
const sx = data.size_x || 127;
|
||||
const sz = data.size_z || 20;
|
||||
|
||||
// 按比例缩放,侧面以 X-Z 面
|
||||
const scaleH = Math.max(1, sz / 60); // 让较矮的板子不会太小
|
||||
|
||||
// 计算枪头露出高度(仅 tip_rack)
|
||||
const tip = data.tip;
|
||||
const grid = data.grid;
|
||||
let tipAbove = 0;
|
||||
if (data.type === 'tip_rack' && tip) {
|
||||
if (tip.tip_above_rack_length != null && tip.tip_above_rack_length > 0) {
|
||||
tipAbove = tip.tip_above_rack_length;
|
||||
} else if (tip.tip_length && grid) {
|
||||
const dz = grid.dz || 0;
|
||||
const calc = tip.tip_length - (sz - dz);
|
||||
if (calc > 0) tipAbove = calc;
|
||||
}
|
||||
}
|
||||
|
||||
const drawW = sx;
|
||||
const drawH = sz;
|
||||
const w = drawW + pad * 2 + 30; // 额外空间给标注
|
||||
const h = drawH + tipAbove + pad * 2 + 10;
|
||||
const svg = _makeSVG(w, h);
|
||||
|
||||
const color = TYPE_COLORS[data.type] || '#3b82f6';
|
||||
const baseY = pad + tipAbove + drawH; // 底部 Y
|
||||
const rackTopY = pad + tipAbove; // rack 顶部 Y
|
||||
|
||||
// 板壳矩形
|
||||
_rect(svg, pad, rackTopY, drawW, drawH, color + '15', color);
|
||||
|
||||
// 尺寸标注
|
||||
// X 方向
|
||||
_line(svg, pad, baseY + 5, pad + drawW, baseY + 5, '#333');
|
||||
_text(svg, pad + drawW / 2, baseY + 12, `${sx} mm`, '3.5', 'middle', '#333');
|
||||
|
||||
// Z 方向
|
||||
_line(svg, pad + drawW + 5, rackTopY, pad + drawW + 5, baseY, '#333');
|
||||
const zt = document.createElementNS(_svgNS(), 'text');
|
||||
zt.setAttribute('x', pad + drawW + 12);
|
||||
zt.setAttribute('y', rackTopY + drawH / 2);
|
||||
zt.setAttribute('font-size', '3.5');
|
||||
zt.setAttribute('text-anchor', 'middle');
|
||||
zt.setAttribute('fill', '#333');
|
||||
zt.setAttribute('font-family', 'sans-serif');
|
||||
zt.setAttribute('transform', `rotate(-90, ${pad + drawW + 12}, ${rackTopY + drawH / 2})`);
|
||||
zt.textContent = `${sz} mm`;
|
||||
svg.appendChild(zt);
|
||||
|
||||
const well = data.well;
|
||||
const tube = data.tube;
|
||||
|
||||
if (grid && (well || tip || tube)) {
|
||||
const dx = grid.dx || 0;
|
||||
const dz = grid.dz || 0;
|
||||
const idx = grid.item_dx || 9;
|
||||
const nx = Math.min(grid.num_items_x || 1, 24); // 最多画24列
|
||||
const dimColor = '#e67e22';
|
||||
|
||||
const child = well || tube;
|
||||
const childTip = tip;
|
||||
|
||||
if (child) {
|
||||
const csx = child.size_x || 8;
|
||||
const csz = child.size_z || 10;
|
||||
const bt = well ? (well.bottom_type || 'FLAT') : 'FLAT';
|
||||
|
||||
// 画几个代表性的孔截面
|
||||
const nDraw = Math.min(nx, 12);
|
||||
for (let i = 0; i < nDraw; i++) {
|
||||
const cx = pad + dx + csx / 2 + i * idx;
|
||||
const topZ = baseY - dz - csz;
|
||||
const botZ = baseY - dz;
|
||||
|
||||
// 孔壁
|
||||
_rect(svg, cx - csx / 2, topZ, csx, csz, '#e0e8ff', color, 0.5);
|
||||
|
||||
// 底部形状
|
||||
if (bt === 'V') {
|
||||
// V 底 三角
|
||||
const triH = Math.min(csx / 2, csz * 0.3);
|
||||
const p = document.createElementNS(_svgNS(), 'polygon');
|
||||
p.setAttribute('points',
|
||||
`${cx - csx / 2},${botZ - triH} ${cx},${botZ} ${cx + csx / 2},${botZ - triH}`);
|
||||
p.setAttribute('fill', color + '33');
|
||||
p.setAttribute('stroke', color);
|
||||
p.setAttribute('stroke-width', '0.3');
|
||||
svg.appendChild(p);
|
||||
} else if (bt === 'U') {
|
||||
// U 底 圆弧
|
||||
const arcR = csx / 2;
|
||||
const p = document.createElementNS(_svgNS(), 'path');
|
||||
p.setAttribute('d', `M ${cx - csx / 2} ${botZ - arcR} A ${arcR} ${arcR} 0 0 0 ${cx + csx / 2} ${botZ - arcR}`);
|
||||
p.setAttribute('fill', color + '33');
|
||||
p.setAttribute('stroke', color);
|
||||
p.setAttribute('stroke-width', '0.3');
|
||||
svg.appendChild(p);
|
||||
}
|
||||
}
|
||||
|
||||
// dz 标注
|
||||
if (dz > 0) {
|
||||
const lx = pad + dx + 0.5 * idx * nDraw + csx / 2 + 5;
|
||||
_line(svg, lx, baseY, lx, baseY - dz, dimColor, '1,1');
|
||||
_line(svg, lx - 2, baseY, lx + 2, baseY, dimColor);
|
||||
_line(svg, lx - 2, baseY - dz, lx + 2, baseY - dz, dimColor);
|
||||
_text(svg, lx + 4, baseY - dz / 2 + 1, `dz=${dz}`, '2.5', 'start', dimColor);
|
||||
}
|
||||
// dx 标注
|
||||
if (dx > 0.1) {
|
||||
const annY = rackTopY + 4;
|
||||
_line(svg, pad, annY, pad + dx, annY, dimColor, '1,1');
|
||||
_line(svg, pad, annY - 2, pad, annY + 2, dimColor);
|
||||
_line(svg, pad + dx, annY - 2, pad + dx, annY + 2, dimColor);
|
||||
_text(svg, pad + dx / 2, annY - 2, `dx=${dx}`, '2.5', 'middle', dimColor);
|
||||
}
|
||||
}
|
||||
|
||||
if (childTip) {
|
||||
// 枪头截面
|
||||
const tipLen = childTip.tip_length || 50;
|
||||
const nDraw = Math.min(nx, 12);
|
||||
for (let i = 0; i < nDraw; i++) {
|
||||
const cx = pad + dx + 3.5 + i * idx;
|
||||
// 枪头顶部 = rack顶部 - 露出长度
|
||||
const tipTopZ = rackTopY - tipAbove;
|
||||
const drawLen = Math.min(tipLen, sz - dz + tipAbove);
|
||||
|
||||
// 枪头轮廓 (梯形)
|
||||
const topW = 4;
|
||||
const botW = 1.5;
|
||||
const p = document.createElementNS(_svgNS(), 'polygon');
|
||||
p.setAttribute('points',
|
||||
`${cx - topW / 2},${tipTopZ} ${cx + topW / 2},${tipTopZ} ${cx + botW / 2},${tipTopZ + drawLen} ${cx - botW / 2},${tipTopZ + drawLen}`);
|
||||
p.setAttribute('fill', '#10b98133');
|
||||
p.setAttribute('stroke', '#10b981');
|
||||
p.setAttribute('stroke-width', '0.3');
|
||||
svg.appendChild(p);
|
||||
}
|
||||
|
||||
// dz 标注
|
||||
if (dz > 0) {
|
||||
const lx = pad + dx + nDraw * idx + 5;
|
||||
_line(svg, lx, baseY, lx, baseY - dz, dimColor, '1,1');
|
||||
_line(svg, lx - 2, baseY, lx + 2, baseY, dimColor);
|
||||
_line(svg, lx - 2, baseY - dz, lx + 2, baseY - dz, dimColor);
|
||||
_text(svg, lx + 4, baseY - dz / 2 + 1, `dz=${dz}`, '2.5', 'start', dimColor);
|
||||
}
|
||||
// dx 标注
|
||||
if (dx > 0.1) {
|
||||
const annY = rackTopY + 4;
|
||||
_line(svg, pad, annY, pad + dx, annY, dimColor, '1,1');
|
||||
_line(svg, pad, annY - 2, pad, annY + 2, dimColor);
|
||||
_line(svg, pad + dx, annY - 2, pad + dx, annY + 2, dimColor);
|
||||
_text(svg, pad + dx / 2, annY - 2, `dx=${dx}`, '2.5', 'middle', dimColor);
|
||||
}
|
||||
|
||||
// 露出长度标注线
|
||||
if (tipAbove > 0) {
|
||||
const annotX = pad + dx + nDraw * idx + 8;
|
||||
// rack 顶部水平参考线
|
||||
_line(svg, annotX - 3, rackTopY, annotX + 3, rackTopY, '#10b981');
|
||||
// 枪头顶部水平参考线
|
||||
_line(svg, annotX - 3, rackTopY - tipAbove, annotX + 3, rackTopY - tipAbove, '#10b981');
|
||||
// 竖直标注线
|
||||
_line(svg, annotX, rackTopY - tipAbove, annotX, rackTopY, '#10b981', '1,1');
|
||||
_text(svg, annotX + 2, rackTopY - tipAbove / 2 + 1, `露出=${Math.round(tipAbove * 100) / 100}mm`, '2.5', 'start', '#10b981');
|
||||
}
|
||||
}
|
||||
} else if (data.type === 'plate_adapter' && data.adapter) {
|
||||
const adp = data.adapter;
|
||||
const ahz = adp.adapter_hole_size_z || 10;
|
||||
const adz = adp.dz || 0;
|
||||
const adx_val = adp.dx != null ? adp.dx : (sx - (adp.adapter_hole_size_x || 127)) / 2;
|
||||
const ahx = adp.adapter_hole_size_x || 127;
|
||||
|
||||
// 凹槽截面
|
||||
_rect(svg, pad + adx_val, rackTopY + adz, ahx, ahz, '#ede9fe', '#8b5cf6');
|
||||
_text(svg, pad + adx_val + ahx / 2, rackTopY + adz + ahz / 2 + 1, `hole: ${ahz}mm deep`, '3', 'middle', '#8b5cf6');
|
||||
} else if (data.type === 'trash') {
|
||||
_text(svg, pad + drawW / 2, rackTopY + drawH / 2, 'TRASH', '8', 'middle', '#ef4444');
|
||||
}
|
||||
|
||||
container.appendChild(svg);
|
||||
_enableZoomPan(svg, `0 0 ${w} ${h}`);
|
||||
}
|
||||
|
||||
// ==================== 缩放 & 平移 ====================
|
||||
function _enableZoomPan(svgEl, origViewBox) {
|
||||
const parts = origViewBox.split(' ').map(Number);
|
||||
let vx = parts[0], vy = parts[1], vw = parts[2], vh = parts[3];
|
||||
const origVx = vx, origVy = vy, origW = vw, origH = vh;
|
||||
const MIN_SCALE = 0.5, MAX_SCALE = 5;
|
||||
|
||||
function applyViewBox() {
|
||||
svgEl.setAttribute('viewBox', `${vx} ${vy} ${vw} ${vh}`);
|
||||
}
|
||||
|
||||
function resetView() {
|
||||
vx = origVx; vy = origVy; vw = origW; vh = origH;
|
||||
applyViewBox();
|
||||
}
|
||||
|
||||
// 将 resetView 挂到 svg 元素上,方便外部调用
|
||||
svgEl._resetView = resetView;
|
||||
|
||||
svgEl.addEventListener('wheel', function (e) {
|
||||
e.preventDefault();
|
||||
if (e.ctrlKey) {
|
||||
// pinch / ctrl+scroll → 缩放
|
||||
const factor = e.deltaY > 0 ? 1.08 : 1 / 1.08;
|
||||
const newW = vw * factor;
|
||||
const newH = vh * factor;
|
||||
// 限制缩放范围
|
||||
if (newW < origW / MAX_SCALE || newW > origW * (1 / MIN_SCALE)) return;
|
||||
// 以鼠标位置为缩放中心
|
||||
const rect = svgEl.getBoundingClientRect();
|
||||
const mx = (e.clientX - rect.left) / rect.width;
|
||||
const my = (e.clientY - rect.top) / rect.height;
|
||||
vx += (vw - newW) * mx;
|
||||
vy += (vh - newH) * my;
|
||||
vw = newW;
|
||||
vh = newH;
|
||||
} else {
|
||||
// 普通滚轮 → 平移
|
||||
const panSpeed = vw * 0.002;
|
||||
vx += e.deltaX * panSpeed;
|
||||
vy += e.deltaY * panSpeed;
|
||||
}
|
||||
applyViewBox();
|
||||
}, { passive: false });
|
||||
}
|
||||
|
||||
// 回中按钮:重置指定容器内 SVG 的 viewBox
|
||||
function resetSvgView(containerId) {
|
||||
const container = document.getElementById(containerId);
|
||||
if (!container) return;
|
||||
const svg = container.querySelector('svg');
|
||||
if (svg && svg._resetView) svg._resetView();
|
||||
}
|
||||
@@ -1,295 +0,0 @@
|
||||
/* PRCXI 耗材管理 - 全局样式 */
|
||||
|
||||
:root {
|
||||
--c-primary: #3b82f6;
|
||||
--c-primary-dark: #2563eb;
|
||||
--c-danger: #ef4444;
|
||||
--c-warning: #f59e0b;
|
||||
--c-success: #10b981;
|
||||
--c-gray-50: #f9fafb;
|
||||
--c-gray-100: #f3f4f6;
|
||||
--c-gray-200: #e5e7eb;
|
||||
--c-gray-300: #d1d5db;
|
||||
--c-gray-500: #6b7280;
|
||||
--c-gray-700: #374151;
|
||||
--c-gray-900: #111827;
|
||||
--radius: 8px;
|
||||
--shadow: 0 1px 3px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: var(--c-gray-50);
|
||||
color: var(--c-gray-900);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* 顶部导航 */
|
||||
.topbar {
|
||||
background: #fff;
|
||||
border-bottom: 1px solid var(--c-gray-200);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 24px;
|
||||
height: 56px;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
}
|
||||
.topbar .logo {
|
||||
font-weight: 700;
|
||||
font-size: 1.1rem;
|
||||
color: var(--c-gray-900);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
/* 容器 */
|
||||
.container {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
/* 页头 */
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 24px;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
.page-header h1 { font-size: 1.5rem; }
|
||||
.header-actions { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||
|
||||
/* 按钮 */
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 8px 16px;
|
||||
border-radius: 6px;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
border: 1px solid transparent;
|
||||
text-decoration: none;
|
||||
transition: all 0.15s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.btn-sm { padding: 4px 10px; font-size: 0.8rem; }
|
||||
.btn-primary { background: var(--c-primary); color: #fff; }
|
||||
.btn-primary:hover { background: var(--c-primary-dark); }
|
||||
.btn-outline { background: #fff; color: var(--c-gray-700); border-color: var(--c-gray-300); }
|
||||
.btn-outline:hover { background: var(--c-gray-100); }
|
||||
.btn-danger { background: var(--c-danger); color: #fff; }
|
||||
.btn-danger:hover { background: #dc2626; }
|
||||
.btn-warning { background: var(--c-warning); color: #fff; }
|
||||
.btn-warning:hover { background: #d97706; }
|
||||
|
||||
/* 徽章 */
|
||||
.badge {
|
||||
background: var(--c-gray-200);
|
||||
color: var(--c-gray-700);
|
||||
font-size: 0.8rem;
|
||||
padding: 2px 8px;
|
||||
border-radius: 12px;
|
||||
font-weight: 500;
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
/* 状态消息 */
|
||||
.status-msg {
|
||||
padding: 12px 16px;
|
||||
border-radius: var(--radius);
|
||||
margin-bottom: 16px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.msg-ok { background: #d1fae5; color: #065f46; }
|
||||
.msg-err { background: #fee2e2; color: #991b1b; }
|
||||
|
||||
/* 类型分段 */
|
||||
.type-section { margin-bottom: 32px; }
|
||||
.type-section h2 {
|
||||
font-size: 1.1rem;
|
||||
margin-bottom: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.type-dot {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
/* 卡片网格 */
|
||||
.card-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
/* 耗材卡片 */
|
||||
.labware-card {
|
||||
background: #fff;
|
||||
border: 1px solid var(--c-gray-200);
|
||||
border-radius: var(--radius);
|
||||
padding: 16px;
|
||||
cursor: pointer;
|
||||
transition: box-shadow 0.2s, border-color 0.2s;
|
||||
}
|
||||
.labware-card:hover {
|
||||
border-color: var(--c-primary);
|
||||
box-shadow: 0 4px 12px rgba(59,130,246,0.15);
|
||||
}
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.card-title {
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
color: var(--c-gray-900);
|
||||
word-break: break-all;
|
||||
}
|
||||
.card-body { font-size: 0.85rem; color: var(--c-gray-500); }
|
||||
.card-info { margin-bottom: 2px; }
|
||||
.card-info .label { color: var(--c-gray-700); font-weight: 500; }
|
||||
.card-footer {
|
||||
margin-top: 12px;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
border-top: 1px solid var(--c-gray-100);
|
||||
padding-top: 10px;
|
||||
}
|
||||
|
||||
/* 标签 */
|
||||
.tag {
|
||||
font-size: 0.7rem;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.tag-tpl { background: #dbeafe; color: #1e40af; }
|
||||
.tag-plate { background: #dbeafe; color: #1e40af; }
|
||||
.tag-tip_rack { background: #d1fae5; color: #065f46; }
|
||||
.tag-trash { background: #fee2e2; color: #991b1b; }
|
||||
.tag-tube_rack { background: #fef3c7; color: #92400e; }
|
||||
.tag-plate_adapter { background: #ede9fe; color: #5b21b6; }
|
||||
|
||||
/* 详情页布局 */
|
||||
.detail-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 24px;
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
.detail-layout { grid-template-columns: 1fr; }
|
||||
}
|
||||
.detail-info, .detail-viz { display: flex; flex-direction: column; gap: 16px; }
|
||||
|
||||
.info-card, .viz-card {
|
||||
background: #fff;
|
||||
border: 1px solid var(--c-gray-200);
|
||||
border-radius: var(--radius);
|
||||
padding: 16px;
|
||||
}
|
||||
.info-card h3, .viz-card h3 {
|
||||
font-size: 0.95rem;
|
||||
margin-bottom: 12px;
|
||||
color: var(--c-gray-700);
|
||||
border-bottom: 1px solid var(--c-gray-100);
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
.info-table { width: 100%; font-size: 0.85rem; }
|
||||
.info-table td { padding: 4px 8px; border-bottom: 1px solid var(--c-gray-100); }
|
||||
.info-table .label { color: var(--c-gray-500); font-weight: 500; width: 140px; }
|
||||
.info-table code { background: var(--c-gray-100); padding: 1px 4px; border-radius: 3px; font-size: 0.8rem; }
|
||||
.info-table code.small { font-size: 0.7rem; }
|
||||
|
||||
/* SVG 容器 */
|
||||
#svg-topdown, #svg-side {
|
||||
width: 100%;
|
||||
min-height: 200px;
|
||||
overflow: hidden;
|
||||
}
|
||||
#svg-topdown svg, #svg-side svg {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
/* 编辑页布局 */
|
||||
.edit-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 24px;
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
.edit-layout { grid-template-columns: 1fr; }
|
||||
}
|
||||
.edit-form { display: flex; flex-direction: column; gap: 16px; }
|
||||
.edit-preview { display: flex; flex-direction: column; gap: 16px; position: sticky; top: 72px; align-self: start; }
|
||||
|
||||
/* 表单 */
|
||||
.form-section {
|
||||
background: #fff;
|
||||
border: 1px solid var(--c-gray-200);
|
||||
border-radius: var(--radius);
|
||||
padding: 16px;
|
||||
}
|
||||
.form-section h3 {
|
||||
font-size: 0.95rem;
|
||||
margin-bottom: 12px;
|
||||
color: var(--c-gray-700);
|
||||
}
|
||||
.form-row { margin-bottom: 10px; }
|
||||
.form-row label { display: block; font-size: 0.8rem; color: var(--c-gray-500); margin-bottom: 4px; font-weight: 500; }
|
||||
.form-row input, .form-row select, .form-row textarea {
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--c-gray-300);
|
||||
border-radius: 6px;
|
||||
font-size: 0.85rem;
|
||||
font-family: inherit;
|
||||
}
|
||||
.form-row input:focus, .form-row select:focus, .form-row textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--c-primary);
|
||||
box-shadow: 0 0 0 3px rgba(59,130,246,0.1);
|
||||
}
|
||||
.form-row-2, .form-row-3 { display: grid; gap: 12px; margin-bottom: 10px; }
|
||||
.form-row-2 { grid-template-columns: 1fr 1fr; }
|
||||
.form-row-3 { grid-template-columns: 1fr 1fr 1fr; }
|
||||
.form-row-2 label, .form-row-3 label { display: block; font-size: 0.8rem; color: var(--c-gray-500); margin-bottom: 4px; font-weight: 500; }
|
||||
.form-row-2 input, .form-row-2 select,
|
||||
.form-row-3 input, .form-row-3 select {
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--c-gray-300);
|
||||
border-radius: 6px;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.form-row-2 input:focus, .form-row-3 input:focus {
|
||||
outline: none;
|
||||
border-color: var(--c-primary);
|
||||
box-shadow: 0 0 0 3px rgba(59,130,246,0.1);
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
/* 双语标签中文部分 */
|
||||
.label-cn { color: var(--c-gray-400, #9ca3af); font-weight: 400; margin-left: 4px; }
|
||||
@@ -1,24 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}PRCXI 耗材管理{% endblock %}</title>
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
{% block head_extra %}{% endblock %}
|
||||
</head>
|
||||
<body>
|
||||
<nav class="topbar">
|
||||
<a href="/" class="logo">PRCXI 耗材管理</a>
|
||||
<div class="nav-actions">
|
||||
<a href="/labware/new" class="btn btn-primary btn-sm">+ 新建耗材</a>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main class="container">
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
|
||||
{% block scripts %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,159 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ item.function_name }} - PRCXI{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<h1>{{ item.function_name }}</h1>
|
||||
<div class="header-actions">
|
||||
<a href="/labware/{{ item.function_name }}/edit" class="btn btn-primary">编辑</a>
|
||||
<a href="/" class="btn btn-outline">返回列表</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-layout">
|
||||
<!-- 左侧: 信息 -->
|
||||
<div class="detail-info">
|
||||
<div class="info-card">
|
||||
<h3>基本信息</h3>
|
||||
<table class="info-table">
|
||||
<tr><td class="label">类型</td><td><span class="tag tag-{{ item.type }}">{{ item.type }}</span></td></tr>
|
||||
<tr><td class="label">函数名</td><td><code>{{ item.function_name }}</code></td></tr>
|
||||
<tr><td class="label">Model</td><td>{{ item.model or '-' }}</td></tr>
|
||||
{% if item.plate_type %}
|
||||
<tr><td class="label">Plate Type</td><td>{{ item.plate_type }}</td></tr>
|
||||
{% endif %}
|
||||
<tr><td class="label">Docstring</td><td>{{ item.docstring or '-' }}</td></tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="info-card">
|
||||
<h3>物理尺寸 (mm)</h3>
|
||||
<table class="info-table">
|
||||
<tr><td class="label">X</td><td>{{ item.size_x }}</td></tr>
|
||||
<tr><td class="label">Y</td><td>{{ item.size_y }}</td></tr>
|
||||
<tr><td class="label">Z</td><td>{{ item.size_z }}</td></tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="info-card">
|
||||
<h3>材料信息</h3>
|
||||
<table class="info-table">
|
||||
<tr><td class="label">UUID</td><td><code class="small">{{ item.material_info.uuid }}</code></td></tr>
|
||||
<tr><td class="label">Code</td><td>{{ item.material_info.Code }}</td></tr>
|
||||
<tr><td class="label">Name</td><td>{{ item.material_info.Name }}</td></tr>
|
||||
{% if item.material_info.materialEnum is not none %}
|
||||
<tr><td class="label">materialEnum</td><td>{{ item.material_info.materialEnum }}</td></tr>
|
||||
{% endif %}
|
||||
{% if item.material_info.SupplyType is not none %}
|
||||
<tr><td class="label">SupplyType</td><td>{{ item.material_info.SupplyType }}</td></tr>
|
||||
{% endif %}
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{% if item.grid %}
|
||||
<div class="info-card">
|
||||
<h3>网格排列</h3>
|
||||
<table class="info-table">
|
||||
<tr><td class="label">列 x 行</td><td>{{ item.grid.num_items_x }} x {{ item.grid.num_items_y }}</td></tr>
|
||||
<tr><td class="label">dx, dy, dz</td><td>{{ item.grid.dx }}, {{ item.grid.dy }}, {{ item.grid.dz }}</td></tr>
|
||||
<tr><td class="label">item_dx, item_dy</td><td>{{ item.grid.item_dx }}, {{ item.grid.item_dy }}</td></tr>
|
||||
</table>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if item.well %}
|
||||
<div class="info-card">
|
||||
<h3>孔参数 (Well)</h3>
|
||||
<table class="info-table">
|
||||
<tr><td class="label">尺寸</td><td>{{ item.well.size_x }} x {{ item.well.size_y }} x {{ item.well.size_z }}</td></tr>
|
||||
{% if item.well.max_volume is not none %}
|
||||
<tr><td class="label">最大体积</td><td>{{ item.well.max_volume }} uL</td></tr>
|
||||
{% endif %}
|
||||
<tr><td class="label">底部类型</td><td>{{ item.well.bottom_type }}</td></tr>
|
||||
<tr><td class="label">截面类型</td><td>{{ item.well.cross_section_type }}</td></tr>
|
||||
{% if item.well.material_z_thickness is not none %}
|
||||
<tr><td class="label">材料Z厚度</td><td>{{ item.well.material_z_thickness }}</td></tr>
|
||||
{% endif %}
|
||||
</table>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if item.tip %}
|
||||
<div class="info-card">
|
||||
<h3>枪头参数 (Tip)</h3>
|
||||
<table class="info-table">
|
||||
<tr><td class="label">Spot 尺寸</td><td>{{ item.tip.spot_size_x }} x {{ item.tip.spot_size_y }} x {{ item.tip.spot_size_z }}</td></tr>
|
||||
<tr><td class="label">容量</td><td>{{ item.tip.tip_volume }} uL</td></tr>
|
||||
<tr><td class="label">长度</td><td>{{ item.tip.tip_length }} mm</td></tr>
|
||||
<tr><td class="label">取枪头插入深度</td><td>{{ item.tip.tip_fitting_depth }} mm</td></tr>
|
||||
{% if item.tip.tip_above_rack_length is not none %}
|
||||
<tr><td class="label">枪头露出枪头盒长度</td><td>{{ item.tip.tip_above_rack_length }} mm</td></tr>
|
||||
{% endif %}
|
||||
<tr><td class="label">有滤芯</td><td>{{ item.tip.has_filter }}</td></tr>
|
||||
</table>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if item.tube %}
|
||||
<div class="info-card">
|
||||
<h3>管参数 (Tube)</h3>
|
||||
<table class="info-table">
|
||||
<tr><td class="label">尺寸</td><td>{{ item.tube.size_x }} x {{ item.tube.size_y }} x {{ item.tube.size_z }}</td></tr>
|
||||
<tr><td class="label">最大体积</td><td>{{ item.tube.max_volume }} uL</td></tr>
|
||||
</table>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if item.adapter %}
|
||||
<div class="info-card">
|
||||
<h3>适配器参数</h3>
|
||||
<table class="info-table">
|
||||
<tr><td class="label">Hole 尺寸</td><td>{{ item.adapter.adapter_hole_size_x }} x {{ item.adapter.adapter_hole_size_y }} x {{ item.adapter.adapter_hole_size_z }}</td></tr>
|
||||
<tr><td class="label">dx, dy, dz</td><td>{{ item.adapter.dx }}, {{ item.adapter.dy }}, {{ item.adapter.dz }}</td></tr>
|
||||
</table>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="info-card">
|
||||
<h3>Registry</h3>
|
||||
<table class="info-table">
|
||||
<tr><td class="label">分类</td><td>{{ item.registry_category | join(' / ') }}</td></tr>
|
||||
<tr><td class="label">描述</td><td>{{ item.registry_description }}</td></tr>
|
||||
<tr><td class="label">模板匹配</td><td>{{ item.include_in_template_matching }}</td></tr>
|
||||
{% if item.template_kind %}
|
||||
<tr><td class="label">模板类型</td><td>{{ item.template_kind }}</td></tr>
|
||||
{% endif %}
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右侧: 可视化 -->
|
||||
<div class="detail-viz">
|
||||
<div class="viz-card">
|
||||
<h3 style="display:flex;align-items:center;justify-content:space-between;">
|
||||
俯视图 (Top-Down)
|
||||
<button type="button" class="btn btn-sm btn-outline" onclick="resetSvgView('svg-topdown')">回中</button>
|
||||
</h3>
|
||||
<div id="svg-topdown"></div>
|
||||
</div>
|
||||
<div class="viz-card">
|
||||
<h3 style="display:flex;align-items:center;justify-content:space-between;">
|
||||
侧面截面图 (Side Profile)
|
||||
<button type="button" class="btn btn-sm btn-outline" onclick="resetSvgView('svg-side')">回中</button>
|
||||
</h3>
|
||||
<div id="svg-side"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script src="/static/labware_viz.js"></script>
|
||||
<script>
|
||||
const itemData = {{ item.model_dump() | tojson }};
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
renderTopDown(document.getElementById('svg-topdown'), itemData);
|
||||
renderSideProfile(document.getElementById('svg-side'), itemData);
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -1,257 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{% if is_new %}新建耗材{% else %}编辑 {{ item.function_name }}{% endif %} - PRCXI{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<h1>{% if is_new %}新建耗材{% else %}编辑 {{ item.function_name }}{% endif %}</h1>
|
||||
<div class="header-actions">
|
||||
<a href="/" class="btn btn-outline">返回列表</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="status-msg" class="status-msg" style="display:none;"></div>
|
||||
|
||||
<div class="edit-layout">
|
||||
<!-- 左侧: 表单 -->
|
||||
<div class="edit-form">
|
||||
<form id="labware-form" onsubmit="return false;">
|
||||
<!-- 基本信息 -->
|
||||
<div class="form-section">
|
||||
<h3>基本信息</h3>
|
||||
<div class="form-row">
|
||||
<label>类型</label>
|
||||
<select name="type" id="f-type" onchange="onTypeChange()">
|
||||
<option value="plate" {% if labware_type == 'plate' %}selected{% endif %}>Plate (孔板)</option>
|
||||
<option value="tip_rack" {% if labware_type == 'tip_rack' %}selected{% endif %}>TipRack (吸头盒)</option>
|
||||
<option value="trash" {% if labware_type == 'trash' %}selected{% endif %}>Trash (废弃槽)</option>
|
||||
<option value="tube_rack" {% if labware_type == 'tube_rack' %}selected{% endif %}>TubeRack (管架)</option>
|
||||
<option value="plate_adapter" {% if labware_type == 'plate_adapter' %}selected{% endif %}>PlateAdapter (适配器)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>函数名</label>
|
||||
<input type="text" name="function_name" id="f-function_name"
|
||||
value="{{ item.function_name if item else 'PRCXI_new_labware' }}"
|
||||
placeholder="PRCXI_xxx">
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>Model</label>
|
||||
<input type="text" name="model" id="f-model"
|
||||
value="{{ item.model if item and item.model else '' }}">
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>Docstring</label>
|
||||
<textarea name="docstring" id="f-docstring" rows="2">{{ item.docstring if item else '' }}</textarea>
|
||||
</div>
|
||||
<div class="form-row" id="row-plate_type" style="display:none;">
|
||||
<label>Plate Type</label>
|
||||
<select name="plate_type" id="f-plate_type">
|
||||
<option value="">-</option>
|
||||
<option value="skirted" {% if item and item.plate_type == 'skirted' %}selected{% endif %}>skirted</option>
|
||||
<option value="semi-skirted" {% if item and item.plate_type == 'semi-skirted' %}selected{% endif %}>semi-skirted</option>
|
||||
<option value="non-skirted" {% if item and item.plate_type == 'non-skirted' %}selected{% endif %}>non-skirted</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 物理尺寸 -->
|
||||
<div class="form-section">
|
||||
<h3>物理尺寸 Physical Dimensions (mm)</h3>
|
||||
<div class="form-row-3">
|
||||
<div><label>size_x <span class="label-cn">板长</span></label><input type="number" step="any" name="size_x" id="f-size_x" value="{{ item.size_x if item else 127 }}"></div>
|
||||
<div><label>size_y <span class="label-cn">板宽</span></label><input type="number" step="any" name="size_y" id="f-size_y" value="{{ item.size_y if item else 85 }}"></div>
|
||||
<div><label>size_z <span class="label-cn">板高</span></label><input type="number" step="any" name="size_z" id="f-size_z" value="{{ item.size_z if item else 20 }}"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 材料信息 -->
|
||||
<div class="form-section">
|
||||
<h3>材料信息</h3>
|
||||
<div class="form-row">
|
||||
<label>UUID</label>
|
||||
<input type="text" name="mi_uuid" id="f-mi_uuid"
|
||||
value="{{ item.material_info.uuid if item else '' }}">
|
||||
</div>
|
||||
<div class="form-row-2">
|
||||
<div><label>Code</label><input type="text" name="mi_code" id="f-mi_code" value="{{ item.material_info.Code if item else '' }}"></div>
|
||||
<div><label>Name</label><input type="text" name="mi_name" id="f-mi_name" value="{{ item.material_info.Name if item else '' }}"></div>
|
||||
</div>
|
||||
<div class="form-row-2">
|
||||
<div><label>materialEnum</label><input type="number" name="mi_menum" id="f-mi_menum" value="{{ item.material_info.materialEnum if item and item.material_info.materialEnum is not none else '' }}"></div>
|
||||
<div><label>SupplyType</label><input type="number" name="mi_stype" id="f-mi_stype" value="{{ item.material_info.SupplyType if item and item.material_info.SupplyType is not none else '' }}"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 网格排列 (plate/tip_rack/tube_rack) -->
|
||||
<div class="form-section" id="section-grid" style="display:none;">
|
||||
<h3 style="display:flex;align-items:center;justify-content:space-between;">
|
||||
网格排列 Grid Layout
|
||||
<button type="button" class="btn btn-sm btn-outline" onclick="autoCenter()">自动居中 Auto-Center</button>
|
||||
</h3>
|
||||
<div class="form-row-2">
|
||||
<div><label>num_items_x <span class="label-cn">列数</span></label><input type="number" name="grid_nx" id="f-grid_nx" value="{{ item.grid.num_items_x if item and item.grid else 12 }}"></div>
|
||||
<div><label>num_items_y <span class="label-cn">行数</span></label><input type="number" name="grid_ny" id="f-grid_ny" value="{{ item.grid.num_items_y if item and item.grid else 8 }}"></div>
|
||||
</div>
|
||||
<div class="form-row-3">
|
||||
<div><label>dx <span class="label-cn">首孔X偏移</span></label><input type="number" step="any" name="grid_dx" id="f-grid_dx" value="{{ item.grid.dx if item and item.grid else 0 }}"></div>
|
||||
<div><label>dy <span class="label-cn">首孔Y偏移</span></label><input type="number" step="any" name="grid_dy" id="f-grid_dy" value="{{ item.grid.dy if item and item.grid else 0 }}"></div>
|
||||
<div><label>dz <span class="label-cn">孔底Z偏移</span></label><input type="number" step="any" name="grid_dz" id="f-grid_dz" value="{{ item.grid.dz if item and item.grid else 0 }}"></div>
|
||||
</div>
|
||||
<div class="form-row-2">
|
||||
<div><label>item_dx <span class="label-cn">列间距</span></label><input type="number" step="any" name="grid_idx" id="f-grid_idx" value="{{ item.grid.item_dx if item and item.grid else 9 }}"></div>
|
||||
<div><label>item_dy <span class="label-cn">行间距</span></label><input type="number" step="any" name="grid_idy" id="f-grid_idy" value="{{ item.grid.item_dy if item and item.grid else 9 }}"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Well 参数 (plate) -->
|
||||
<div class="form-section" id="section-well" style="display:none;">
|
||||
<h3>孔参数 Well</h3>
|
||||
<div class="form-row-3">
|
||||
<div><label>size_x <span class="label-cn">孔长</span></label><input type="number" step="any" name="well_sx" id="f-well_sx" value="{{ item.well.size_x if item and item.well else 8 }}"></div>
|
||||
<div><label>size_y <span class="label-cn">孔宽</span></label><input type="number" step="any" name="well_sy" id="f-well_sy" value="{{ item.well.size_y if item and item.well else 8 }}"></div>
|
||||
<div><label>size_z <span class="label-cn">孔深</span></label><input type="number" step="any" name="well_sz" id="f-well_sz" value="{{ item.well.size_z if item and item.well else 10 }}"></div>
|
||||
</div>
|
||||
<div class="form-row-2">
|
||||
<div><label>max_volume <span class="label-cn">最大容量 (uL)</span></label><input type="number" step="any" name="well_vol" id="f-well_vol" value="{{ item.well.max_volume if item and item.well and item.well.max_volume is not none else '' }}"></div>
|
||||
<div><label>material_z_thickness <span class="label-cn">底壁厚度</span></label><input type="number" step="any" name="well_mzt" id="f-well_mzt" value="{{ item.well.material_z_thickness if item and item.well and item.well.material_z_thickness is not none else '' }}"></div>
|
||||
</div>
|
||||
<div class="form-row-2">
|
||||
<div>
|
||||
<label>bottom_type <span class="label-cn">底部形状</span></label>
|
||||
<select name="well_bt" id="f-well_bt">
|
||||
<option value="FLAT" {% if item and item.well and item.well.bottom_type == 'FLAT' %}selected{% endif %}>FLAT</option>
|
||||
<option value="V" {% if item and item.well and item.well.bottom_type == 'V' %}selected{% endif %}>V</option>
|
||||
<option value="U" {% if item and item.well and item.well.bottom_type == 'U' %}selected{% endif %}>U</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label>cross_section_type <span class="label-cn">截面形状</span></label>
|
||||
<select name="well_cs" id="f-well_cs">
|
||||
<option value="CIRCLE" {% if item and item.well and item.well.cross_section_type == 'CIRCLE' %}selected{% endif %}>CIRCLE</option>
|
||||
<option value="RECTANGLE" {% if item and item.well and item.well.cross_section_type == 'RECTANGLE' %}selected{% endif %}>RECTANGLE</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label><input type="checkbox" name="has_vf" id="f-has_vf" {% if item and item.volume_functions %}checked{% endif %}> 使用 volume_functions (rectangle)</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tip 参数 (tip_rack) -->
|
||||
<div class="form-section" id="section-tip" style="display:none;">
|
||||
<h3>枪头参数 Tip</h3>
|
||||
<div class="form-row-3">
|
||||
<div><label>spot_size_x <span class="label-cn">卡槽长</span></label><input type="number" step="any" name="tip_sx" id="f-tip_sx" value="{{ item.tip.spot_size_x if item and item.tip else 7 }}"></div>
|
||||
<div><label>spot_size_y <span class="label-cn">卡槽宽</span></label><input type="number" step="any" name="tip_sy" id="f-tip_sy" value="{{ item.tip.spot_size_y if item and item.tip else 7 }}"></div>
|
||||
<div><label>spot_size_z <span class="label-cn">卡槽高</span></label><input type="number" step="any" name="tip_sz" id="f-tip_sz" value="{{ item.tip.spot_size_z if item and item.tip else 0 }}"></div>
|
||||
</div>
|
||||
<div class="form-row-3">
|
||||
<div><label>tip_volume <span class="label-cn">枪头容量 (uL)</span></label><input type="number" step="any" name="tip_vol" id="f-tip_vol" value="{{ item.tip.tip_volume if item and item.tip else 300 }}"></div>
|
||||
<div><label>tip_length <span class="label-cn">枪头总长度 (mm)</span></label><input type="number" step="any" name="tip_len" id="f-tip_len" value="{{ item.tip.tip_length if item and item.tip else 60 }}"></div>
|
||||
<div><label>fitting_depth <span class="label-cn">取枪头时插入的长度 (mm)</span></label><input type="number" step="any" name="tip_dep" id="f-tip_dep" value="{{ item.tip.tip_fitting_depth if item and item.tip else 51 }}"></div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>tip_above_rack_length <span class="label-cn">枪头在枪头盒上方的部分的长度 (mm)</span></label>
|
||||
<input type="number" step="any" name="tip_above" id="f-tip_above"
|
||||
value="{{ item.tip.tip_above_rack_length if item and item.tip and item.tip.tip_above_rack_length is not none else '' }}"
|
||||
placeholder="tip_length - (size_z - dz)">
|
||||
<small style="color:#888;margin-top:2px;">公式: tip_length = tip_above + size_z - dz;填 tip_above 自动算 dz,填 dz 自动算 tip_above</small>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label><input type="checkbox" name="tip_filter" id="f-tip_filter" {% if item and item.tip and item.tip.has_filter %}checked{% endif %}> has_filter</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tube 参数 (tube_rack) -->
|
||||
<div class="form-section" id="section-tube" style="display:none;">
|
||||
<h3>管参数 Tube</h3>
|
||||
<div class="form-row-3">
|
||||
<div><label>size_x <span class="label-cn">管径X</span></label><input type="number" step="any" name="tube_sx" id="f-tube_sx" value="{{ item.tube.size_x if item and item.tube else 10.6 }}"></div>
|
||||
<div><label>size_y <span class="label-cn">管径Y</span></label><input type="number" step="any" name="tube_sy" id="f-tube_sy" value="{{ item.tube.size_y if item and item.tube else 10.6 }}"></div>
|
||||
<div><label>size_z <span class="label-cn">管高</span></label><input type="number" step="any" name="tube_sz" id="f-tube_sz" value="{{ item.tube.size_z if item and item.tube else 40 }}"></div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>max_volume <span class="label-cn">最大容量 (uL)</span></label>
|
||||
<input type="number" step="any" name="tube_vol" id="f-tube_vol" value="{{ item.tube.max_volume if item and item.tube else 1500 }}">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Adapter 参数 (plate_adapter) -->
|
||||
<div class="form-section" id="section-adapter" style="display:none;">
|
||||
<h3>适配器参数 Adapter</h3>
|
||||
<div class="form-row-3">
|
||||
<div><label>hole_size_x <span class="label-cn">凹槽长</span></label><input type="number" step="any" name="adp_hsx" id="f-adp_hsx" value="{{ item.adapter.adapter_hole_size_x if item and item.adapter else 127.76 }}"></div>
|
||||
<div><label>hole_size_y <span class="label-cn">凹槽宽</span></label><input type="number" step="any" name="adp_hsy" id="f-adp_hsy" value="{{ item.adapter.adapter_hole_size_y if item and item.adapter else 85.48 }}"></div>
|
||||
<div><label>hole_size_z <span class="label-cn">凹槽深</span></label><input type="number" step="any" name="adp_hsz" id="f-adp_hsz" value="{{ item.adapter.adapter_hole_size_z if item and item.adapter else 10 }}"></div>
|
||||
</div>
|
||||
<div class="form-row-3">
|
||||
<div><label>dx <span class="label-cn">X偏移</span></label><input type="number" step="any" name="adp_dx" id="f-adp_dx" value="{{ item.adapter.dx if item and item.adapter and item.adapter.dx is not none else '' }}"></div>
|
||||
<div><label>dy <span class="label-cn">Y偏移</span></label><input type="number" step="any" name="adp_dy" id="f-adp_dy" value="{{ item.adapter.dy if item and item.adapter and item.adapter.dy is not none else '' }}"></div>
|
||||
<div><label>dz <span class="label-cn">Z偏移</span></label><input type="number" step="any" name="adp_dz" id="f-adp_dz" value="{{ item.adapter.dz if item and item.adapter else 0 }}"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Registry -->
|
||||
<div class="form-section">
|
||||
<h3>Registry / Template</h3>
|
||||
<div class="form-row">
|
||||
<label>registry_category (逗号分隔)</label>
|
||||
<input type="text" name="reg_cat" id="f-reg_cat"
|
||||
value="{{ item.registry_category | join(',') if item else 'prcxi,plates' }}">
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>registry_description</label>
|
||||
<input type="text" name="reg_desc" id="f-reg_desc"
|
||||
value="{{ item.registry_description if item else '' }}">
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label><input type="checkbox" name="in_tpl" id="f-in_tpl" {% if item and item.include_in_template_matching %}checked{% endif %}> include_in_template_matching</label>
|
||||
</div>
|
||||
<div class="form-row" id="row-tpl_kind">
|
||||
<label>template_kind</label>
|
||||
<input type="text" name="tpl_kind" id="f-tpl_kind"
|
||||
value="{{ item.template_kind if item and item.template_kind else '' }}">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="button" class="btn btn-primary" onclick="saveForm()">
|
||||
{% if is_new %}创建{% else %}保存{% endif %}
|
||||
</button>
|
||||
<a href="/" class="btn btn-outline">取消</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- 右侧: 实时预览 -->
|
||||
<div class="edit-preview">
|
||||
<div class="viz-card">
|
||||
<h3 style="display:flex;align-items:center;justify-content:space-between;">
|
||||
预览: 俯视图
|
||||
<button type="button" class="btn btn-sm btn-outline" onclick="resetSvgView('svg-topdown')">回中</button>
|
||||
</h3>
|
||||
<div id="svg-topdown"></div>
|
||||
</div>
|
||||
<div class="viz-card">
|
||||
<h3 style="display:flex;align-items:center;justify-content:space-between;">
|
||||
预览: 侧面截面图
|
||||
<button type="button" class="btn btn-sm btn-outline" onclick="resetSvgView('svg-side')">回中</button>
|
||||
</h3>
|
||||
<div id="svg-side"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script src="/static/labware_viz.js"></script>
|
||||
<script src="/static/form_handler.js"></script>
|
||||
<script>
|
||||
const IS_NEW = {{ 'true' if is_new else 'false' }};
|
||||
const ITEM_ID = "{{ item.function_name if item else '' }}";
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
onTypeChange();
|
||||
updatePreview();
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -1,131 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}耗材列表 - PRCXI{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<h1>耗材列表 <span class="badge">{{ total }}</span></h1>
|
||||
<div class="header-actions">
|
||||
<button class="btn btn-outline" onclick="importFromCode()">从代码导入</button>
|
||||
<button class="btn btn-outline" onclick="generateCode(true)">生成代码 (测试)</button>
|
||||
<button class="btn btn-warning" onclick="generateCode(false)">生成代码 (正式)</button>
|
||||
<a href="/labware/new" class="btn btn-primary">+ 新建耗材</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="status-msg" class="status-msg" style="display:none;"></div>
|
||||
|
||||
{% set type_labels = {
|
||||
"plate": "孔板 (Plate)",
|
||||
"tip_rack": "吸头盒 (TipRack)",
|
||||
"trash": "废弃槽 (Trash)",
|
||||
"tube_rack": "管架 (TubeRack)",
|
||||
"plate_adapter": "适配器 (PlateAdapter)"
|
||||
} %}
|
||||
{% set type_colors = {
|
||||
"plate": "#3b82f6",
|
||||
"tip_rack": "#10b981",
|
||||
"trash": "#ef4444",
|
||||
"tube_rack": "#f59e0b",
|
||||
"plate_adapter": "#8b5cf6"
|
||||
} %}
|
||||
|
||||
{% for type_key in ["plate", "tip_rack", "trash", "tube_rack", "plate_adapter"] %}
|
||||
{% if type_key in groups %}
|
||||
<section class="type-section">
|
||||
<h2>
|
||||
<span class="type-dot" style="background:{{ type_colors[type_key] }}"></span>
|
||||
{{ type_labels[type_key] }}
|
||||
<span class="badge">{{ groups[type_key]|length }}</span>
|
||||
</h2>
|
||||
<div class="card-grid">
|
||||
{% for item in groups[type_key] %}
|
||||
<div class="labware-card" onclick="location.href='/labware/{{ item.function_name }}'">
|
||||
<div class="card-header">
|
||||
<span class="card-title">{{ item.function_name }}</span>
|
||||
{% if item.include_in_template_matching %}
|
||||
<span class="tag tag-tpl">TPL</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="card-info">
|
||||
<span class="label">Code:</span> {{ item.material_info.Code or '-' }}
|
||||
</div>
|
||||
<div class="card-info">
|
||||
<span class="label">名称:</span> {{ item.material_info.Name or '-' }}
|
||||
</div>
|
||||
<div class="card-info">
|
||||
<span class="label">尺寸:</span>
|
||||
{{ item.size_x }} x {{ item.size_y }} x {{ item.size_z }} mm
|
||||
</div>
|
||||
{% if item.grid %}
|
||||
<div class="card-info">
|
||||
<span class="label">网格:</span>
|
||||
{{ item.grid.num_items_x }} x {{ item.grid.num_items_y }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="card-footer">
|
||||
<a href="/labware/{{ item.function_name }}/edit" class="btn btn-sm btn-outline"
|
||||
onclick="event.stopPropagation()">编辑</a>
|
||||
<button class="btn btn-sm btn-danger"
|
||||
onclick="event.stopPropagation(); deleteItem('{{ item.function_name }}')">删除</button>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
function showMsg(text, ok) {
|
||||
const el = document.getElementById('status-msg');
|
||||
el.textContent = text;
|
||||
el.className = 'status-msg ' + (ok ? 'msg-ok' : 'msg-err');
|
||||
el.style.display = 'block';
|
||||
setTimeout(() => el.style.display = 'none', 4000);
|
||||
}
|
||||
|
||||
async function importFromCode() {
|
||||
if (!confirm('将从现有 prcxi_labware.py + YAML 重新导入,覆盖当前 JSON 数据?')) return;
|
||||
const r = await fetch('/api/import-from-code', {method:'POST'});
|
||||
const d = await r.json();
|
||||
if (d.status === 'ok') {
|
||||
showMsg('导入成功: ' + d.count + ' 个耗材', true);
|
||||
setTimeout(() => location.reload(), 1000);
|
||||
} else {
|
||||
showMsg('导入失败: ' + JSON.stringify(d), false);
|
||||
}
|
||||
}
|
||||
|
||||
async function generateCode(testMode) {
|
||||
const label = testMode ? '测试' : '正式';
|
||||
if (!testMode && !confirm('正式模式将覆盖原有 prcxi_labware.py 和 YAML 文件,确定?')) return;
|
||||
const r = await fetch('/api/generate-code', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type':'application/json'},
|
||||
body: JSON.stringify({test_mode: testMode})
|
||||
});
|
||||
const d = await r.json();
|
||||
if (d.status === 'ok') {
|
||||
showMsg(`[${label}] 生成成功: ${d.python_file}`, true);
|
||||
} else {
|
||||
showMsg('生成失败: ' + JSON.stringify(d), false);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteItem(id) {
|
||||
if (!confirm('确定删除 ' + id + '?')) return;
|
||||
const r = await fetch('/api/labware/' + id, {method:'DELETE'});
|
||||
const d = await r.json();
|
||||
if (d.status === 'ok') {
|
||||
showMsg('已删除', true);
|
||||
setTimeout(() => location.reload(), 500);
|
||||
} else {
|
||||
showMsg('删除失败', false);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -1,119 +0,0 @@
|
||||
"""JSON → Registry YAML 文件生成。
|
||||
|
||||
按 type 分组输出到对应 YAML 文件(与现有格式完全一致)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
from collections import defaultdict
|
||||
from pathlib import Path
|
||||
from typing import Dict, List
|
||||
|
||||
import yaml
|
||||
|
||||
from unilabos.labware_manager.models import LabwareDB, LabwareItem
|
||||
|
||||
_REGISTRY_DIR = Path(__file__).resolve().parents[1] / "registry" / "resources" / "prcxi"
|
||||
|
||||
# type → yaml 文件名
|
||||
_TYPE_TO_YAML = {
|
||||
"plate": "plates",
|
||||
"tip_rack": "tip_racks",
|
||||
"trash": "trash",
|
||||
"tube_rack": "tube_racks",
|
||||
"plate_adapter": "plate_adapters",
|
||||
}
|
||||
|
||||
|
||||
def _build_entry(item: LabwareItem) -> dict:
|
||||
"""构建单个 YAML 条目(与现有格式完全一致)。"""
|
||||
mi = item.material_info
|
||||
desc = item.registry_description
|
||||
if not desc:
|
||||
desc = f'{mi.Name} (Code: {mi.Code})' if mi.Name and mi.Code else item.function_name
|
||||
|
||||
return {
|
||||
"category": list(item.registry_category),
|
||||
"class": {
|
||||
"module": f"unilabos.devices.liquid_handling.prcxi.prcxi_labware:{item.function_name}",
|
||||
"type": "pylabrobot",
|
||||
},
|
||||
"description": desc,
|
||||
"handles": [],
|
||||
"icon": "",
|
||||
"init_param_schema": {},
|
||||
"version": "1.0.0",
|
||||
}
|
||||
|
||||
|
||||
class _YAMLDumper(yaml.SafeDumper):
|
||||
"""自定义 Dumper: 空列表输出为 [],空字典输出为 {}。"""
|
||||
pass
|
||||
|
||||
|
||||
def _represent_list(dumper, data):
|
||||
if not data:
|
||||
return dumper.represent_sequence("tag:yaml.org,2002:seq", data, flow_style=True)
|
||||
return dumper.represent_sequence("tag:yaml.org,2002:seq", data)
|
||||
|
||||
|
||||
def _represent_dict(dumper, data):
|
||||
if not data:
|
||||
return dumper.represent_mapping("tag:yaml.org,2002:map", data, flow_style=True)
|
||||
return dumper.represent_mapping("tag:yaml.org,2002:map", data)
|
||||
|
||||
|
||||
def _represent_str(dumper, data):
|
||||
if '\n' in data or ':' in data or "'" in data:
|
||||
return dumper.represent_scalar("tag:yaml.org,2002:str", data, style="'")
|
||||
return dumper.represent_scalar("tag:yaml.org,2002:str", data)
|
||||
|
||||
|
||||
_YAMLDumper.add_representer(list, _represent_list)
|
||||
_YAMLDumper.add_representer(dict, _represent_dict)
|
||||
_YAMLDumper.add_representer(str, _represent_str)
|
||||
|
||||
|
||||
def generate_yaml(db: LabwareDB, test_mode: bool = True) -> List[Path]:
|
||||
"""生成所有 registry YAML 文件,返回输出文件路径列表。"""
|
||||
suffix = "_test" if test_mode else ""
|
||||
|
||||
# 按 type 分组
|
||||
groups: Dict[str, Dict[str, dict]] = defaultdict(dict)
|
||||
for item in db.items:
|
||||
yaml_key = _TYPE_TO_YAML.get(item.type)
|
||||
if yaml_key is None:
|
||||
continue
|
||||
groups[yaml_key][item.function_name] = _build_entry(item)
|
||||
|
||||
out_paths: List[Path] = []
|
||||
for yaml_key, entries in groups.items():
|
||||
out_path = _REGISTRY_DIR / f"{yaml_key}{suffix}.yaml"
|
||||
|
||||
# 备份
|
||||
if out_path.exists():
|
||||
bak = out_path.with_suffix(".yaml.bak")
|
||||
shutil.copy2(out_path, bak)
|
||||
|
||||
# 按函数名排序
|
||||
sorted_entries = dict(sorted(entries.items()))
|
||||
|
||||
content = yaml.dump(sorted_entries, Dumper=_YAMLDumper, allow_unicode=True,
|
||||
default_flow_style=False, sort_keys=False)
|
||||
out_path.write_text(content, encoding="utf-8")
|
||||
out_paths.append(out_path)
|
||||
|
||||
return out_paths
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
from unilabos.labware_manager.importer import load_db
|
||||
db = load_db()
|
||||
if not db.items:
|
||||
print("labware_db.json 为空,请先运行 importer.py")
|
||||
else:
|
||||
paths = generate_yaml(db, test_mode=True)
|
||||
print(f"已生成 {len(paths)} 个 YAML 文件:")
|
||||
for p in paths:
|
||||
print(f" {p}")
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,658 +0,0 @@
|
||||
"""
|
||||
装饰器注册表系统
|
||||
|
||||
通过 @device, @action, @resource 装饰器替代 YAML 配置文件来定义设备/动作/资源注册表信息。
|
||||
|
||||
Usage:
|
||||
from unilabos.registry.decorators import (
|
||||
device, action, resource,
|
||||
InputHandle, OutputHandle,
|
||||
ActionInputHandle, ActionOutputHandle,
|
||||
HardwareInterface, Side, DataSource, NodeType,
|
||||
)
|
||||
|
||||
@device(
|
||||
id="solenoid_valve.mock",
|
||||
category=["pump_and_valve"],
|
||||
description="模拟电磁阀设备",
|
||||
handles=[
|
||||
InputHandle(key="in", data_type="fluid", label="in", side=Side.NORTH),
|
||||
OutputHandle(key="out", data_type="fluid", label="out", side=Side.SOUTH),
|
||||
],
|
||||
hardware_interface=HardwareInterface(
|
||||
name="hardware_interface",
|
||||
read="send_command",
|
||||
write="send_command",
|
||||
),
|
||||
)
|
||||
class SolenoidValveMock:
|
||||
@action(action_type=EmptyIn)
|
||||
def close(self):
|
||||
...
|
||||
|
||||
@action(
|
||||
handles=[
|
||||
ActionInputHandle(key="in", data_type="fluid", label="in"),
|
||||
ActionOutputHandle(key="out", data_type="fluid", label="out"),
|
||||
],
|
||||
)
|
||||
def set_valve_position(self, position):
|
||||
...
|
||||
|
||||
# 无 @action 装饰器 => auto- 前缀动作
|
||||
def is_open(self):
|
||||
...
|
||||
"""
|
||||
|
||||
from enum import Enum
|
||||
from functools import wraps
|
||||
from typing import Any, Callable, Dict, List, Optional, TypeVar
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
F = TypeVar("F", bound=Callable[..., Any])
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 枚举
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class Side(str, Enum):
|
||||
"""UI 上 Handle 的显示位置"""
|
||||
|
||||
NORTH = "NORTH"
|
||||
SOUTH = "SOUTH"
|
||||
EAST = "EAST"
|
||||
WEST = "WEST"
|
||||
|
||||
|
||||
class DataSource(str, Enum):
|
||||
"""Handle 的数据来源"""
|
||||
|
||||
HANDLE = "handle" # 从上游 handle 获取数据 (用于 InputHandle)
|
||||
EXECUTOR = "executor" # 从执行器输出数据 (用于 OutputHandle)
|
||||
|
||||
|
||||
class NodeType(str, Enum):
|
||||
"""动作的节点类型(用于区分 ILab 节点和人工确认节点等)"""
|
||||
|
||||
ILAB = "ILab"
|
||||
MANUAL_CONFIRM = "manual_confirm"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Device / Resource Handle (设备/资源级别端口, 序列化时包含 io_type)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _DeviceHandleBase(BaseModel):
|
||||
"""设备/资源端口基类 (内部使用)"""
|
||||
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
key: str = Field(serialization_alias="handler_key")
|
||||
data_type: str
|
||||
label: str
|
||||
side: Optional[Side] = None
|
||||
data_key: Optional[str] = None
|
||||
data_source: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
|
||||
# 子类覆盖
|
||||
io_type: str = ""
|
||||
|
||||
def to_registry_dict(self) -> Dict[str, Any]:
|
||||
return self.model_dump(by_alias=True, exclude_none=True)
|
||||
|
||||
|
||||
class InputHandle(_DeviceHandleBase):
|
||||
"""
|
||||
输入端口 (io_type="target"), 用于 @device / @resource handles
|
||||
|
||||
Example:
|
||||
InputHandle(key="in", data_type="fluid", label="in", side=Side.NORTH)
|
||||
"""
|
||||
|
||||
io_type: str = "target"
|
||||
|
||||
|
||||
class OutputHandle(_DeviceHandleBase):
|
||||
"""
|
||||
输出端口 (io_type="source"), 用于 @device / @resource handles
|
||||
|
||||
Example:
|
||||
OutputHandle(key="out", data_type="fluid", label="out", side=Side.SOUTH)
|
||||
"""
|
||||
|
||||
io_type: str = "source"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Action Handle (动作级别端口, 序列化时不含 io_type, 按类型自动分组)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _ActionHandleBase(BaseModel):
|
||||
"""动作端口基类 (内部使用)"""
|
||||
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
key: str = Field(serialization_alias="handler_key")
|
||||
data_type: str
|
||||
label: str
|
||||
side: Optional[Side] = None
|
||||
data_key: Optional[str] = None
|
||||
data_source: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
io_type: Optional[str] = None # source/sink (dataflow) or target/source (device-style)
|
||||
|
||||
def to_registry_dict(self) -> Dict[str, Any]:
|
||||
return self.model_dump(by_alias=True, exclude_none=True)
|
||||
|
||||
|
||||
class ActionInputHandle(_ActionHandleBase):
|
||||
"""
|
||||
动作输入端口, 用于 @action handles, 序列化后归入 "input" 组
|
||||
|
||||
Example:
|
||||
ActionInputHandle(
|
||||
key="material_input", data_type="workbench_material",
|
||||
label="物料编号", data_key="material_number", data_source="handle",
|
||||
)
|
||||
"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class ActionOutputHandle(_ActionHandleBase):
|
||||
"""
|
||||
动作输出端口, 用于 @action handles, 序列化后归入 "output" 组
|
||||
|
||||
Example:
|
||||
ActionOutputHandle(
|
||||
key="station_output", data_type="workbench_station",
|
||||
label="加热台ID", data_key="station_id", data_source="executor",
|
||||
)
|
||||
"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# HardwareInterface
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class HardwareInterface(BaseModel):
|
||||
"""
|
||||
硬件通信接口定义
|
||||
|
||||
描述设备与底层硬件通信的方式 (串口、Modbus 等)。
|
||||
|
||||
Example:
|
||||
HardwareInterface(name="hardware_interface", read="send_command", write="send_command")
|
||||
"""
|
||||
|
||||
name: str
|
||||
read: Optional[str] = None
|
||||
write: Optional[str] = None
|
||||
extra_info: Optional[List[str]] = None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 全局注册表 -- 记录所有被装饰器标记的类/函数
|
||||
# ---------------------------------------------------------------------------
|
||||
_registered_devices: Dict[str, type] = {} # device_id -> class
|
||||
_registered_resources: Dict[str, Any] = {} # resource_id -> class or function
|
||||
|
||||
|
||||
def _device_handles_to_list(
|
||||
handles: Optional[List[_DeviceHandleBase]],
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""将设备/资源 Handle 列表序列化为字典列表 (含 io_type)"""
|
||||
if handles is None:
|
||||
return []
|
||||
return [h.to_registry_dict() for h in handles]
|
||||
|
||||
|
||||
def _action_handles_to_dict(
|
||||
handles: Optional[List[_ActionHandleBase]],
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
将动作 Handle 列表序列化为 {"input": [...], "output": [...]} 格式。
|
||||
|
||||
ActionInputHandle => "input", ActionOutputHandle => "output"
|
||||
"""
|
||||
if handles is None:
|
||||
return {}
|
||||
input_list = [h.to_registry_dict() for h in handles if isinstance(h, ActionInputHandle)]
|
||||
output_list = [h.to_registry_dict() for h in handles if isinstance(h, ActionOutputHandle)]
|
||||
result: Dict[str, Any] = {}
|
||||
if input_list:
|
||||
result["input"] = input_list
|
||||
if output_list:
|
||||
result["output"] = output_list
|
||||
return result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# @device 类装饰器
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
# noinspection PyShadowingBuiltins
|
||||
def device(
|
||||
id: Optional[str] = None,
|
||||
ids: Optional[List[str]] = None,
|
||||
id_meta: Optional[Dict[str, Dict[str, Any]]] = None,
|
||||
category: Optional[List[str]] = None,
|
||||
description: str = "",
|
||||
display_name: str = "",
|
||||
icon: str = "",
|
||||
version: str = "1.0.0",
|
||||
handles: Optional[List[_DeviceHandleBase]] = None,
|
||||
model: Optional[Dict[str, Any]] = None,
|
||||
device_type: str = "python",
|
||||
hardware_interface: Optional[HardwareInterface] = None,
|
||||
):
|
||||
"""
|
||||
设备类装饰器
|
||||
|
||||
将类标记为一个 UniLab-OS 设备,并附加注册表元数据。
|
||||
|
||||
支持两种模式:
|
||||
1. 单设备: id="xxx", category=[...]
|
||||
2. 多设备: ids=["id1","id2"], id_meta={"id1":{handles:[...]}, "id2":{...}}
|
||||
|
||||
Args:
|
||||
id: 单设备时的注册表唯一标识
|
||||
ids: 多设备时的 id 列表,与 id_meta 配合使用
|
||||
id_meta: 每个 device_id 的覆盖元数据 (handles/description/icon/model)
|
||||
category: 设备分类标签列表 (必填)
|
||||
description: 设备描述
|
||||
display_name: 人类可读的设备显示名称,缺失时默认使用 id
|
||||
icon: 图标路径
|
||||
version: 版本号
|
||||
handles: 设备端口列表 (单设备或 id_meta 未覆盖时使用)
|
||||
model: 可选的 3D 模型配置
|
||||
device_type: 设备实现类型 ("python" / "ros2")
|
||||
hardware_interface: 硬件通信接口 (HardwareInterface)
|
||||
"""
|
||||
# Resolve device ids
|
||||
if ids is not None:
|
||||
device_ids = list(ids)
|
||||
if not device_ids:
|
||||
raise ValueError("@device ids 不能为空")
|
||||
id_meta = id_meta or {}
|
||||
elif id is not None:
|
||||
device_ids = [id]
|
||||
id_meta = {}
|
||||
else:
|
||||
raise ValueError("@device 必须提供 id 或 ids")
|
||||
|
||||
if category is None:
|
||||
raise ValueError("@device category 必填")
|
||||
|
||||
base_meta = {
|
||||
"category": category,
|
||||
"description": description,
|
||||
"display_name": display_name,
|
||||
"icon": icon,
|
||||
"version": version,
|
||||
"handles": _device_handles_to_list(handles),
|
||||
"model": model,
|
||||
"device_type": device_type,
|
||||
"hardware_interface": (hardware_interface.model_dump(exclude_none=True) if hardware_interface else None),
|
||||
}
|
||||
|
||||
def decorator(cls):
|
||||
cls._device_registry_meta = base_meta
|
||||
cls._device_registry_id_meta = id_meta
|
||||
cls._device_registry_ids = device_ids
|
||||
|
||||
for did in device_ids:
|
||||
if did in _registered_devices:
|
||||
raise ValueError(f"@device id 重复: '{did}' 已被 {_registered_devices[did]} 注册")
|
||||
_registered_devices[did] = cls
|
||||
|
||||
return cls
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# @action 方法装饰器
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# 区分 "用户没传 action_type" 和 "用户传了 None"
|
||||
_ACTION_TYPE_UNSET = object()
|
||||
|
||||
|
||||
# noinspection PyShadowingNames
|
||||
def action(
|
||||
action_type: Any = _ACTION_TYPE_UNSET,
|
||||
goal: Optional[Dict[str, str]] = None,
|
||||
feedback: Optional[Dict[str, str]] = None,
|
||||
result: Optional[Dict[str, str]] = None,
|
||||
handles: Optional[List[_ActionHandleBase]] = None,
|
||||
goal_default: Optional[Dict[str, Any]] = None,
|
||||
placeholder_keys: Optional[Dict[str, str]] = None,
|
||||
always_free: bool = False,
|
||||
is_protocol: bool = False,
|
||||
description: str = "",
|
||||
auto_prefix: bool = False,
|
||||
parent: bool = False,
|
||||
node_type: Optional["NodeType"] = None,
|
||||
):
|
||||
"""
|
||||
动作方法装饰器
|
||||
|
||||
标记方法为注册表动作。有三种用法:
|
||||
1. @action(action_type=EmptyIn, ...) -- 非 auto, 使用指定 ROS Action 类型
|
||||
2. @action() -- 非 auto, UniLabJsonCommand (从方法签名生成 schema)
|
||||
3. 不加 @action -- auto- 前缀, UniLabJsonCommand
|
||||
|
||||
Protocol 用法:
|
||||
@action(action_type=Add, is_protocol=True)
|
||||
def AddProtocol(self): ...
|
||||
标记该动作为高级协议 (protocol),运行时通过 ROS Action 路由到
|
||||
protocol generator 执行。action_type 指向 unilabos_msgs 的 Action 类型。
|
||||
|
||||
Args:
|
||||
action_type: ROS Action 消息类型 (如 EmptyIn, SendCmd, HeatChill).
|
||||
不传/默认 = UniLabJsonCommand (非 auto).
|
||||
goal: Goal 字段映射 (ROS字段名 -> 设备参数名).
|
||||
protocol 模式下可留空,系统自动生成 identity 映射.
|
||||
feedback: Feedback 字段映射
|
||||
result: Result 字段映射
|
||||
handles: 动作端口列表 (ActionInputHandle / ActionOutputHandle)
|
||||
goal_default: Goal 字段默认值映射 (字段名 -> 默认值), 与自动生成的 goal_default 合并
|
||||
placeholder_keys: 参数占位符配置
|
||||
always_free: 是否为永久闲置动作 (不受排队限制)
|
||||
is_protocol: 是否为工作站协议 (protocol)。True 时运行时走 protocol generator 路径。
|
||||
description: 动作描述
|
||||
auto_prefix: 若为 True,动作名使用 auto-{method_name} 形式(与无 @action 时一致)
|
||||
parent: 若为 True,当方法参数为空 (*args, **kwargs) 时,通过 MRO 从父类获取真实方法参数
|
||||
node_type: 动作的节点类型 (NodeType.ILAB / NodeType.MANUAL_CONFIRM)。
|
||||
不填写时不写入注册表。
|
||||
"""
|
||||
|
||||
def decorator(func: F) -> F:
|
||||
@wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
return func(*args, **kwargs)
|
||||
|
||||
# action_type 为哨兵值 => 用户没传, 视为 None (UniLabJsonCommand)
|
||||
resolved_type = None if action_type is _ACTION_TYPE_UNSET else action_type
|
||||
|
||||
meta = {
|
||||
"action_type": resolved_type,
|
||||
"goal": goal or {},
|
||||
"feedback": feedback or {},
|
||||
"result": result or {},
|
||||
"handles": _action_handles_to_dict(handles),
|
||||
"goal_default": goal_default or {},
|
||||
"placeholder_keys": placeholder_keys or {},
|
||||
"always_free": always_free,
|
||||
"is_protocol": is_protocol,
|
||||
"description": description,
|
||||
"auto_prefix": auto_prefix,
|
||||
"parent": parent,
|
||||
}
|
||||
if node_type is not None:
|
||||
meta["node_type"] = node_type.value if isinstance(node_type, NodeType) else str(node_type)
|
||||
wrapper._action_registry_meta = meta # type: ignore[attr-defined]
|
||||
|
||||
# 设置 _is_always_free 保持与旧 @always_free 装饰器兼容
|
||||
if always_free:
|
||||
wrapper._is_always_free = True # type: ignore[attr-defined]
|
||||
|
||||
return wrapper # type: ignore[return-value]
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
def get_action_meta(func) -> Optional[Dict[str, Any]]:
|
||||
"""获取方法上的 @action 装饰器元数据"""
|
||||
return getattr(func, "_action_registry_meta", None)
|
||||
|
||||
|
||||
def has_action_decorator(func) -> bool:
|
||||
"""检查函数是否带有 @action 装饰器"""
|
||||
return hasattr(func, "_action_registry_meta")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# @resource 类/函数装饰器
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def resource(
|
||||
id: str,
|
||||
category: List[str],
|
||||
description: str = "",
|
||||
icon: str = "",
|
||||
version: str = "1.0.0",
|
||||
handles: Optional[List[_DeviceHandleBase]] = None,
|
||||
model: Optional[Dict[str, Any]] = None,
|
||||
class_type: str = "pylabrobot",
|
||||
):
|
||||
"""
|
||||
资源类/函数装饰器
|
||||
|
||||
将类或工厂函数标记为一个 UniLab-OS 资源,附加注册表元数据。
|
||||
|
||||
Args:
|
||||
id: 注册表唯一标识 (必填, 不可重复)
|
||||
category: 资源分类标签列表 (必填)
|
||||
description: 资源描述
|
||||
icon: 图标路径
|
||||
version: 版本号
|
||||
handles: 端口列表 (InputHandle / OutputHandle)
|
||||
model: 可选的 3D 模型配置
|
||||
class_type: 资源实现类型 ("python" / "pylabrobot" / "unilabos")
|
||||
"""
|
||||
|
||||
def decorator(obj):
|
||||
meta = {
|
||||
"resource_id": id,
|
||||
"category": category,
|
||||
"description": description,
|
||||
"icon": icon,
|
||||
"version": version,
|
||||
"handles": _device_handles_to_list(handles),
|
||||
"model": model,
|
||||
"class_type": class_type,
|
||||
}
|
||||
obj._resource_registry_meta = meta
|
||||
|
||||
if id in _registered_resources:
|
||||
raise ValueError(f"@resource id 重复: '{id}' 已被 {_registered_resources[id]} 注册")
|
||||
_registered_resources[id] = obj
|
||||
|
||||
return obj
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
def get_device_meta(cls, device_id: Optional[str] = None) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
获取类上的 @device 装饰器元数据。
|
||||
|
||||
当 device_id 存在且类使用 ids+id_meta 时,返回合并后的 meta
|
||||
(base_meta 与 id_meta[device_id] 深度合并)。
|
||||
"""
|
||||
base = getattr(cls, "_device_registry_meta", None)
|
||||
if base is None:
|
||||
return None
|
||||
id_meta = getattr(cls, "_device_registry_id_meta", None) or {}
|
||||
if device_id is None or device_id not in id_meta:
|
||||
result = dict(base)
|
||||
ids = getattr(cls, "_device_registry_ids", None)
|
||||
result["device_id"] = device_id if device_id is not None else (ids[0] if ids else None)
|
||||
return result
|
||||
|
||||
overrides = id_meta[device_id]
|
||||
result = dict(base)
|
||||
result["device_id"] = device_id
|
||||
for key in ["handles", "description", "icon", "model"]:
|
||||
if key in overrides:
|
||||
val = overrides[key]
|
||||
if key == "handles" and isinstance(val, list):
|
||||
# handles 必须是 Handle 对象列表
|
||||
result[key] = [h.to_registry_dict() for h in val]
|
||||
else:
|
||||
result[key] = val
|
||||
return result
|
||||
|
||||
|
||||
def get_resource_meta(obj) -> Optional[Dict[str, Any]]:
|
||||
"""获取对象上的 @resource 装饰器元数据"""
|
||||
return getattr(obj, "_resource_registry_meta", None)
|
||||
|
||||
|
||||
def get_all_registered_devices() -> Dict[str, type]:
|
||||
"""获取所有已注册的设备类"""
|
||||
return _registered_devices.copy()
|
||||
|
||||
|
||||
def get_all_registered_resources() -> Dict[str, Any]:
|
||||
"""获取所有已注册的资源"""
|
||||
return _registered_resources.copy()
|
||||
|
||||
|
||||
def clear_registry():
|
||||
"""清空全局注册表 (用于测试)"""
|
||||
_registered_devices.clear()
|
||||
_registered_resources.clear()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 枚举值归一化
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def normalize_enum_value(raw: Any, enum_cls) -> Optional[str]:
|
||||
"""将 AST 提取的枚举成员名 / YAML 值字符串 / 旧格式长路径统一归一化为枚举值。
|
||||
|
||||
适用于 Side、DataSource、NodeType 等继承自 ``str, Enum`` 的装饰器枚举。
|
||||
|
||||
处理以下格式:
|
||||
- "MANUAL_CONFIRM" → NodeType["MANUAL_CONFIRM"].value = "manual_confirm"
|
||||
- "manual_confirm" → NodeType("manual_confirm").value = "manual_confirm"
|
||||
- "HANDLE" → DataSource["HANDLE"].value = "handle"
|
||||
- "NORTH" → Side["NORTH"].value = "NORTH"
|
||||
- 旧缓存长路径 "unilabos...NodeType.MANUAL_CONFIRM" → 先 rsplit 再查找
|
||||
"""
|
||||
if not raw:
|
||||
return None
|
||||
raw_str = str(raw)
|
||||
if "." in raw_str:
|
||||
raw_str = raw_str.rsplit(".", 1)[-1]
|
||||
try:
|
||||
return enum_cls[raw_str].value
|
||||
except KeyError:
|
||||
pass
|
||||
try:
|
||||
return enum_cls(raw_str).value
|
||||
except ValueError:
|
||||
return raw_str
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# topic_config / not_action / always_free 装饰器
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def topic_config(
|
||||
period: Optional[float] = None,
|
||||
print_publish: Optional[bool] = None,
|
||||
qos: Optional[int] = None,
|
||||
name: Optional[str] = None,
|
||||
) -> Callable[[F], F]:
|
||||
"""
|
||||
Topic发布配置装饰器
|
||||
|
||||
用于装饰 get_{attr_name} 方法或 @property,控制对应属性的ROS topic发布行为。
|
||||
|
||||
Args:
|
||||
period: 发布周期(秒)。None 表示使用默认值 5.0
|
||||
print_publish: 是否打印发布日志。None 表示使用节点默认配置
|
||||
qos: QoS深度配置。None 表示使用默认值 10
|
||||
name: 自定义发布名称。None 表示使用方法名(去掉 get_ 前缀)
|
||||
|
||||
Note:
|
||||
与 @property 连用时,@topic_config 必须放在 @property 下面,
|
||||
这样装饰器执行顺序为:先 topic_config 添加配置,再 property 包装。
|
||||
"""
|
||||
|
||||
def decorator(func: F) -> F:
|
||||
@wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
return func(*args, **kwargs)
|
||||
|
||||
wrapper._topic_period = period # type: ignore[attr-defined]
|
||||
wrapper._topic_print_publish = print_publish # type: ignore[attr-defined]
|
||||
wrapper._topic_qos = qos # type: ignore[attr-defined]
|
||||
wrapper._topic_name = name # type: ignore[attr-defined]
|
||||
wrapper._has_topic_config = True # type: ignore[attr-defined]
|
||||
|
||||
return wrapper # type: ignore[return-value]
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
def get_topic_config(func) -> dict:
|
||||
"""获取函数上的 topic 配置 (period, print_publish, qos, name)"""
|
||||
if hasattr(func, "_has_topic_config") and getattr(func, "_has_topic_config", False):
|
||||
return {
|
||||
"period": getattr(func, "_topic_period", None),
|
||||
"print_publish": getattr(func, "_topic_print_publish", None),
|
||||
"qos": getattr(func, "_topic_qos", None),
|
||||
"name": getattr(func, "_topic_name", None),
|
||||
}
|
||||
return {}
|
||||
|
||||
|
||||
def always_free(func: F) -> F:
|
||||
"""
|
||||
标记动作为永久闲置(不受busy队列限制)的装饰器
|
||||
|
||||
被此装饰器标记的 action 方法,在执行时不会受到设备级别的排队限制,
|
||||
任何时候请求都可以立即执行。适用于查询类、状态读取类等轻量级操作。
|
||||
"""
|
||||
|
||||
@wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
return func(*args, **kwargs)
|
||||
|
||||
wrapper._is_always_free = True # type: ignore[attr-defined]
|
||||
|
||||
return wrapper # type: ignore[return-value]
|
||||
|
||||
|
||||
def is_always_free(func) -> bool:
|
||||
"""检查函数是否被标记为永久闲置"""
|
||||
return getattr(func, "_is_always_free", False)
|
||||
|
||||
|
||||
def not_action(func: F) -> F:
|
||||
"""
|
||||
标记方法为非动作的装饰器
|
||||
|
||||
用于装饰 driver 类中的方法,使其在注册表扫描时不被识别为动作。
|
||||
适用于辅助方法、内部工具方法等不应暴露为设备动作的公共方法。
|
||||
"""
|
||||
|
||||
@wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
return func(*args, **kwargs)
|
||||
|
||||
wrapper._is_not_action = True # type: ignore[attr-defined]
|
||||
|
||||
return wrapper # type: ignore[return-value]
|
||||
|
||||
|
||||
def is_not_action(func) -> bool:
|
||||
"""检查函数是否被标记为非动作"""
|
||||
return getattr(func, "_is_not_action", False)
|
||||
@@ -96,13 +96,10 @@ serial:
|
||||
type: string
|
||||
port:
|
||||
type: string
|
||||
registry_name:
|
||||
type: string
|
||||
resource_tracker:
|
||||
type: object
|
||||
required:
|
||||
- device_id
|
||||
- registry_name
|
||||
- port
|
||||
type: object
|
||||
data:
|
||||
|
||||
@@ -13,18 +13,21 @@ Qone_nmr:
|
||||
description: ''
|
||||
properties:
|
||||
feedback:
|
||||
additionalProperties: true
|
||||
properties: {}
|
||||
required: []
|
||||
title: EmptyIn_Feedback
|
||||
type: object
|
||||
goal:
|
||||
additionalProperties: true
|
||||
properties: {}
|
||||
required: []
|
||||
title: EmptyIn_Goal
|
||||
type: object
|
||||
result:
|
||||
additionalProperties: false
|
||||
properties:
|
||||
return_info:
|
||||
type: string
|
||||
required:
|
||||
- return_info
|
||||
title: EmptyIn_Result
|
||||
type: object
|
||||
required:
|
||||
@@ -68,6 +71,31 @@ Qone_nmr:
|
||||
title: monitor_folder_for_new_content参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-post_init:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default:
|
||||
ros_node: null
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
ros_node:
|
||||
type: string
|
||||
required:
|
||||
- ros_node
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: post_init参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-strings_to_txt:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
@@ -110,18 +138,21 @@ Qone_nmr:
|
||||
description: ''
|
||||
properties:
|
||||
feedback:
|
||||
additionalProperties: true
|
||||
properties: {}
|
||||
required: []
|
||||
title: EmptyIn_Feedback
|
||||
type: object
|
||||
goal:
|
||||
additionalProperties: true
|
||||
properties: {}
|
||||
required: []
|
||||
title: EmptyIn_Goal
|
||||
type: object
|
||||
result:
|
||||
additionalProperties: false
|
||||
properties:
|
||||
return_info:
|
||||
type: string
|
||||
required:
|
||||
- return_info
|
||||
title: EmptyIn_Result
|
||||
type: object
|
||||
required:
|
||||
@@ -136,31 +167,32 @@ Qone_nmr:
|
||||
goal_default:
|
||||
string: ''
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result:
|
||||
return_info: return_info
|
||||
success: success
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback:
|
||||
additionalProperties: true
|
||||
properties: {}
|
||||
required: []
|
||||
title: StrSingleInput_Feedback
|
||||
type: object
|
||||
goal:
|
||||
additionalProperties: false
|
||||
properties:
|
||||
string:
|
||||
type: string
|
||||
required:
|
||||
- string
|
||||
title: StrSingleInput_Goal
|
||||
type: object
|
||||
result:
|
||||
additionalProperties: false
|
||||
properties:
|
||||
return_info:
|
||||
type: string
|
||||
success:
|
||||
type: boolean
|
||||
required:
|
||||
- return_info
|
||||
- success
|
||||
title: StrSingleInput_Result
|
||||
type: object
|
||||
required:
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -24,321 +24,39 @@ bioyond_dispensing_station:
|
||||
required:
|
||||
- data
|
||||
type: object
|
||||
result:
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: brief_step_parameters参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-process_order_finish_report:
|
||||
auto-compute_experiment_design:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default:
|
||||
report_request: null
|
||||
used_materials: null
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
report_request:
|
||||
type: string
|
||||
used_materials:
|
||||
type: string
|
||||
required:
|
||||
- report_request
|
||||
- used_materials
|
||||
type: object
|
||||
result:
|
||||
type: object
|
||||
required:
|
||||
- goal
|
||||
title: process_order_finish_report参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-project_order_report:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default:
|
||||
order_id: null
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
order_id:
|
||||
type: string
|
||||
required:
|
||||
- order_id
|
||||
type: object
|
||||
result:
|
||||
type: object
|
||||
required:
|
||||
- goal
|
||||
title: project_order_report参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-query_resource_by_name:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default:
|
||||
material_name: null
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
material_name:
|
||||
type: string
|
||||
required:
|
||||
- material_name
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: query_resource_by_name参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-workflow_sample_locations:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default:
|
||||
workflow_id: null
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
workflow_id:
|
||||
type: string
|
||||
required:
|
||||
- workflow_id
|
||||
type: object
|
||||
result:
|
||||
type: object
|
||||
required:
|
||||
- goal
|
||||
title: workflow_sample_locations参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
batch_create_90_10_vial_feeding_tasks:
|
||||
feedback: {}
|
||||
goal:
|
||||
delay_time: delay_time
|
||||
hold_m_name: hold_m_name
|
||||
liquid_material_name: liquid_material_name
|
||||
speed: speed
|
||||
temperature: temperature
|
||||
titration: titration
|
||||
goal_default:
|
||||
delay_time: null
|
||||
hold_m_name: null
|
||||
liquid_material_name: NMP
|
||||
speed: null
|
||||
temperature: null
|
||||
titration: null
|
||||
handles:
|
||||
input:
|
||||
- data_key: titration
|
||||
data_source: handle
|
||||
data_type: object
|
||||
handler_key: titration
|
||||
io_type: source
|
||||
label: Titration Data From Calculation Node
|
||||
output:
|
||||
- data_key: return_info
|
||||
data_source: executor
|
||||
data_type: string
|
||||
handler_key: BATCH_CREATE_RESULT
|
||||
io_type: sink
|
||||
label: Complete Batch Create Result JSON (contains order_codes and order_ids)
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: 批量创建90%10%小瓶投料任务。从计算节点接收titration数据,包含物料名称、主称固体质量、滴定固体质量和滴定溶剂体积。返回的return_info中包含order_codes和order_ids列表。
|
||||
properties:
|
||||
feedback:
|
||||
title: BatchCreate9010VialFeedingTasks_Feedback
|
||||
goal:
|
||||
properties:
|
||||
delay_time:
|
||||
description: 延迟时间(秒),默认600
|
||||
type: string
|
||||
hold_m_name:
|
||||
description: 库位名称,如"C01",必填参数
|
||||
type: string
|
||||
liquid_material_name:
|
||||
default: NMP
|
||||
description: 10%物料的液体物料名称,默认为"NMP"
|
||||
type: string
|
||||
speed:
|
||||
description: 搅拌速度,默认400
|
||||
type: string
|
||||
temperature:
|
||||
description: 温度(℃),默认40
|
||||
type: string
|
||||
titration:
|
||||
description: '滴定信息对象,包含: name(物料名称), main_portion(主称固体质量g), titration_portion(滴定固体质量g),
|
||||
titration_solvent(滴定溶液体积mL)'
|
||||
type: string
|
||||
required:
|
||||
- titration
|
||||
title: BatchCreate9010VialFeedingTasks_Goal
|
||||
type: object
|
||||
result:
|
||||
title: BatchCreate9010VialFeedingTasks_Result
|
||||
type: string
|
||||
required:
|
||||
- goal
|
||||
title: batch_create_90_10_vial_feeding_tasks参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
batch_create_diamine_solution_tasks:
|
||||
feedback: {}
|
||||
goal:
|
||||
delay_time: delay_time
|
||||
liquid_material_name: liquid_material_name
|
||||
solutions: solutions
|
||||
speed: speed
|
||||
temperature: temperature
|
||||
goal_default:
|
||||
delay_time: null
|
||||
liquid_material_name: NMP
|
||||
solutions: null
|
||||
speed: null
|
||||
temperature: null
|
||||
handles:
|
||||
input:
|
||||
- data_key: solutions
|
||||
data_source: handle
|
||||
data_type: array
|
||||
handler_key: solutions
|
||||
io_type: source
|
||||
label: Solution Data From Python
|
||||
output:
|
||||
- data_key: return_info
|
||||
data_source: executor
|
||||
data_type: string
|
||||
handler_key: BATCH_CREATE_RESULT
|
||||
io_type: sink
|
||||
label: Complete Batch Create Result JSON (contains order_codes and order_ids)
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: 批量创建二胺溶液配置任务。自动为多个二胺样品创建溶液配置任务,每个任务包含固体物料称量、溶剂添加、搅拌混合等步骤。返回的return_info中包含order_codes和order_ids列表。
|
||||
properties:
|
||||
feedback:
|
||||
title: BatchCreateDiamineSolutionTasks_Feedback
|
||||
goal:
|
||||
properties:
|
||||
delay_time:
|
||||
description: 溶液配置完成后的延迟时间(秒),用于充分混合和溶解,默认600秒
|
||||
type: string
|
||||
liquid_material_name:
|
||||
default: NMP
|
||||
description: 液体溶剂名称,用于溶解固体物料,默认为NMP(N-甲基吡咯烷酮)
|
||||
type: string
|
||||
solutions:
|
||||
description: '溶液列表,JSON数组格式,每个元素包含: name(物料名称), order(序号), solid_mass(固体质量g),
|
||||
solvent_volume(溶剂体积mL)。示例: [{"name": "MDA", "order": 0, "solid_mass":
|
||||
5.0, "solvent_volume": 20}, {"name": "MPDA", "order": 1, "solid_mass":
|
||||
4.5, "solvent_volume": 18}]'
|
||||
type: string
|
||||
speed:
|
||||
description: 搅拌速度(rpm),用于混合溶液,默认400转/分钟
|
||||
type: string
|
||||
temperature:
|
||||
description: 配置温度(℃),溶液配置过程的目标温度,默认20℃(室温)
|
||||
type: string
|
||||
required:
|
||||
- solutions
|
||||
title: BatchCreateDiamineSolutionTasks_Goal
|
||||
type: object
|
||||
result:
|
||||
title: BatchCreateDiamineSolutionTasks_Result
|
||||
type: string
|
||||
required:
|
||||
- goal
|
||||
title: batch_create_diamine_solution_tasks参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
compute_experiment_design:
|
||||
feedback: {}
|
||||
goal:
|
||||
m_tot: m_tot
|
||||
ratio: ratio
|
||||
titration_percent: titration_percent
|
||||
wt_percent: wt_percent
|
||||
goal_default:
|
||||
m_tot: '70'
|
||||
ratio: null
|
||||
titration_percent: '0.03'
|
||||
wt_percent: '0.25'
|
||||
handles:
|
||||
output:
|
||||
- data_key: solutions
|
||||
data_source: executor
|
||||
data_type: array
|
||||
handler_key: solutions
|
||||
io_type: sink
|
||||
label: Solution Data From Python
|
||||
- data_key: titration
|
||||
data_source: executor
|
||||
data_type: object
|
||||
handler_key: titration
|
||||
io_type: sink
|
||||
label: Titration Data From Calculation Node
|
||||
- data_key: solvents
|
||||
data_source: executor
|
||||
data_type: object
|
||||
handler_key: solvents
|
||||
io_type: sink
|
||||
label: Solvents Data From Calculation Node
|
||||
- data_key: feeding_order
|
||||
data_source: executor
|
||||
data_type: array
|
||||
handler_key: feeding_order
|
||||
io_type: sink
|
||||
label: Feeding Order Data From Calculation Node
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: 计算实验设计,输出solutions/titration/solvents/feeding_order用于后续节点。
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
m_tot:
|
||||
default: '70'
|
||||
description: 总质量(g)
|
||||
type: string
|
||||
ratio:
|
||||
description: 组分摩尔比的对象,保持输入顺序,如{"MDA":1,"BTDA":1}
|
||||
type: object
|
||||
titration_percent:
|
||||
default: '0.03'
|
||||
description: 滴定比例(10%部分)
|
||||
type: string
|
||||
wt_percent:
|
||||
default: '0.25'
|
||||
description: 目标固含质量分数
|
||||
type: string
|
||||
required:
|
||||
- ratio
|
||||
@@ -377,6 +95,305 @@ bioyond_dispensing_station:
|
||||
title: compute_experiment_design参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-process_order_finish_report:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default:
|
||||
report_request: null
|
||||
used_materials: null
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
report_request:
|
||||
type: string
|
||||
used_materials:
|
||||
type: string
|
||||
required:
|
||||
- report_request
|
||||
- used_materials
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: process_order_finish_report参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-project_order_report:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default:
|
||||
order_id: null
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
order_id:
|
||||
type: string
|
||||
required:
|
||||
- order_id
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: project_order_report参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-query_resource_by_name:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default:
|
||||
material_name: null
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
material_name:
|
||||
type: string
|
||||
required:
|
||||
- material_name
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: query_resource_by_name参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-transfer_materials_to_reaction_station:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default:
|
||||
target_device_id: null
|
||||
transfer_groups: null
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
target_device_id:
|
||||
type: string
|
||||
transfer_groups:
|
||||
type: array
|
||||
required:
|
||||
- target_device_id
|
||||
- transfer_groups
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: transfer_materials_to_reaction_station参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-workflow_sample_locations:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default:
|
||||
workflow_id: null
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
workflow_id:
|
||||
type: string
|
||||
required:
|
||||
- workflow_id
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: workflow_sample_locations参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
batch_create_90_10_vial_feeding_tasks:
|
||||
feedback: {}
|
||||
goal:
|
||||
delay_time: delay_time
|
||||
hold_m_name: hold_m_name
|
||||
liquid_material_name: liquid_material_name
|
||||
speed: speed
|
||||
temperature: temperature
|
||||
titration: titration
|
||||
goal_default:
|
||||
delay_time: '600'
|
||||
hold_m_name: ''
|
||||
liquid_material_name: NMP
|
||||
speed: '400'
|
||||
temperature: '40'
|
||||
titration: ''
|
||||
handles:
|
||||
input:
|
||||
- data_key: titration
|
||||
data_source: handle
|
||||
data_type: object
|
||||
handler_key: titration
|
||||
io_type: source
|
||||
label: Titration Data From Calculation Node
|
||||
output:
|
||||
- data_key: return_info
|
||||
data_source: executor
|
||||
data_type: string
|
||||
handler_key: BATCH_CREATE_RESULT
|
||||
io_type: sink
|
||||
label: Complete Batch Create Result JSON (contains order_codes and order_ids)
|
||||
result:
|
||||
return_info: return_info
|
||||
schema:
|
||||
description: 批量创建90%10%小瓶投料任务。从计算节点接收titration数据,包含物料名称、主称固体质量、滴定固体质量和滴定溶剂体积。返回的return_info中包含order_codes和order_ids列表。
|
||||
properties:
|
||||
feedback:
|
||||
properties: {}
|
||||
required: []
|
||||
title: BatchCreate9010VialFeedingTasks_Feedback
|
||||
type: object
|
||||
goal:
|
||||
properties:
|
||||
delay_time:
|
||||
default: '600'
|
||||
description: 延迟时间(秒),默认600
|
||||
type: string
|
||||
hold_m_name:
|
||||
description: 库位名称,如"C01",必填参数
|
||||
type: string
|
||||
liquid_material_name:
|
||||
default: NMP
|
||||
description: 10%物料的液体物料名称,默认为"NMP"
|
||||
type: string
|
||||
speed:
|
||||
default: '400'
|
||||
description: 搅拌速度,默认400
|
||||
type: string
|
||||
temperature:
|
||||
default: '40'
|
||||
description: 温度(℃),默认40
|
||||
type: string
|
||||
titration:
|
||||
description: '滴定信息对象,包含: name(物料名称), main_portion(主称固体质量g), titration_portion(滴定固体质量g),
|
||||
titration_solvent(滴定溶液体积mL)'
|
||||
type: string
|
||||
required:
|
||||
- titration
|
||||
- hold_m_name
|
||||
title: BatchCreate9010VialFeedingTasks_Goal
|
||||
type: object
|
||||
result:
|
||||
properties:
|
||||
return_info:
|
||||
description: 批量任务创建结果汇总JSON字符串,包含total(总数)、success(成功数)、failed(失败数)、order_codes(任务编码数组)、order_ids(任务ID数组)、details(每个任务的详细信息)
|
||||
type: string
|
||||
required:
|
||||
- return_info
|
||||
title: BatchCreate9010VialFeedingTasks_Result
|
||||
type: object
|
||||
required:
|
||||
- goal
|
||||
title: BatchCreate9010VialFeedingTasks
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
batch_create_diamine_solution_tasks:
|
||||
feedback: {}
|
||||
goal:
|
||||
delay_time: delay_time
|
||||
liquid_material_name: liquid_material_name
|
||||
solutions: solutions
|
||||
speed: speed
|
||||
temperature: temperature
|
||||
goal_default:
|
||||
delay_time: '600'
|
||||
liquid_material_name: NMP
|
||||
solutions: ''
|
||||
speed: '400'
|
||||
temperature: '20'
|
||||
handles:
|
||||
input:
|
||||
- data_key: solutions
|
||||
data_source: handle
|
||||
data_type: array
|
||||
handler_key: solutions
|
||||
io_type: source
|
||||
label: Solution Data From Python
|
||||
output:
|
||||
- data_key: return_info
|
||||
data_source: executor
|
||||
data_type: string
|
||||
handler_key: BATCH_CREATE_RESULT
|
||||
io_type: sink
|
||||
label: Complete Batch Create Result JSON (contains order_codes and order_ids)
|
||||
result:
|
||||
return_info: return_info
|
||||
schema:
|
||||
description: 批量创建二胺溶液配置任务。自动为多个二胺样品创建溶液配置任务,每个任务包含固体物料称量、溶剂添加、搅拌混合等步骤。返回的return_info中包含order_codes和order_ids列表。
|
||||
properties:
|
||||
feedback:
|
||||
properties: {}
|
||||
required: []
|
||||
title: BatchCreateDiamineSolutionTasks_Feedback
|
||||
type: object
|
||||
goal:
|
||||
properties:
|
||||
delay_time:
|
||||
default: '600'
|
||||
description: 溶液配置完成后的延迟时间(秒),用于充分混合和溶解,默认600秒
|
||||
type: string
|
||||
liquid_material_name:
|
||||
default: NMP
|
||||
description: 液体溶剂名称,用于溶解固体物料,默认为NMP(N-甲基吡咯烷酮)
|
||||
type: string
|
||||
solutions:
|
||||
description: '溶液列表,JSON数组格式,每个元素包含: name(物料名称), order(序号), solid_mass(固体质量g),
|
||||
solvent_volume(溶剂体积mL)。示例: [{"name": "MDA", "order": 0, "solid_mass":
|
||||
5.0, "solvent_volume": 20}, {"name": "MPDA", "order": 1, "solid_mass":
|
||||
4.5, "solvent_volume": 18}]'
|
||||
type: string
|
||||
speed:
|
||||
default: '400'
|
||||
description: 搅拌速度(rpm),用于混合溶液,默认400转/分钟
|
||||
type: string
|
||||
temperature:
|
||||
default: '20'
|
||||
description: 配置温度(℃),溶液配置过程的目标温度,默认20℃(室温)
|
||||
type: string
|
||||
required:
|
||||
- solutions
|
||||
title: BatchCreateDiamineSolutionTasks_Goal
|
||||
type: object
|
||||
result:
|
||||
properties:
|
||||
return_info:
|
||||
description: 批量任务创建结果汇总JSON字符串,包含total(总数)、success(成功数)、failed(失败数)、order_codes(任务编码数组)、order_ids(任务ID数组)、details(每个任务的详细信息)
|
||||
type: string
|
||||
required:
|
||||
- return_info
|
||||
title: BatchCreateDiamineSolutionTasks_Result
|
||||
type: object
|
||||
required:
|
||||
- goal
|
||||
title: BatchCreateDiamineSolutionTasks
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
create_90_10_vial_feeding_task:
|
||||
feedback: {}
|
||||
goal:
|
||||
@@ -428,18 +445,17 @@ bioyond_dispensing_station:
|
||||
speed: ''
|
||||
temperature: ''
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result:
|
||||
return_info: return_info
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback:
|
||||
additionalProperties: true
|
||||
properties: {}
|
||||
required: []
|
||||
title: DispenStationVialFeed_Feedback
|
||||
type: object
|
||||
goal:
|
||||
additionalProperties: false
|
||||
properties:
|
||||
delay_time:
|
||||
type: string
|
||||
@@ -487,13 +503,38 @@ bioyond_dispensing_station:
|
||||
type: string
|
||||
temperature:
|
||||
type: string
|
||||
required:
|
||||
- order_name
|
||||
- percent_90_1_assign_material_name
|
||||
- percent_90_1_target_weigh
|
||||
- percent_90_2_assign_material_name
|
||||
- percent_90_2_target_weigh
|
||||
- percent_90_3_assign_material_name
|
||||
- percent_90_3_target_weigh
|
||||
- percent_10_1_assign_material_name
|
||||
- percent_10_1_target_weigh
|
||||
- percent_10_1_volume
|
||||
- percent_10_1_liquid_material_name
|
||||
- percent_10_2_assign_material_name
|
||||
- percent_10_2_target_weigh
|
||||
- percent_10_2_volume
|
||||
- percent_10_2_liquid_material_name
|
||||
- percent_10_3_assign_material_name
|
||||
- percent_10_3_target_weigh
|
||||
- percent_10_3_volume
|
||||
- percent_10_3_liquid_material_name
|
||||
- speed
|
||||
- temperature
|
||||
- delay_time
|
||||
- hold_m_name
|
||||
title: DispenStationVialFeed_Goal
|
||||
type: object
|
||||
result:
|
||||
additionalProperties: false
|
||||
properties:
|
||||
return_info:
|
||||
type: string
|
||||
required:
|
||||
- return_info
|
||||
title: DispenStationVialFeed_Result
|
||||
type: object
|
||||
required:
|
||||
@@ -524,18 +565,17 @@ bioyond_dispensing_station:
|
||||
temperature: ''
|
||||
volume: ''
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result:
|
||||
return_info: return_info
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback:
|
||||
additionalProperties: true
|
||||
properties: {}
|
||||
required: []
|
||||
title: DispenStationSolnPrep_Feedback
|
||||
type: object
|
||||
goal:
|
||||
additionalProperties: false
|
||||
properties:
|
||||
delay_time:
|
||||
type: string
|
||||
@@ -555,13 +595,24 @@ bioyond_dispensing_station:
|
||||
type: string
|
||||
volume:
|
||||
type: string
|
||||
required:
|
||||
- order_name
|
||||
- material_name
|
||||
- target_weigh
|
||||
- volume
|
||||
- liquid_material_name
|
||||
- speed
|
||||
- temperature
|
||||
- delay_time
|
||||
- hold_m_name
|
||||
title: DispenStationSolnPrep_Goal
|
||||
type: object
|
||||
result:
|
||||
additionalProperties: false
|
||||
properties:
|
||||
return_info:
|
||||
type: string
|
||||
required:
|
||||
- return_info
|
||||
title: DispenStationSolnPrep_Result
|
||||
type: object
|
||||
required:
|
||||
@@ -569,64 +620,6 @@ bioyond_dispensing_station:
|
||||
title: DispenStationSolnPrep
|
||||
type: object
|
||||
type: DispenStationSolnPrep
|
||||
scheduler_start:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default: {}
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: 启动调度器 - 启动Bioyond配液站的任务调度器,开始执行队列中的任务
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties: {}
|
||||
required: []
|
||||
type: object
|
||||
result:
|
||||
title: scheduler_start结果
|
||||
type: object
|
||||
required:
|
||||
- goal
|
||||
title: scheduler_start参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
transfer_materials_to_reaction_station:
|
||||
feedback: {}
|
||||
goal:
|
||||
target_device_id: target_device_id
|
||||
transfer_groups: transfer_groups
|
||||
goal_default:
|
||||
target_device_id: null
|
||||
transfer_groups: null
|
||||
handles: {}
|
||||
placeholder_keys:
|
||||
target_device_id: unilabos_devices
|
||||
result: {}
|
||||
schema:
|
||||
description: 将配液站完成的物料(溶液、样品等)转移到指定反应站的堆栈库位。支持配置多组转移任务,每组包含物料名称、目标堆栈和目标库位。
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
target_device_id:
|
||||
description: 目标反应站设备ID(从设备列表中选择,所有转移组都使用同一个目标设备)
|
||||
type: string
|
||||
transfer_groups:
|
||||
description: 转移任务组列表,每组包含物料名称、目标堆栈和目标库位,可以添加多组
|
||||
type: array
|
||||
required:
|
||||
- target_device_id
|
||||
- transfer_groups
|
||||
type: object
|
||||
result:
|
||||
type: object
|
||||
required:
|
||||
- goal
|
||||
title: transfer_materials_to_reaction_station参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
wait_for_multiple_orders_and_get_reports:
|
||||
feedback: {}
|
||||
goal:
|
||||
@@ -634,9 +627,9 @@ bioyond_dispensing_station:
|
||||
check_interval: check_interval
|
||||
timeout: timeout
|
||||
goal_default:
|
||||
batch_create_result: null
|
||||
check_interval: 10
|
||||
timeout: 7200
|
||||
batch_create_result: ''
|
||||
check_interval: '10'
|
||||
timeout: '7200'
|
||||
handles:
|
||||
input:
|
||||
- data_key: batch_create_result
|
||||
@@ -652,38 +645,50 @@ bioyond_dispensing_station:
|
||||
handler_key: batch_reports_result
|
||||
io_type: sink
|
||||
label: Batch Order Completion Reports
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
result:
|
||||
return_info: return_info
|
||||
schema:
|
||||
description: 同时等待多个任务完成并获取所有实验报告。从上游batch_create任务接收包含order_codes和order_ids的结果对象,并行监控所有任务状态并返回每个任务的报告。
|
||||
properties:
|
||||
feedback:
|
||||
properties: {}
|
||||
required: []
|
||||
title: WaitForMultipleOrdersAndGetReports_Feedback
|
||||
type: object
|
||||
goal:
|
||||
properties:
|
||||
batch_create_result:
|
||||
description: 批量创建任务的返回结果对象,包含order_codes和order_ids数组。从上游batch_create节点通过handle传递
|
||||
type: string
|
||||
check_interval:
|
||||
default: 10
|
||||
default: '10'
|
||||
description: 检查任务状态的时间间隔(秒),默认每10秒检查一次所有待完成任务
|
||||
type: integer
|
||||
type: string
|
||||
timeout:
|
||||
default: 7200
|
||||
default: '7200'
|
||||
description: 等待超时时间(秒),默认7200秒(2小时)。超过此时间未完成的任务将标记为timeout
|
||||
type: integer
|
||||
required: []
|
||||
type: string
|
||||
required:
|
||||
- batch_create_result
|
||||
title: WaitForMultipleOrdersAndGetReports_Goal
|
||||
type: object
|
||||
result:
|
||||
properties:
|
||||
return_info:
|
||||
description: 'JSON格式的批量任务完成信息,包含: total(总数), completed(成功数), timeout(超时数),
|
||||
error(错误数), elapsed_time(总耗时), reports(报告数组,每个元素包含order_code,
|
||||
order_id, status, completion_status, report, elapsed_time)'
|
||||
type: string
|
||||
required:
|
||||
- return_info
|
||||
title: WaitForMultipleOrdersAndGetReports_Result
|
||||
type: object
|
||||
required:
|
||||
- goal
|
||||
title: wait_for_multiple_orders_and_get_reports参数
|
||||
title: WaitForMultipleOrdersAndGetReports
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
module: unilabos.devices.workstation.bioyond_studio.dispensing_station.dispensing_station:BioyondDispensingStation
|
||||
module: unilabos.devices.workstation.bioyond_studio.dispensing_station:BioyondDispensingStation
|
||||
status_types: {}
|
||||
type: python
|
||||
config_info: []
|
||||
@@ -694,16 +699,15 @@ bioyond_dispensing_station:
|
||||
config:
|
||||
properties:
|
||||
config:
|
||||
type: object
|
||||
type: string
|
||||
deck:
|
||||
type: string
|
||||
protocol_type:
|
||||
type: string
|
||||
required: []
|
||||
required:
|
||||
- config
|
||||
- deck
|
||||
type: object
|
||||
data:
|
||||
properties: {}
|
||||
required: []
|
||||
type: object
|
||||
model: {}
|
||||
version: 1.0.0
|
||||
|
||||
78
unilabos/registry/devices/camera.yaml
Normal file
78
unilabos/registry/devices/camera.yaml
Normal file
@@ -0,0 +1,78 @@
|
||||
camera:
|
||||
category:
|
||||
- camera
|
||||
class:
|
||||
action_value_mappings:
|
||||
auto-destroy_node:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default: {}
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: 用于安全地关闭摄像头设备,释放摄像头资源,停止视频采集和发布服务。调用此函数将清理OpenCV摄像头连接并销毁ROS2节点。
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties: {}
|
||||
required: []
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: destroy_node参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-timer_callback:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default: {}
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: 定时器回调函数的参数schema。此函数负责定期采集摄像头视频帧,将OpenCV格式的图像转换为ROS Image消息格式,并发布到指定的视频话题。默认以10Hz频率执行,确保视频流的连续性和实时性。
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties: {}
|
||||
required: []
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: timer_callback参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
module: unilabos.ros.nodes.presets.camera:VideoPublisher
|
||||
status_types: {}
|
||||
type: ros2
|
||||
config_info: []
|
||||
description: VideoPublisher摄像头设备节点,用于实时视频采集和流媒体发布。该设备通过OpenCV连接本地摄像头(如USB摄像头、内置摄像头等),定时采集视频帧并将其转换为ROS2的sensor_msgs/Image消息格式发布到视频话题。主要用于实验室自动化系统中的视觉监控、图像分析、实时观察等应用场景。支持可配置的摄像头索引、发布频率等参数。
|
||||
handles: []
|
||||
icon: ''
|
||||
init_param_schema:
|
||||
config:
|
||||
properties:
|
||||
camera_index:
|
||||
default: 0
|
||||
type: string
|
||||
device_id:
|
||||
default: video_publisher
|
||||
type: string
|
||||
device_uuid:
|
||||
default: ''
|
||||
type: string
|
||||
period:
|
||||
default: 0.1
|
||||
type: number
|
||||
resource_tracker:
|
||||
type: object
|
||||
required: []
|
||||
type: object
|
||||
data:
|
||||
properties: {}
|
||||
required: []
|
||||
type: object
|
||||
version: 1.0.0
|
||||
@@ -18,7 +18,7 @@ cameracontroller_device:
|
||||
goal:
|
||||
properties:
|
||||
config:
|
||||
type: object
|
||||
type: string
|
||||
required: []
|
||||
type: object
|
||||
result: {}
|
||||
@@ -42,8 +42,7 @@ cameracontroller_device:
|
||||
properties: {}
|
||||
required: []
|
||||
type: object
|
||||
result:
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: stop参数
|
||||
@@ -51,7 +50,7 @@ cameracontroller_device:
|
||||
type: UniLabJsonCommand
|
||||
module: unilabos.devices.cameraSII.cameraUSB:CameraController
|
||||
status_types:
|
||||
status: Dict[str, Any]
|
||||
status: dict
|
||||
type: python
|
||||
config_info: []
|
||||
description: Uni-Lab-OS 摄像头驱动(Linux USB 摄像头版,无 PTZ)
|
||||
@@ -104,4 +103,5 @@ cameracontroller_device:
|
||||
required:
|
||||
- status
|
||||
type: object
|
||||
registry_type: device
|
||||
version: 1.0.0
|
||||
|
||||
@@ -141,26 +141,30 @@ hplc.agilent:
|
||||
description: ''
|
||||
properties:
|
||||
feedback:
|
||||
additionalProperties: false
|
||||
properties:
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
- status
|
||||
title: SendCmd_Feedback
|
||||
type: object
|
||||
goal:
|
||||
additionalProperties: false
|
||||
properties:
|
||||
command:
|
||||
type: string
|
||||
required:
|
||||
- command
|
||||
title: SendCmd_Goal
|
||||
type: object
|
||||
result:
|
||||
additionalProperties: false
|
||||
properties:
|
||||
return_info:
|
||||
type: string
|
||||
success:
|
||||
type: boolean
|
||||
required:
|
||||
- return_info
|
||||
- success
|
||||
title: SendCmd_Result
|
||||
type: object
|
||||
required:
|
||||
@@ -171,6 +175,7 @@ hplc.agilent:
|
||||
module: unilabos.devices.hplc.AgilentHPLC:HPLCDriver
|
||||
status_types:
|
||||
could_run: bool
|
||||
data_file: String
|
||||
device_status: str
|
||||
driver_init_ok: bool
|
||||
finish_status: str
|
||||
@@ -194,6 +199,10 @@ hplc.agilent:
|
||||
properties:
|
||||
could_run:
|
||||
type: boolean
|
||||
data_file:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
device_status:
|
||||
type: string
|
||||
driver_init_ok:
|
||||
@@ -207,13 +216,14 @@ hplc.agilent:
|
||||
success:
|
||||
type: boolean
|
||||
required:
|
||||
- could_run
|
||||
- device_status
|
||||
- driver_init_ok
|
||||
- finish_status
|
||||
- is_running
|
||||
- status_text
|
||||
- device_status
|
||||
- could_run
|
||||
- driver_init_ok
|
||||
- is_running
|
||||
- success
|
||||
- finish_status
|
||||
- data_file
|
||||
type: object
|
||||
version: 1.0.0
|
||||
hplc.agilent-zhida:
|
||||
@@ -226,25 +236,26 @@ hplc.agilent-zhida:
|
||||
goal: {}
|
||||
goal_default: {}
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result:
|
||||
return_info: return_info
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback:
|
||||
additionalProperties: true
|
||||
properties: {}
|
||||
required: []
|
||||
title: EmptyIn_Feedback
|
||||
type: object
|
||||
goal:
|
||||
additionalProperties: true
|
||||
properties: {}
|
||||
required: []
|
||||
title: EmptyIn_Goal
|
||||
type: object
|
||||
result:
|
||||
additionalProperties: false
|
||||
properties:
|
||||
return_info:
|
||||
type: string
|
||||
required:
|
||||
- return_info
|
||||
title: EmptyIn_Result
|
||||
type: object
|
||||
required:
|
||||
@@ -304,18 +315,21 @@ hplc.agilent-zhida:
|
||||
description: ''
|
||||
properties:
|
||||
feedback:
|
||||
additionalProperties: true
|
||||
properties: {}
|
||||
required: []
|
||||
title: EmptyIn_Feedback
|
||||
type: object
|
||||
goal:
|
||||
additionalProperties: true
|
||||
properties: {}
|
||||
required: []
|
||||
title: EmptyIn_Goal
|
||||
type: object
|
||||
result:
|
||||
additionalProperties: false
|
||||
properties:
|
||||
return_info:
|
||||
type: string
|
||||
required:
|
||||
- return_info
|
||||
title: EmptyIn_Result
|
||||
type: object
|
||||
required:
|
||||
@@ -327,35 +341,35 @@ hplc.agilent-zhida:
|
||||
feedback: {}
|
||||
goal:
|
||||
string: string
|
||||
text: text
|
||||
goal_default:
|
||||
string: ''
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result:
|
||||
return_info: return_info
|
||||
success: success
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback:
|
||||
additionalProperties: true
|
||||
properties: {}
|
||||
required: []
|
||||
title: StrSingleInput_Feedback
|
||||
type: object
|
||||
goal:
|
||||
additionalProperties: false
|
||||
properties:
|
||||
string:
|
||||
type: string
|
||||
required:
|
||||
- string
|
||||
title: StrSingleInput_Goal
|
||||
type: object
|
||||
result:
|
||||
additionalProperties: false
|
||||
properties:
|
||||
return_info:
|
||||
type: string
|
||||
success:
|
||||
type: boolean
|
||||
required:
|
||||
- return_info
|
||||
- success
|
||||
title: StrSingleInput_Result
|
||||
type: object
|
||||
required:
|
||||
@@ -393,7 +407,7 @@ hplc.agilent-zhida:
|
||||
status:
|
||||
type: object
|
||||
required:
|
||||
- methods
|
||||
- status
|
||||
- methods
|
||||
type: object
|
||||
version: 1.0.0
|
||||
|
||||
@@ -120,41 +120,42 @@ raman.home_made:
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
raman_cmd:
|
||||
feedback:
|
||||
status: status
|
||||
feedback: {}
|
||||
goal:
|
||||
command: command
|
||||
goal_default:
|
||||
command: ''
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result:
|
||||
return_info: return_info
|
||||
success: success
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback:
|
||||
additionalProperties: false
|
||||
properties:
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
- status
|
||||
title: SendCmd_Feedback
|
||||
type: object
|
||||
goal:
|
||||
additionalProperties: false
|
||||
properties:
|
||||
command:
|
||||
type: string
|
||||
required:
|
||||
- command
|
||||
title: SendCmd_Goal
|
||||
type: object
|
||||
result:
|
||||
additionalProperties: false
|
||||
properties:
|
||||
return_info:
|
||||
type: string
|
||||
success:
|
||||
type: boolean
|
||||
required:
|
||||
- return_info
|
||||
- success
|
||||
title: SendCmd_Result
|
||||
type: object
|
||||
required:
|
||||
|
||||
@@ -19,8 +19,7 @@ separator.chinwe:
|
||||
properties: {}
|
||||
required: []
|
||||
type: object
|
||||
result:
|
||||
type: boolean
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: connect参数
|
||||
@@ -66,145 +65,135 @@ separator.chinwe:
|
||||
required:
|
||||
- command_dict
|
||||
type: object
|
||||
result:
|
||||
type: boolean
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: execute_command_from_outer参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
motor_rotate_quarter:
|
||||
feedback: {}
|
||||
goal:
|
||||
direction: 顺时针
|
||||
motor_id: 4
|
||||
speed: 60
|
||||
goal_default:
|
||||
direction: 顺时针
|
||||
motor_id: null
|
||||
speed: 60
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: 电机旋转 1/4 圈
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
direction:
|
||||
default: 顺时针
|
||||
description: 旋转方向
|
||||
enum:
|
||||
- 顺时针
|
||||
- 逆时针
|
||||
type: string
|
||||
motor_id:
|
||||
default: '4'
|
||||
description: 选择电机 (4:搅拌, 5:旋钮)
|
||||
type: integer
|
||||
enum:
|
||||
- '4'
|
||||
- '5'
|
||||
type: string
|
||||
speed:
|
||||
default: 60
|
||||
description: 速度 (RPM)
|
||||
type: integer
|
||||
required:
|
||||
- motor_id
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: motor_rotate_quarter参数
|
||||
- speed
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
motor_run_continuous:
|
||||
feedback: {}
|
||||
goal:
|
||||
direction: 顺时针
|
||||
motor_id: 4
|
||||
speed: 60
|
||||
goal_default:
|
||||
direction: 顺时针
|
||||
motor_id: null
|
||||
speed: null
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: 电机一直旋转 (速度模式)
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
direction:
|
||||
default: 顺时针
|
||||
description: 旋转方向
|
||||
enum:
|
||||
- 顺时针
|
||||
- 逆时针
|
||||
type: string
|
||||
motor_id:
|
||||
default: '4'
|
||||
description: 选择电机 (4:搅拌, 5:旋钮)
|
||||
type: integer
|
||||
enum:
|
||||
- '4'
|
||||
- '5'
|
||||
type: string
|
||||
speed:
|
||||
default: 60
|
||||
description: 速度 (RPM)
|
||||
type: integer
|
||||
required:
|
||||
- motor_id
|
||||
- speed
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: motor_run_continuous参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
motor_stop:
|
||||
feedback: {}
|
||||
goal:
|
||||
motor_id: 4
|
||||
goal_default:
|
||||
motor_id: null
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: 停止指定步进电机
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
motor_id:
|
||||
default: '4'
|
||||
description: 选择电机
|
||||
enum:
|
||||
- '4'
|
||||
- '5'
|
||||
title: '注: 4=搅拌, 5=旋钮'
|
||||
type: integer
|
||||
type: string
|
||||
required:
|
||||
- motor_id
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: motor_stop参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
pump_aspirate:
|
||||
feedback: {}
|
||||
goal:
|
||||
pump_id: 1
|
||||
valve_port: 1
|
||||
volume: 1000
|
||||
goal_default:
|
||||
pump_id: null
|
||||
valve_port: null
|
||||
volume: null
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: 注射泵吸液
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
pump_id:
|
||||
default: '1'
|
||||
description: 选择泵
|
||||
type: integer
|
||||
enum:
|
||||
- '1'
|
||||
- '2'
|
||||
- '3'
|
||||
type: string
|
||||
valve_port:
|
||||
default: '1'
|
||||
description: 阀门端口
|
||||
type: integer
|
||||
enum:
|
||||
- '1'
|
||||
- '2'
|
||||
- '3'
|
||||
- '4'
|
||||
- '5'
|
||||
- '6'
|
||||
- '7'
|
||||
- '8'
|
||||
type: string
|
||||
volume:
|
||||
default: 1000
|
||||
description: 吸液步数
|
||||
type: integer
|
||||
required:
|
||||
@@ -212,38 +201,41 @@ separator.chinwe:
|
||||
- volume
|
||||
- valve_port
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: pump_aspirate参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
pump_dispense:
|
||||
feedback: {}
|
||||
goal:
|
||||
pump_id: 1
|
||||
valve_port: 1
|
||||
volume: 1000
|
||||
goal_default:
|
||||
pump_id: null
|
||||
valve_port: null
|
||||
volume: null
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: 注射泵排液
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
pump_id:
|
||||
default: '1'
|
||||
description: 选择泵
|
||||
type: integer
|
||||
enum:
|
||||
- '1'
|
||||
- '2'
|
||||
- '3'
|
||||
type: string
|
||||
valve_port:
|
||||
default: '1'
|
||||
description: 阀门端口
|
||||
type: integer
|
||||
enum:
|
||||
- '1'
|
||||
- '2'
|
||||
- '3'
|
||||
- '4'
|
||||
- '5'
|
||||
- '6'
|
||||
- '7'
|
||||
- '8'
|
||||
type: string
|
||||
volume:
|
||||
default: 1000
|
||||
description: 排液步数
|
||||
type: integer
|
||||
required:
|
||||
@@ -251,152 +243,121 @@ separator.chinwe:
|
||||
- volume
|
||||
- valve_port
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: pump_dispense参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
pump_initialize:
|
||||
feedback: {}
|
||||
goal:
|
||||
drain_port: 0
|
||||
output_port: 0
|
||||
pump_id: 1
|
||||
speed: 10
|
||||
goal_default:
|
||||
drain_port: 0
|
||||
output_port: 0
|
||||
pump_id: null
|
||||
speed: 10
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: 初始化指定注射泵
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
drain_port:
|
||||
default: 0
|
||||
description: 排液口索引
|
||||
type: string
|
||||
type: integer
|
||||
output_port:
|
||||
default: 0
|
||||
description: 输出口索引
|
||||
type: string
|
||||
pump_id:
|
||||
description: 选择泵
|
||||
title: '注: 1号泵, 2号泵, 3号泵'
|
||||
type: integer
|
||||
pump_id:
|
||||
default: '1'
|
||||
description: 选择泵
|
||||
enum:
|
||||
- '1'
|
||||
- '2'
|
||||
- '3'
|
||||
title: '注: 1号泵, 2号泵, 3号泵'
|
||||
type: string
|
||||
speed:
|
||||
default: 10
|
||||
description: 运动速度
|
||||
type: string
|
||||
type: integer
|
||||
required:
|
||||
- pump_id
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: pump_initialize参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
pump_valve:
|
||||
feedback: {}
|
||||
goal:
|
||||
port: 1
|
||||
pump_id: 1
|
||||
goal_default:
|
||||
port: null
|
||||
pump_id: null
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: 切换指定泵的阀门端口
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
port:
|
||||
default: '1'
|
||||
description: 阀门端口号 (1-8)
|
||||
type: integer
|
||||
enum:
|
||||
- '1'
|
||||
- '2'
|
||||
- '3'
|
||||
- '4'
|
||||
- '5'
|
||||
- '6'
|
||||
- '7'
|
||||
- '8'
|
||||
type: string
|
||||
pump_id:
|
||||
default: '1'
|
||||
description: 选择泵
|
||||
type: integer
|
||||
enum:
|
||||
- '1'
|
||||
- '2'
|
||||
- '3'
|
||||
type: string
|
||||
required:
|
||||
- pump_id
|
||||
- port
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: pump_valve参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
wait_sensor_level:
|
||||
feedback: {}
|
||||
goal:
|
||||
target_state: 有液
|
||||
timeout: 30
|
||||
goal_default:
|
||||
target_state: 有液
|
||||
timeout: 30
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: 等待传感器液位条件
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
target_state:
|
||||
default: 有液
|
||||
description: 目标液位状态
|
||||
enum:
|
||||
- 有液
|
||||
- 无液
|
||||
type: string
|
||||
timeout:
|
||||
default: 30
|
||||
description: 超时时间 (秒)
|
||||
type: integer
|
||||
required: []
|
||||
type: object
|
||||
result:
|
||||
type: boolean
|
||||
required:
|
||||
- goal
|
||||
title: wait_sensor_level参数
|
||||
- target_state
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
wait_time:
|
||||
feedback: {}
|
||||
goal:
|
||||
duration: 10
|
||||
goal_default:
|
||||
duration: null
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: 等待指定时间
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
duration:
|
||||
default: 10
|
||||
description: 等待时间 (秒)
|
||||
type: integer
|
||||
required:
|
||||
- duration
|
||||
type: object
|
||||
result:
|
||||
type: boolean
|
||||
required:
|
||||
- goal
|
||||
title: wait_time参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
module: unilabos.devices.separator.chinwe:ChinweDevice
|
||||
status_types:
|
||||
@@ -445,8 +406,8 @@ separator.chinwe:
|
||||
sensor_rssi:
|
||||
type: integer
|
||||
required:
|
||||
- is_connected
|
||||
- sensor_level
|
||||
- sensor_rssi
|
||||
- is_connected
|
||||
type: object
|
||||
version: 2.1.0
|
||||
|
||||
@@ -1,834 +0,0 @@
|
||||
coincellassemblyworkstation_device:
|
||||
category:
|
||||
- coin_cell_workstation
|
||||
class:
|
||||
action_value_mappings:
|
||||
auto-change_hole_sheet_to_2:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default:
|
||||
hole: null
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
hole:
|
||||
type: object
|
||||
required:
|
||||
- hole
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: change_hole_sheet_to_2参数
|
||||
type: object
|
||||
type: UniLabJsonCommandAsync
|
||||
auto-fill_plate:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default: {}
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties: {}
|
||||
required: []
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: fill_plate参数
|
||||
type: object
|
||||
type: UniLabJsonCommandAsync
|
||||
auto-fun_wuliao_test:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default: {}
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties: {}
|
||||
required: []
|
||||
type: object
|
||||
result:
|
||||
type: boolean
|
||||
required:
|
||||
- goal
|
||||
title: fun_wuliao_test参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-func_allpack_cmd:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default:
|
||||
assembly_pressure: 4200
|
||||
assembly_type: 7
|
||||
elec_num: null
|
||||
elec_use_num: null
|
||||
elec_vol: 50
|
||||
file_path: /Users/sml/work
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
assembly_pressure:
|
||||
default: 4200
|
||||
type: integer
|
||||
assembly_type:
|
||||
default: 7
|
||||
type: integer
|
||||
elec_num:
|
||||
type: string
|
||||
elec_use_num:
|
||||
type: string
|
||||
elec_vol:
|
||||
default: 50
|
||||
type: integer
|
||||
file_path:
|
||||
default: /Users/sml/work
|
||||
type: string
|
||||
required:
|
||||
- elec_num
|
||||
- elec_use_num
|
||||
type: object
|
||||
result:
|
||||
type: object
|
||||
required:
|
||||
- goal
|
||||
title: func_allpack_cmd参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-func_allpack_cmd_simp:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default:
|
||||
assembly_pressure: 4200
|
||||
assembly_type: 7
|
||||
battery_clean_ignore: false
|
||||
battery_pressure_mode: true
|
||||
dual_drop_first_volume: 25
|
||||
dual_drop_mode: false
|
||||
dual_drop_start_timing: false
|
||||
dual_drop_suction_timing: false
|
||||
elec_num: null
|
||||
elec_use_num: null
|
||||
elec_vol: 50
|
||||
file_path: /Users/sml/work
|
||||
fujipian_juzhendianwei: 0
|
||||
fujipian_panshu: 0
|
||||
gemo_juzhendianwei: 0
|
||||
gemopanshu: 0
|
||||
lvbodian: true
|
||||
qiangtou_juzhendianwei: 0
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: 简化版电池组装函数,整合了参数设置和双滴模式
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
assembly_pressure:
|
||||
default: 4200
|
||||
description: 电池压制力(N)
|
||||
type: integer
|
||||
assembly_type:
|
||||
default: 7
|
||||
description: 组装类型(7=不用铝箔垫, 8=使用铝箔垫)
|
||||
type: integer
|
||||
battery_clean_ignore:
|
||||
default: false
|
||||
description: 是否忽略电池清洁步骤
|
||||
type: boolean
|
||||
battery_pressure_mode:
|
||||
default: true
|
||||
description: 是否启用压力模式
|
||||
type: boolean
|
||||
dual_drop_first_volume:
|
||||
default: 25
|
||||
description: 二次滴液第一次排液体积(μL)
|
||||
type: integer
|
||||
dual_drop_mode:
|
||||
default: false
|
||||
description: 电解液添加模式(false=单次滴液, true=二次滴液)
|
||||
type: boolean
|
||||
dual_drop_start_timing:
|
||||
default: false
|
||||
description: 二次滴液开始滴液时机(false=正极片前, true=正极片后)
|
||||
type: boolean
|
||||
dual_drop_suction_timing:
|
||||
default: false
|
||||
description: 二次滴液吸液时机(false=正常吸液, true=先吸液)
|
||||
type: boolean
|
||||
elec_num:
|
||||
description: 电解液瓶数
|
||||
type: string
|
||||
elec_use_num:
|
||||
description: 每瓶电解液组装电池数
|
||||
type: string
|
||||
elec_vol:
|
||||
default: 50
|
||||
description: 电解液吸液量(μL)
|
||||
type: integer
|
||||
file_path:
|
||||
default: /Users/sml/work
|
||||
description: 实验记录保存路径
|
||||
type: string
|
||||
fujipian_juzhendianwei:
|
||||
default: 0
|
||||
description: 负极片矩阵点位。盘位置从1开始计数,有效范围:1-8, 13-20 (写入值比实际位置少1,例如:写0取盘位1,写1取盘位2)
|
||||
type: integer
|
||||
fujipian_panshu:
|
||||
default: 0
|
||||
description: 负极片盘数
|
||||
type: integer
|
||||
gemo_juzhendianwei:
|
||||
default: 0
|
||||
description: 隔膜矩阵点位。盘位置从1开始计数,有效范围:1-8, 13-20 (写入值比实际位置少1,例如:写0取盘位1,写1取盘位2)
|
||||
type: integer
|
||||
gemopanshu:
|
||||
default: 0
|
||||
description: 隔膜盘数
|
||||
type: integer
|
||||
lvbodian:
|
||||
default: true
|
||||
description: 是否使用铝箔垫片
|
||||
type: boolean
|
||||
qiangtou_juzhendianwei:
|
||||
default: 0
|
||||
description: 枪头盒矩阵点位。盘位置从1开始计数,有效范围:1-32, 64-96 (写入值比实际位置少1,例如:写0取盘位1,写1取盘位2)
|
||||
type: integer
|
||||
required:
|
||||
- elec_num
|
||||
- elec_use_num
|
||||
type: object
|
||||
result:
|
||||
type: object
|
||||
required:
|
||||
- goal
|
||||
title: func_allpack_cmd_simp参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-func_get_csv_export_status:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default: {}
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties: {}
|
||||
required: []
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: func_get_csv_export_status参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-func_pack_device_auto:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default: {}
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties: {}
|
||||
required: []
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: func_pack_device_auto参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-func_pack_device_init:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default: {}
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties: {}
|
||||
required: []
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: func_pack_device_init参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-func_pack_device_init_auto_start_combined:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default:
|
||||
material_search_enable: false
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: 组合函数:设备初始化 + 物料搜寻确认 + 切换自动模式 + 启动。初始化过程中会自动检测物料搜寻确认弹窗,并根据参数自动点击"是"或"否"按钮
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
material_search_enable:
|
||||
default: false
|
||||
description: 是否启用物料搜寻功能。设备初始化后会弹出物料搜寻确认弹窗,此参数控制自动点击"是"(启用)或"否"(不启用)。默认为false(不启用物料搜寻)
|
||||
type: boolean
|
||||
required: []
|
||||
type: object
|
||||
result:
|
||||
type: boolean
|
||||
required:
|
||||
- goal
|
||||
title: func_pack_device_init_auto_start_combined参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-func_pack_device_start:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default: {}
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties: {}
|
||||
required: []
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: func_pack_device_start参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-func_pack_device_stop:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default: {}
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties: {}
|
||||
required: []
|
||||
type: object
|
||||
result:
|
||||
type: boolean
|
||||
required:
|
||||
- goal
|
||||
title: func_pack_device_stop参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-func_pack_get_msg_cmd:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default:
|
||||
file_path: D:\coin_cell_data
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
file_path:
|
||||
default: D:\coin_cell_data
|
||||
type: string
|
||||
required: []
|
||||
type: object
|
||||
result:
|
||||
type: boolean
|
||||
required:
|
||||
- goal
|
||||
title: func_pack_get_msg_cmd参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-func_pack_send_bottle_num:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default:
|
||||
bottle_num: null
|
||||
handles:
|
||||
input:
|
||||
- data_key: bottle_num
|
||||
data_source: workflow
|
||||
data_type: integer
|
||||
handler_key: bottle_count
|
||||
io_type: source
|
||||
label: 配液瓶数
|
||||
required: true
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
bottle_num:
|
||||
type: string
|
||||
required:
|
||||
- bottle_num
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: func_pack_send_bottle_num参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-func_pack_send_finished_cmd:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default: {}
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties: {}
|
||||
required: []
|
||||
type: object
|
||||
result:
|
||||
type: boolean
|
||||
required:
|
||||
- goal
|
||||
title: func_pack_send_finished_cmd参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-func_pack_send_msg_cmd:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default:
|
||||
assembly_pressure: null
|
||||
assembly_type: null
|
||||
elec_use_num: null
|
||||
elec_vol: null
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
assembly_pressure:
|
||||
type: string
|
||||
assembly_type:
|
||||
type: string
|
||||
elec_use_num:
|
||||
type: string
|
||||
elec_vol:
|
||||
type: string
|
||||
required:
|
||||
- elec_use_num
|
||||
- elec_vol
|
||||
- assembly_type
|
||||
- assembly_pressure
|
||||
type: object
|
||||
result:
|
||||
type: boolean
|
||||
required:
|
||||
- goal
|
||||
title: func_pack_send_msg_cmd参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-func_read_data_and_output:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default:
|
||||
file_path: /Users/sml/work
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
file_path:
|
||||
default: /Users/sml/work
|
||||
type: string
|
||||
required: []
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: func_read_data_and_output参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-func_sendbottle_allpack_multi:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default:
|
||||
assembly_pressure: 4200
|
||||
assembly_type: 7
|
||||
battery_clean_ignore: false
|
||||
battery_pressure_mode: true
|
||||
dual_drop_first_volume: 25
|
||||
dual_drop_mode: false
|
||||
dual_drop_start_timing: false
|
||||
dual_drop_suction_timing: false
|
||||
elec_num: null
|
||||
elec_use_num: null
|
||||
elec_vol: 50
|
||||
file_path: /Users/sml/work
|
||||
fujipian_juzhendianwei: 0
|
||||
fujipian_panshu: 0
|
||||
gemo_juzhendianwei: 0
|
||||
gemopanshu: 0
|
||||
lvbodian: true
|
||||
qiangtou_juzhendianwei: 0
|
||||
handles:
|
||||
input:
|
||||
- data_key: elec_num
|
||||
data_source: workflow
|
||||
data_type: integer
|
||||
handler_key: bottle_count
|
||||
io_type: source
|
||||
label: 配液瓶数
|
||||
required: true
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: 发送瓶数+简化组装函数(适用于第二批次及后续批次),合并了发送瓶数和简化组装流程
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
assembly_pressure:
|
||||
default: 4200
|
||||
description: 电池压制力(N)
|
||||
type: integer
|
||||
assembly_type:
|
||||
default: 7
|
||||
description: 组装类型(7=不用铝箔垫, 8=使用铝箔垫)
|
||||
type: integer
|
||||
battery_clean_ignore:
|
||||
default: false
|
||||
description: 是否忽略电池清洁步骤
|
||||
type: boolean
|
||||
battery_pressure_mode:
|
||||
default: true
|
||||
description: 是否启用压力模式
|
||||
type: boolean
|
||||
dual_drop_first_volume:
|
||||
default: 25
|
||||
description: 二次滴液第一次排液体积(μL)
|
||||
type: integer
|
||||
dual_drop_mode:
|
||||
default: false
|
||||
description: 电解液添加模式(false=单次滴液, true=二次滴液)
|
||||
type: boolean
|
||||
dual_drop_start_timing:
|
||||
default: false
|
||||
description: 二次滴液开始滴液时机(false=正极片前, true=正极片后)
|
||||
type: boolean
|
||||
dual_drop_suction_timing:
|
||||
default: false
|
||||
description: 二次滴液吸液时机(false=正常吸液, true=先吸液)
|
||||
type: boolean
|
||||
elec_num:
|
||||
description: 电解液瓶数,如果在workflow中已通过handles连接上游(create_orders的bottle_count输出),则此参数会自动从上游获取,无需手动填写;如果单独使用此函数(没有上游连接),则必须手动填写电解液瓶数
|
||||
type: string
|
||||
elec_use_num:
|
||||
description: 每瓶电解液组装电池数
|
||||
type: string
|
||||
elec_vol:
|
||||
default: 50
|
||||
description: 电解液吸液量(μL)
|
||||
type: integer
|
||||
file_path:
|
||||
default: /Users/sml/work
|
||||
description: 实验记录保存路径
|
||||
type: string
|
||||
fujipian_juzhendianwei:
|
||||
default: 0
|
||||
description: 负极片矩阵点位。盘位置从1开始计数,有效范围:1-8, 13-20 (写入值比实际位置少1,例如:写0取盘位1,写1取盘位2)
|
||||
type: integer
|
||||
fujipian_panshu:
|
||||
default: 0
|
||||
description: 负极片盘数
|
||||
type: integer
|
||||
gemo_juzhendianwei:
|
||||
default: 0
|
||||
description: 隔膜矩阵点位。盘位置从1开始计数,有效范围:1-8, 13-20 (写入值比实际位置少1,例如:写0取盘位1,写1取盘位2)
|
||||
type: integer
|
||||
gemopanshu:
|
||||
default: 0
|
||||
description: 隔膜盘数
|
||||
type: integer
|
||||
lvbodian:
|
||||
default: true
|
||||
description: 是否使用铝箔垫片
|
||||
type: boolean
|
||||
qiangtou_juzhendianwei:
|
||||
default: 0
|
||||
description: 枪头盒矩阵点位。盘位置从1开始计数,有效范围:1-32, 64-96 (写入值比实际位置少1,例如:写0取盘位1,写1取盘位2)
|
||||
type: integer
|
||||
required:
|
||||
- elec_num
|
||||
- elec_use_num
|
||||
type: object
|
||||
result:
|
||||
type: object
|
||||
required:
|
||||
- goal
|
||||
title: func_sendbottle_allpack_multi参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-func_stop_read_data:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default: {}
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties: {}
|
||||
required: []
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: func_stop_read_data参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-modify_deck_name:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default:
|
||||
resource_name: null
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
resource_name:
|
||||
type: string
|
||||
required:
|
||||
- resource_name
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: modify_deck_name参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-qiming_coin_cell_code:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default:
|
||||
battery_clean_ignore: false
|
||||
battery_pressure: 4000
|
||||
battery_pressure_mode: true
|
||||
fujipian_juzhendianwei: 0
|
||||
fujipian_panshu: null
|
||||
gemo_juzhendianwei: 0
|
||||
gemopanshu: 0
|
||||
lvbodian: true
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
battery_clean_ignore:
|
||||
default: false
|
||||
type: boolean
|
||||
battery_pressure:
|
||||
default: 4000
|
||||
type: integer
|
||||
battery_pressure_mode:
|
||||
default: true
|
||||
type: boolean
|
||||
fujipian_juzhendianwei:
|
||||
default: 0
|
||||
type: integer
|
||||
fujipian_panshu:
|
||||
type: integer
|
||||
gemo_juzhendianwei:
|
||||
default: 0
|
||||
type: integer
|
||||
gemopanshu:
|
||||
default: 0
|
||||
type: integer
|
||||
lvbodian:
|
||||
default: true
|
||||
type: boolean
|
||||
required:
|
||||
- fujipian_panshu
|
||||
type: object
|
||||
result:
|
||||
type: boolean
|
||||
required:
|
||||
- goal
|
||||
title: qiming_coin_cell_code参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
module: unilabos.devices.workstation.coin_cell_assembly.coin_cell_assembly:CoinCellAssemblyWorkstation
|
||||
status_types:
|
||||
data_assembly_coin_cell_num: int
|
||||
data_assembly_pressure: int
|
||||
data_assembly_time: float
|
||||
data_axis_x_pos: float
|
||||
data_axis_y_pos: float
|
||||
data_axis_z_pos: float
|
||||
data_coin_cell_code: str
|
||||
data_coin_num: int
|
||||
data_electrolyte_code: str
|
||||
data_electrolyte_volume: int
|
||||
data_glove_box_o2_content: float
|
||||
data_glove_box_pressure: float
|
||||
data_glove_box_water_content: float
|
||||
data_open_circuit_voltage: float
|
||||
data_pole_weight: float
|
||||
request_rec_msg_status: bool
|
||||
request_send_msg_status: bool
|
||||
sys_mode: str
|
||||
sys_status: str
|
||||
type: python
|
||||
config_info: []
|
||||
description: ''
|
||||
handles: []
|
||||
icon: koudian.webp
|
||||
init_param_schema:
|
||||
config:
|
||||
properties:
|
||||
address:
|
||||
default: 172.16.28.102
|
||||
type: string
|
||||
config:
|
||||
type: object
|
||||
debug_mode:
|
||||
default: false
|
||||
type: boolean
|
||||
deck:
|
||||
type: string
|
||||
port:
|
||||
default: '502'
|
||||
type: string
|
||||
required: []
|
||||
type: object
|
||||
data:
|
||||
properties:
|
||||
data_assembly_coin_cell_num:
|
||||
type: integer
|
||||
data_assembly_pressure:
|
||||
type: integer
|
||||
data_assembly_time:
|
||||
type: number
|
||||
data_axis_x_pos:
|
||||
type: number
|
||||
data_axis_y_pos:
|
||||
type: number
|
||||
data_axis_z_pos:
|
||||
type: number
|
||||
data_coin_cell_code:
|
||||
type: string
|
||||
data_coin_num:
|
||||
type: integer
|
||||
data_electrolyte_code:
|
||||
type: string
|
||||
data_electrolyte_volume:
|
||||
type: integer
|
||||
data_glove_box_o2_content:
|
||||
type: number
|
||||
data_glove_box_pressure:
|
||||
type: number
|
||||
data_glove_box_water_content:
|
||||
type: number
|
||||
data_open_circuit_voltage:
|
||||
type: number
|
||||
data_pole_weight:
|
||||
type: number
|
||||
request_rec_msg_status:
|
||||
type: boolean
|
||||
request_send_msg_status:
|
||||
type: boolean
|
||||
sys_mode:
|
||||
type: string
|
||||
sys_status:
|
||||
type: string
|
||||
required:
|
||||
- data_assembly_coin_cell_num
|
||||
- data_assembly_pressure
|
||||
- data_assembly_time
|
||||
- data_axis_x_pos
|
||||
- data_axis_y_pos
|
||||
- data_axis_z_pos
|
||||
- data_coin_cell_code
|
||||
- data_coin_num
|
||||
- data_electrolyte_code
|
||||
- data_electrolyte_volume
|
||||
- data_glove_box_o2_content
|
||||
- data_glove_box_pressure
|
||||
- data_glove_box_water_content
|
||||
- data_open_circuit_voltage
|
||||
- data_pole_weight
|
||||
- request_rec_msg_status
|
||||
- request_send_msg_status
|
||||
- sys_mode
|
||||
- sys_status
|
||||
type: object
|
||||
version: 1.0.0
|
||||
@@ -50,25 +50,26 @@ gas_source.mock:
|
||||
goal: {}
|
||||
goal_default: {}
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result:
|
||||
return_info: return_info
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback:
|
||||
additionalProperties: true
|
||||
properties: {}
|
||||
required: []
|
||||
title: EmptyIn_Feedback
|
||||
type: object
|
||||
goal:
|
||||
additionalProperties: true
|
||||
properties: {}
|
||||
required: []
|
||||
title: EmptyIn_Goal
|
||||
type: object
|
||||
result:
|
||||
additionalProperties: false
|
||||
properties:
|
||||
return_info:
|
||||
type: string
|
||||
required:
|
||||
- return_info
|
||||
title: EmptyIn_Result
|
||||
type: object
|
||||
required:
|
||||
@@ -81,25 +82,26 @@ gas_source.mock:
|
||||
goal: {}
|
||||
goal_default: {}
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result:
|
||||
return_info: return_info
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback:
|
||||
additionalProperties: true
|
||||
properties: {}
|
||||
required: []
|
||||
title: EmptyIn_Feedback
|
||||
type: object
|
||||
goal:
|
||||
additionalProperties: true
|
||||
properties: {}
|
||||
required: []
|
||||
title: EmptyIn_Goal
|
||||
type: object
|
||||
result:
|
||||
additionalProperties: false
|
||||
properties:
|
||||
return_info:
|
||||
type: string
|
||||
required:
|
||||
- return_info
|
||||
title: EmptyIn_Result
|
||||
type: object
|
||||
required:
|
||||
@@ -114,31 +116,32 @@ gas_source.mock:
|
||||
goal_default:
|
||||
string: ''
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result:
|
||||
return_info: return_info
|
||||
success: success
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback:
|
||||
additionalProperties: true
|
||||
properties: {}
|
||||
required: []
|
||||
title: StrSingleInput_Feedback
|
||||
type: object
|
||||
goal:
|
||||
additionalProperties: false
|
||||
properties:
|
||||
string:
|
||||
type: string
|
||||
required:
|
||||
- string
|
||||
title: StrSingleInput_Goal
|
||||
type: object
|
||||
result:
|
||||
additionalProperties: false
|
||||
properties:
|
||||
return_info:
|
||||
type: string
|
||||
success:
|
||||
type: boolean
|
||||
required:
|
||||
- return_info
|
||||
- success
|
||||
title: StrSingleInput_Result
|
||||
type: object
|
||||
required:
|
||||
@@ -229,25 +232,26 @@ vacuum_pump.mock:
|
||||
goal: {}
|
||||
goal_default: {}
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result:
|
||||
return_info: return_info
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback:
|
||||
additionalProperties: true
|
||||
properties: {}
|
||||
required: []
|
||||
title: EmptyIn_Feedback
|
||||
type: object
|
||||
goal:
|
||||
additionalProperties: true
|
||||
properties: {}
|
||||
required: []
|
||||
title: EmptyIn_Goal
|
||||
type: object
|
||||
result:
|
||||
additionalProperties: false
|
||||
properties:
|
||||
return_info:
|
||||
type: string
|
||||
required:
|
||||
- return_info
|
||||
title: EmptyIn_Result
|
||||
type: object
|
||||
required:
|
||||
@@ -260,25 +264,26 @@ vacuum_pump.mock:
|
||||
goal: {}
|
||||
goal_default: {}
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result:
|
||||
return_info: return_info
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback:
|
||||
additionalProperties: true
|
||||
properties: {}
|
||||
required: []
|
||||
title: EmptyIn_Feedback
|
||||
type: object
|
||||
goal:
|
||||
additionalProperties: true
|
||||
properties: {}
|
||||
required: []
|
||||
title: EmptyIn_Goal
|
||||
type: object
|
||||
result:
|
||||
additionalProperties: false
|
||||
properties:
|
||||
return_info:
|
||||
type: string
|
||||
required:
|
||||
- return_info
|
||||
title: EmptyIn_Result
|
||||
type: object
|
||||
required:
|
||||
@@ -293,31 +298,32 @@ vacuum_pump.mock:
|
||||
goal_default:
|
||||
string: ''
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result:
|
||||
return_info: return_info
|
||||
success: success
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback:
|
||||
additionalProperties: true
|
||||
properties: {}
|
||||
required: []
|
||||
title: StrSingleInput_Feedback
|
||||
type: object
|
||||
goal:
|
||||
additionalProperties: false
|
||||
properties:
|
||||
string:
|
||||
type: string
|
||||
required:
|
||||
- string
|
||||
title: StrSingleInput_Goal
|
||||
type: object
|
||||
result:
|
||||
additionalProperties: false
|
||||
properties:
|
||||
return_info:
|
||||
type: string
|
||||
success:
|
||||
type: boolean
|
||||
required:
|
||||
- return_info
|
||||
- success
|
||||
title: StrSingleInput_Result
|
||||
type: object
|
||||
required:
|
||||
|
||||
@@ -5,7 +5,7 @@ hotel.thermo_orbitor_rs2_hotel:
|
||||
action_value_mappings: {}
|
||||
module: unilabos.devices.resource_container.container:HotelContainer
|
||||
status_types:
|
||||
rotation: ''
|
||||
rotation: String
|
||||
type: python
|
||||
config_info: []
|
||||
description: Thermo Orbitor RS2 Hotel容器设备,用于实验室样品的存储和管理。该设备通过HotelContainer类实现容器的旋转控制和状态监控,主要用于存储实验样品、试剂瓶或其他实验器具,支持旋转功能以便于样品的自动化存取。适用于需要有序存储和快速访问大量样品的实验室自动化场景。
|
||||
|
||||
@@ -22,8 +22,7 @@ xyz_stepper_controller:
|
||||
required:
|
||||
- degrees
|
||||
type: object
|
||||
result:
|
||||
type: integer
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: degrees_to_steps参数
|
||||
@@ -48,8 +47,7 @@ xyz_stepper_controller:
|
||||
required:
|
||||
- axis
|
||||
type: object
|
||||
result:
|
||||
type: boolean
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: emergency_stop参数
|
||||
@@ -74,10 +72,7 @@ xyz_stepper_controller:
|
||||
type: boolean
|
||||
required: []
|
||||
type: object
|
||||
result:
|
||||
additionalProperties:
|
||||
type: boolean
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: enable_all_axes参数
|
||||
@@ -106,8 +101,7 @@ xyz_stepper_controller:
|
||||
required:
|
||||
- axis
|
||||
type: object
|
||||
result:
|
||||
type: boolean
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: enable_motor参数
|
||||
@@ -128,10 +122,7 @@ xyz_stepper_controller:
|
||||
properties: {}
|
||||
required: []
|
||||
type: object
|
||||
result:
|
||||
additionalProperties:
|
||||
type: boolean
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: home_all_axes参数
|
||||
@@ -156,8 +147,7 @@ xyz_stepper_controller:
|
||||
required:
|
||||
- axis
|
||||
type: object
|
||||
result:
|
||||
type: boolean
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: home_axis参数
|
||||
@@ -198,8 +188,7 @@ xyz_stepper_controller:
|
||||
- axis
|
||||
- position
|
||||
type: object
|
||||
result:
|
||||
type: boolean
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: move_to_position参数
|
||||
@@ -240,8 +229,7 @@ xyz_stepper_controller:
|
||||
- axis
|
||||
- degrees
|
||||
type: object
|
||||
result:
|
||||
type: boolean
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: move_to_position_degrees参数
|
||||
@@ -282,8 +270,7 @@ xyz_stepper_controller:
|
||||
- axis
|
||||
- revolutions
|
||||
type: object
|
||||
result:
|
||||
type: boolean
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: move_to_position_revolutions参数
|
||||
@@ -314,17 +301,14 @@ xyz_stepper_controller:
|
||||
default: 5000
|
||||
type: integer
|
||||
x:
|
||||
type: integer
|
||||
type: string
|
||||
y:
|
||||
type: integer
|
||||
type: string
|
||||
z:
|
||||
type: integer
|
||||
type: string
|
||||
required: []
|
||||
type: object
|
||||
result:
|
||||
additionalProperties:
|
||||
type: boolean
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: move_xyz参数
|
||||
@@ -355,17 +339,14 @@ xyz_stepper_controller:
|
||||
default: 5000
|
||||
type: integer
|
||||
x_deg:
|
||||
type: number
|
||||
type: string
|
||||
y_deg:
|
||||
type: number
|
||||
type: string
|
||||
z_deg:
|
||||
type: number
|
||||
type: string
|
||||
required: []
|
||||
type: object
|
||||
result:
|
||||
additionalProperties:
|
||||
type: boolean
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: move_xyz_degrees参数
|
||||
@@ -396,17 +377,14 @@ xyz_stepper_controller:
|
||||
default: 5000
|
||||
type: integer
|
||||
x_rev:
|
||||
type: number
|
||||
type: string
|
||||
y_rev:
|
||||
type: number
|
||||
type: string
|
||||
z_rev:
|
||||
type: number
|
||||
type: string
|
||||
required: []
|
||||
type: object
|
||||
result:
|
||||
additionalProperties:
|
||||
type: boolean
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: move_xyz_revolutions参数
|
||||
@@ -431,8 +409,7 @@ xyz_stepper_controller:
|
||||
required:
|
||||
- revolutions
|
||||
type: object
|
||||
result:
|
||||
type: integer
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: revolutions_to_steps参数
|
||||
@@ -465,8 +442,7 @@ xyz_stepper_controller:
|
||||
- axis
|
||||
- speed
|
||||
type: object
|
||||
result:
|
||||
type: boolean
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: set_speed_mode参数
|
||||
@@ -491,8 +467,7 @@ xyz_stepper_controller:
|
||||
required:
|
||||
- steps
|
||||
type: object
|
||||
result:
|
||||
type: number
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: steps_to_degrees参数
|
||||
@@ -517,8 +492,7 @@ xyz_stepper_controller:
|
||||
required:
|
||||
- steps
|
||||
type: object
|
||||
result:
|
||||
type: number
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: steps_to_revolutions参数
|
||||
@@ -539,10 +513,7 @@ xyz_stepper_controller:
|
||||
properties: {}
|
||||
required: []
|
||||
type: object
|
||||
result:
|
||||
additionalProperties:
|
||||
type: boolean
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: stop_all_axes参数
|
||||
@@ -571,8 +542,7 @@ xyz_stepper_controller:
|
||||
required:
|
||||
- axis
|
||||
type: object
|
||||
result:
|
||||
type: boolean
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: wait_for_completion参数
|
||||
@@ -580,7 +550,8 @@ xyz_stepper_controller:
|
||||
type: UniLabJsonCommand
|
||||
module: unilabos.devices.liquid_handling.laiyu.drivers.xyz_stepper_driver:XYZStepperController
|
||||
status_types:
|
||||
all_positions: Dict[MotorAxis, MotorPosition]
|
||||
all_positions: dict
|
||||
motor_status: unilabos.devices.liquid_handling.laiyu.drivers.xyz_stepper_driver:MotorPosition
|
||||
type: python
|
||||
config_info: []
|
||||
description: 新XYZ控制器
|
||||
@@ -603,10 +574,12 @@ xyz_stepper_controller:
|
||||
data:
|
||||
properties:
|
||||
all_positions:
|
||||
additionalProperties:
|
||||
type: object
|
||||
motor_status:
|
||||
type: object
|
||||
required:
|
||||
- motor_status
|
||||
- all_positions
|
||||
type: object
|
||||
registry_type: device
|
||||
version: 1.0.0
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -5,6 +5,31 @@ neware_battery_test_system:
|
||||
- battery_test
|
||||
class:
|
||||
action_value_mappings:
|
||||
auto-post_init:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default:
|
||||
ros_node: null
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
ros_node:
|
||||
type: string
|
||||
required:
|
||||
- ros_node
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: post_init参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-print_status_summary:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
@@ -41,8 +66,7 @@ neware_battery_test_system:
|
||||
properties: {}
|
||||
required: []
|
||||
type: object
|
||||
result:
|
||||
type: boolean
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: test_connection参数
|
||||
@@ -53,8 +77,9 @@ neware_battery_test_system:
|
||||
goal: {}
|
||||
goal_default: {}
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
result:
|
||||
return_info: return_info
|
||||
success: success
|
||||
schema:
|
||||
description: 调试方法:显示所有资源的实际名称
|
||||
properties:
|
||||
@@ -64,10 +89,19 @@ neware_battery_test_system:
|
||||
required: []
|
||||
type: object
|
||||
result:
|
||||
properties:
|
||||
return_info:
|
||||
description: 资源调试信息
|
||||
type: string
|
||||
success:
|
||||
description: 是否成功
|
||||
type: boolean
|
||||
required:
|
||||
- return_info
|
||||
- success
|
||||
type: object
|
||||
required:
|
||||
- goal
|
||||
title: debug_resource_names参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
export_status_json:
|
||||
@@ -77,8 +111,9 @@ neware_battery_test_system:
|
||||
goal_default:
|
||||
filepath: bts_status.json
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
result:
|
||||
return_info: return_info
|
||||
success: success
|
||||
schema:
|
||||
description: 导出当前状态数据到JSON文件
|
||||
properties:
|
||||
@@ -92,10 +127,19 @@ neware_battery_test_system:
|
||||
required: []
|
||||
type: object
|
||||
result:
|
||||
properties:
|
||||
return_info:
|
||||
description: 导出操作结果信息
|
||||
type: string
|
||||
success:
|
||||
description: 导出是否成功
|
||||
type: boolean
|
||||
required:
|
||||
- return_info
|
||||
- success
|
||||
type: object
|
||||
required:
|
||||
- goal
|
||||
title: export_status_json参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
get_device_summary:
|
||||
@@ -137,8 +181,10 @@ neware_battery_test_system:
|
||||
goal_default:
|
||||
plate_num: null
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
result:
|
||||
plate_data: plate_data
|
||||
return_info: return_info
|
||||
success: success
|
||||
schema:
|
||||
description: 获取指定盘或所有盘的状态信息
|
||||
properties:
|
||||
@@ -147,14 +193,29 @@ neware_battery_test_system:
|
||||
properties:
|
||||
plate_num:
|
||||
description: 盘号 (1 或 2),如果为null则返回所有盘的状态
|
||||
maximum: 2
|
||||
minimum: 1
|
||||
type: integer
|
||||
required: []
|
||||
type: object
|
||||
result:
|
||||
properties:
|
||||
plate_data:
|
||||
description: 盘状态数据(单盘或所有盘)
|
||||
type: object
|
||||
return_info:
|
||||
description: 操作结果信息
|
||||
type: string
|
||||
success:
|
||||
description: 查询是否成功
|
||||
type: boolean
|
||||
required:
|
||||
- return_info
|
||||
- success
|
||||
- plate_data
|
||||
type: object
|
||||
required:
|
||||
- goal
|
||||
title: get_plate_status参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
print_status_summary_action:
|
||||
@@ -162,8 +223,9 @@ neware_battery_test_system:
|
||||
goal: {}
|
||||
goal_default: {}
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
result:
|
||||
return_info: return_info
|
||||
success: success
|
||||
schema:
|
||||
description: 打印通道状态摘要信息到控制台
|
||||
properties:
|
||||
@@ -173,21 +235,28 @@ neware_battery_test_system:
|
||||
required: []
|
||||
type: object
|
||||
result:
|
||||
properties:
|
||||
return_info:
|
||||
description: 打印操作结果信息
|
||||
type: string
|
||||
success:
|
||||
description: 打印是否成功
|
||||
type: boolean
|
||||
required:
|
||||
- return_info
|
||||
- success
|
||||
type: object
|
||||
required:
|
||||
- goal
|
||||
title: print_status_summary_action参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
query_plate_action:
|
||||
feedback: {}
|
||||
goal:
|
||||
plate_id: plate_id
|
||||
string: string
|
||||
string: plate_id
|
||||
goal_default:
|
||||
string: ''
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result:
|
||||
return_info: return_info
|
||||
success: success
|
||||
@@ -195,23 +264,27 @@ neware_battery_test_system:
|
||||
description: ''
|
||||
properties:
|
||||
feedback:
|
||||
additionalProperties: true
|
||||
properties: {}
|
||||
required: []
|
||||
title: StrSingleInput_Feedback
|
||||
type: object
|
||||
goal:
|
||||
additionalProperties: false
|
||||
properties:
|
||||
string:
|
||||
type: string
|
||||
required:
|
||||
- string
|
||||
title: StrSingleInput_Goal
|
||||
type: object
|
||||
result:
|
||||
additionalProperties: false
|
||||
properties:
|
||||
return_info:
|
||||
type: string
|
||||
success:
|
||||
type: boolean
|
||||
required:
|
||||
- return_info
|
||||
- success
|
||||
title: StrSingleInput_Result
|
||||
type: object
|
||||
required:
|
||||
@@ -225,11 +298,13 @@ neware_battery_test_system:
|
||||
csv_path: string
|
||||
output_dir: string
|
||||
goal_default:
|
||||
csv_path: null
|
||||
csv_path: ''
|
||||
output_dir: .
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
result:
|
||||
return_info: return_info
|
||||
submitted_count: submitted_count
|
||||
success: success
|
||||
schema:
|
||||
description: 从CSV文件批量提交Neware测试任务
|
||||
properties:
|
||||
@@ -240,17 +315,31 @@ neware_battery_test_system:
|
||||
description: 输入CSV文件的绝对路径
|
||||
type: string
|
||||
output_dir:
|
||||
default: .
|
||||
description: 输出目录(用于存储XML和备份文件),默认当前目录
|
||||
type: string
|
||||
required:
|
||||
- csv_path
|
||||
type: object
|
||||
result:
|
||||
properties:
|
||||
return_info:
|
||||
description: 执行结果详细信息
|
||||
type: string
|
||||
submitted_count:
|
||||
description: 成功提交的任务数量
|
||||
type: integer
|
||||
success:
|
||||
description: 是否成功
|
||||
type: boolean
|
||||
total_count:
|
||||
description: CSV文件中的总行数
|
||||
type: integer
|
||||
required:
|
||||
- return_info
|
||||
- success
|
||||
type: object
|
||||
required:
|
||||
- goal
|
||||
title: submit_from_csv参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
test_connection_action:
|
||||
@@ -258,8 +347,9 @@ neware_battery_test_system:
|
||||
goal: {}
|
||||
goal_default: {}
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
result:
|
||||
return_info: return_info
|
||||
success: success
|
||||
schema:
|
||||
description: 测试与电池测试系统的TCP连接
|
||||
properties:
|
||||
@@ -269,10 +359,19 @@ neware_battery_test_system:
|
||||
required: []
|
||||
type: object
|
||||
result:
|
||||
properties:
|
||||
return_info:
|
||||
description: 连接测试结果信息
|
||||
type: string
|
||||
success:
|
||||
description: 连接测试是否成功
|
||||
type: boolean
|
||||
required:
|
||||
- return_info
|
||||
- success
|
||||
type: object
|
||||
required:
|
||||
- goal
|
||||
title: test_connection_action参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
upload_backup_to_oss:
|
||||
@@ -293,8 +392,12 @@ neware_battery_test_system:
|
||||
handler_key: uploaded_files
|
||||
io_type: sink
|
||||
label: Uploaded Files (with standard flow info)
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
result:
|
||||
failed_files: failed_files
|
||||
return_info: return_info
|
||||
success: success
|
||||
total_count: total_count
|
||||
uploaded_count: uploaded_count
|
||||
schema:
|
||||
description: 上传备份文件到阿里云OSS
|
||||
properties:
|
||||
@@ -314,17 +417,65 @@ neware_battery_test_system:
|
||||
required: []
|
||||
type: object
|
||||
result:
|
||||
properties:
|
||||
failed_files:
|
||||
description: 上传失败的文件名列表
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
return_info:
|
||||
description: 上传操作结果信息
|
||||
type: string
|
||||
success:
|
||||
description: 上传是否成功
|
||||
type: boolean
|
||||
total_count:
|
||||
description: 总文件数
|
||||
type: integer
|
||||
uploaded_count:
|
||||
description: 成功上传的文件数
|
||||
type: integer
|
||||
uploaded_files:
|
||||
description: 成功上传的文件详情列表
|
||||
items:
|
||||
properties:
|
||||
Battery_Code:
|
||||
description: 电池编码
|
||||
type: string
|
||||
Electrolyte_Code:
|
||||
description: 电解液编码
|
||||
type: string
|
||||
filename:
|
||||
description: 文件名
|
||||
type: string
|
||||
url:
|
||||
description: OSS下载链接
|
||||
type: string
|
||||
required:
|
||||
- filename
|
||||
- url
|
||||
- Battery_Code
|
||||
- Electrolyte_Code
|
||||
type: object
|
||||
type: array
|
||||
required:
|
||||
- return_info
|
||||
- success
|
||||
- uploaded_count
|
||||
- total_count
|
||||
- failed_files
|
||||
- uploaded_files
|
||||
type: object
|
||||
required:
|
||||
- goal
|
||||
title: upload_backup_to_oss参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
module: unilabos.devices.neware_battery_test_system.neware_battery_test_system:NewareBatteryTestSystem
|
||||
status_types:
|
||||
channel_status: Dict[int, Dict]
|
||||
connection_info: Dict[str, str]
|
||||
channel_status: dict
|
||||
connection_info: dict
|
||||
device_summary: dict
|
||||
plate_status: dict
|
||||
status: str
|
||||
total_channels: int
|
||||
type: python
|
||||
@@ -366,24 +517,23 @@ neware_battery_test_system:
|
||||
data:
|
||||
properties:
|
||||
channel_status:
|
||||
additionalProperties:
|
||||
type: object
|
||||
type: object
|
||||
connection_info:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
device_summary:
|
||||
type: object
|
||||
plate_status:
|
||||
type: object
|
||||
status:
|
||||
type: string
|
||||
total_channels:
|
||||
type: integer
|
||||
required:
|
||||
- status
|
||||
- channel_status
|
||||
- connection_info
|
||||
- device_summary
|
||||
- status
|
||||
- total_channels
|
||||
- plate_status
|
||||
- device_summary
|
||||
type: object
|
||||
version: 1.0.0
|
||||
|
||||
@@ -49,7 +49,32 @@ opcua_example:
|
||||
title: load_config参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-refresh_node_values:
|
||||
auto-post_init:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default:
|
||||
ros_node: null
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
ros_node:
|
||||
type: string
|
||||
required:
|
||||
- ros_node
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: post_init参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-print_cache_stats:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default: {}
|
||||
@@ -67,7 +92,32 @@ opcua_example:
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: refresh_node_values参数
|
||||
title: print_cache_stats参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-read_node:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default:
|
||||
node_name: null
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
node_name:
|
||||
type: string
|
||||
required:
|
||||
- node_name
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: read_node参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-set_node_value:
|
||||
@@ -99,50 +149,10 @@ opcua_example:
|
||||
title: set_node_value参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-start_node_refresh:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default: {}
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties: {}
|
||||
required: []
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: start_node_refresh参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-stop_node_refresh:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default: {}
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties: {}
|
||||
required: []
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: stop_node_refresh参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
module: unilabos.device_comms.opcua_client.client:OpcUaClient
|
||||
status_types: {}
|
||||
status_types:
|
||||
cache_stats: dict
|
||||
node_value: String
|
||||
type: python
|
||||
config_info: []
|
||||
description: null
|
||||
@@ -151,22 +161,36 @@ opcua_example:
|
||||
init_param_schema:
|
||||
config:
|
||||
properties:
|
||||
cache_timeout:
|
||||
default: 5.0
|
||||
type: number
|
||||
config_path:
|
||||
type: string
|
||||
deck:
|
||||
type: string
|
||||
password:
|
||||
type: string
|
||||
refresh_interval:
|
||||
default: 1.0
|
||||
type: number
|
||||
subscription_interval:
|
||||
default: 500
|
||||
type: integer
|
||||
url:
|
||||
type: string
|
||||
use_subscription:
|
||||
default: true
|
||||
type: boolean
|
||||
username:
|
||||
type: string
|
||||
required:
|
||||
- url
|
||||
type: object
|
||||
data:
|
||||
properties: {}
|
||||
required: []
|
||||
properties:
|
||||
cache_stats:
|
||||
type: object
|
||||
node_value:
|
||||
type: string
|
||||
required:
|
||||
- node_value
|
||||
- cache_stats
|
||||
type: object
|
||||
version: 1.0.0
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user