mirror of
https://github.com/deepmodeling/Uni-Lab-OS
synced 2026-04-27 15:39:57 +00:00
Compare commits
20 Commits
feat/lab_r
...
56d25b88bd
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
56d25b88bd | ||
|
|
95f3e0b291 | ||
|
|
9b706236f6 | ||
|
|
9f60e65b6d | ||
|
|
59aa991988 | ||
|
|
aff340de84 | ||
|
|
2fd4270831 | ||
|
|
0d41d83ce5 | ||
|
|
68ef739f4a | ||
|
|
29a484f16f | ||
|
|
14cf4ddc0d | ||
|
|
d13d3f7dfe | ||
|
|
71d35d31af | ||
|
|
7f4b57f589 | ||
|
|
0c667e68e6 | ||
|
|
9430be51a4 | ||
|
|
a187a57430 | ||
|
|
68029217de | ||
|
|
792504e08c | ||
|
|
ca985f92ab |
31
CLAUDE.md
31
CLAUDE.md
@@ -24,10 +24,15 @@ unilab --skip_env_check # skip auto-install of dependencies
|
|||||||
unilab --visual rviz|web|disable # visualization mode
|
unilab --visual rviz|web|disable # visualization mode
|
||||||
unilab --is_slave # run as slave node
|
unilab --is_slave # run as slave node
|
||||||
unilab --restart_mode # auto-restart on config changes (supervisor/child process)
|
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
|
# Workflow upload subcommand
|
||||||
unilab workflow_upload -f <workflow.json> -n <name> --tags tag1 tag2
|
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
|
# Tests
|
||||||
pytest tests/ # all tests
|
pytest tests/ # all tests
|
||||||
pytest tests/resources/test_resourcetreeset.py # single test file
|
pytest tests/resources/test_resourcetreeset.py # single test file
|
||||||
@@ -35,6 +40,12 @@ pytest tests/resources/test_resourcetreeset.py::TestClassName::test_method # si
|
|||||||
|
|
||||||
# CI check (matches .github/workflows/ci-check.yml)
|
# CI check (matches .github/workflows/ci-check.yml)
|
||||||
python -m unilabos --check_mode --skip_env_check
|
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
|
## Architecture
|
||||||
@@ -45,7 +56,22 @@ python -m unilabos --check_mode --skip_env_check
|
|||||||
|
|
||||||
### Core Layers
|
### Core Layers
|
||||||
|
|
||||||
**Registry** (`unilabos/registry/`): Singleton `Registry` class discovers and catalogs all device types, resource types, and communication devices. Two registration mechanisms: YAML definitions in `registry/devices/*.yaml` and Python decorators (`@device`, `@action`, `@resource` in `registry/decorators.py`). AST scanning discovers decorated classes without importing them. Class paths resolved to Python classes via `utils/import_manager.py`.
|
**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`.
|
**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`.
|
||||||
|
|
||||||
@@ -61,6 +87,8 @@ python -m unilabos --check_mode --skip_env_check
|
|||||||
|
|
||||||
**Web/API** (`unilabos/app/web/`): FastAPI server with REST API (`api.py`), Jinja2 templates (`pages.py`), HTTP client (`client.py`). Default port 8002.
|
**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
|
### 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`)
|
- **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`)
|
||||||
@@ -88,6 +116,7 @@ Example device graphs and experiment configs are in `unilabos/test/experiments/`
|
|||||||
- CLI argument dashes auto-converted to underscores for consistency
|
- CLI argument dashes auto-converted to underscores for consistency
|
||||||
- No linter/formatter configuration enforced (no ruff, black, flake8, mypy configs)
|
- No linter/formatter configuration enforced (no ruff, black, flake8, mypy configs)
|
||||||
- Documentation built with Sphinx (Chinese language, `sphinx_rtd_theme`, `myst_parser`)
|
- 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
|
## Licensing
|
||||||
|
|
||||||
|
|||||||
@@ -1369,6 +1369,10 @@ class WebSocketClient(BaseCommunicationClient):
|
|||||||
self.message_processor = MessageProcessor(self.websocket_url, self.send_queue, self.device_manager)
|
self.message_processor = MessageProcessor(self.websocket_url, self.send_queue, self.device_manager)
|
||||||
self.queue_processor = QueueProcessor(self.device_manager, self.message_processor)
|
self.queue_processor = QueueProcessor(self.device_manager, self.message_processor)
|
||||||
|
|
||||||
|
# running状态debounce缓存: {job_id: (last_send_timestamp, last_feedback_data)}
|
||||||
|
self._job_running_last_sent: Dict[str, tuple] = {}
|
||||||
|
self._job_running_debounce_interval: float = 10.0 # 秒
|
||||||
|
|
||||||
# 设置相互引用
|
# 设置相互引用
|
||||||
self.message_processor.set_queue_processor(self.queue_processor)
|
self.message_processor.set_queue_processor(self.queue_processor)
|
||||||
self.message_processor.set_websocket_client(self)
|
self.message_processor.set_websocket_client(self)
|
||||||
@@ -1468,22 +1472,32 @@ class WebSocketClient(BaseCommunicationClient):
|
|||||||
logger.debug(f"[WebSocketClient] Not connected, cannot publish job status for job_id: {item.job_id}")
|
logger.debug(f"[WebSocketClient] Not connected, cannot publish job status for job_id: {item.job_id}")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
job_log = format_job_log(item.job_id, item.task_id, item.device_id, item.action_name)
|
||||||
|
|
||||||
# 拦截最终结果状态,与原版本逻辑一致
|
# 拦截最终结果状态,与原版本逻辑一致
|
||||||
if status in ["success", "failed"]:
|
if status in ["success", "failed"]:
|
||||||
|
self._job_running_last_sent.pop(item.job_id, None)
|
||||||
|
|
||||||
host_node = HostNode.get_instance(0)
|
host_node = HostNode.get_instance(0)
|
||||||
if host_node:
|
if host_node:
|
||||||
# 从HostNode的device_action_status中移除job_id
|
|
||||||
try:
|
try:
|
||||||
host_node._device_action_status[item.device_action_key].job_ids.pop(item.job_id, None)
|
host_node._device_action_status[item.device_action_key].job_ids.pop(item.job_id, None)
|
||||||
except (KeyError, AttributeError):
|
except (KeyError, AttributeError):
|
||||||
logger.warning(f"[WebSocketClient] Failed to remove job {item.job_id} from HostNode status")
|
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}")
|
|
||||||
|
|
||||||
# 通知队列处理器job完成(包括timeout的job)
|
|
||||||
self.queue_processor.handle_job_completed(item.job_id, status)
|
self.queue_processor.handle_job_completed(item.job_id, status)
|
||||||
|
|
||||||
# 发送job状态消息
|
# running状态按job_id做debounce,内容变化时仍然上报
|
||||||
|
if status == "running":
|
||||||
|
now = time.time()
|
||||||
|
cached = self._job_running_last_sent.get(item.job_id)
|
||||||
|
if cached is not None:
|
||||||
|
last_ts, last_data = cached
|
||||||
|
if now - last_ts < self._job_running_debounce_interval and last_data == feedback_data:
|
||||||
|
logger.trace(f"[WebSocketClient] Job status debounced (skip): {job_log} - {status}")
|
||||||
|
return
|
||||||
|
self._job_running_last_sent[item.job_id] = (now, feedback_data)
|
||||||
|
|
||||||
message = {
|
message = {
|
||||||
"action": "job_status",
|
"action": "job_status",
|
||||||
"data": {
|
"data": {
|
||||||
@@ -1499,7 +1513,6 @@ class WebSocketClient(BaseCommunicationClient):
|
|||||||
}
|
}
|
||||||
self.message_processor.send_message(message)
|
self.message_processor.send_message(message)
|
||||||
|
|
||||||
job_log = format_job_log(item.job_id, item.task_id, item.device_id, item.action_name)
|
|
||||||
logger.trace(f"[WebSocketClient] Job status published: {job_log} - {status}")
|
logger.trace(f"[WebSocketClient] Job status published: {job_log} - {status}")
|
||||||
|
|
||||||
def send_ping(self, ping_id: str, timestamp: float) -> None:
|
def send_ping(self, ping_id: str, timestamp: float) -> None:
|
||||||
|
|||||||
@@ -1651,7 +1651,7 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
|
|||||||
tip = []
|
tip = []
|
||||||
if pick_up:
|
if pick_up:
|
||||||
tip.append(self._get_next_tip())
|
tip.append(self._get_next_tip())
|
||||||
await self.pick_up_tips(tip)
|
await self.pick_up_tips(tip,use_channels=use_channels)
|
||||||
blow_out_air_volume_before_vol = 0.0
|
blow_out_air_volume_before_vol = 0.0
|
||||||
if blow_out_air_volume_before is not None and len(blow_out_air_volume_before) > 0:
|
if blow_out_air_volume_before is not None and len(blow_out_air_volume_before) > 0:
|
||||||
blow_out_air_volume_before_vol = float(blow_out_air_volume_before[0] or 0.0)
|
blow_out_air_volume_before_vol = float(blow_out_air_volume_before[0] or 0.0)
|
||||||
|
|||||||
@@ -781,14 +781,18 @@ class PRCXI9300Handler(LiquidHandlerAbstract):
|
|||||||
rail_interval=0,
|
rail_interval=0,
|
||||||
x_increase = -0.003636,
|
x_increase = -0.003636,
|
||||||
y_increase = -0.003636,
|
y_increase = -0.003636,
|
||||||
x_offset = 9.2,
|
x_offset = -1.8,
|
||||||
y_offset = -27.98,
|
y_offset = -37.48,
|
||||||
deck_z = 300,
|
deck_z = 235.5,
|
||||||
deck_y = 400,
|
deck_y = 400,
|
||||||
rail_width=27.5,
|
rail_width=27.5,
|
||||||
xy_coupling = -0.0045,
|
xy_coupling = -0.0045,
|
||||||
|
calibration_points: Optional[Dict[str, List[List[float]]]] = None,
|
||||||
|
calibration_labware_type: Optional[str] = "PRCXI_300ul_Tips",
|
||||||
):
|
):
|
||||||
|
|
||||||
|
self._rail_width = rail_width
|
||||||
|
self._rail_interval = rail_interval
|
||||||
self.deck_x = (start_rail + rail_nums*5 + (rail_nums-1)*rail_interval) * rail_width
|
self.deck_x = (start_rail + rail_nums*5 + (rail_nums-1)*rail_interval) * rail_width
|
||||||
self.deck_y = deck_y
|
self.deck_y = deck_y
|
||||||
self.deck_z = deck_z
|
self.deck_z = deck_z
|
||||||
@@ -797,10 +801,15 @@ class PRCXI9300Handler(LiquidHandlerAbstract):
|
|||||||
self.x_offset = x_offset
|
self.x_offset = x_offset
|
||||||
self.y_offset = y_offset
|
self.y_offset = y_offset
|
||||||
self.xy_coupling = xy_coupling
|
self.xy_coupling = xy_coupling
|
||||||
self.left_2_claw = Coordinate(-130.2, 34, -134)
|
self._slot_prcxi_positions: Dict[int, Tuple[float, float]] = {}
|
||||||
self.right_2_left = Coordinate(22,-1, 8)
|
self.calibration_labware_type = calibration_labware_type
|
||||||
plate_positions = []
|
|
||||||
|
|
||||||
|
if calibration_points is not None:
|
||||||
|
self.calibrate_from_points(calibration_points, labware_type=self.calibration_labware_type)
|
||||||
|
|
||||||
|
self.left_2_claw = Coordinate(130.2, -34, 74)
|
||||||
|
self.right_2_left = Coordinate(22,-1, 12)
|
||||||
|
self.tip_height = 0
|
||||||
tablets_info = []
|
tablets_info = []
|
||||||
|
|
||||||
if is_9320 is None:
|
if is_9320 is None:
|
||||||
@@ -877,9 +886,9 @@ class PRCXI9300Handler(LiquidHandlerAbstract):
|
|||||||
# 根据 resource 类型推断 materialEnum
|
# 根据 resource 类型推断 materialEnum
|
||||||
# MaterialEnum: Other=0, Tips=1, DeepWellPlate=2, PCRPlate=3, ELISAPlate=4, Reservoir=5, WasteBox=6
|
# MaterialEnum: Other=0, Tips=1, DeepWellPlate=2, PCRPlate=3, ELISAPlate=4, Reservoir=5, WasteBox=6
|
||||||
expected_enum = None
|
expected_enum = None
|
||||||
if isinstance(resource, PRCXI9300TipRack) or isinstance(resource, TipRack):
|
if isinstance(resource, TipRack):
|
||||||
expected_enum = 1 # Tips
|
expected_enum = 1 # Tips
|
||||||
elif isinstance(resource, PRCXI9300Trash) or isinstance(resource, Trash):
|
elif isinstance(resource, Trash):
|
||||||
expected_enum = 6 # WasteBox
|
expected_enum = 6 # WasteBox
|
||||||
elif isinstance(resource, (PRCXI9300Plate, Plate)):
|
elif isinstance(resource, (PRCXI9300Plate, Plate)):
|
||||||
expected_enum = None # Plate 可能是 DeepWellPlate/PCRPlate/ELISAPlate,不限定
|
expected_enum = None # Plate 可能是 DeepWellPlate/PCRPlate/ELISAPlate,不限定
|
||||||
@@ -930,7 +939,7 @@ class PRCXI9300Handler(LiquidHandlerAbstract):
|
|||||||
matrix_id = str(uuid.uuid4())
|
matrix_id = str(uuid.uuid4())
|
||||||
matrix_info = {
|
matrix_info = {
|
||||||
"MatrixId": matrix_id,
|
"MatrixId": matrix_id,
|
||||||
"MatrixName": matrix_id,
|
"MatrixName": "matrix_" + str(time.time()),
|
||||||
"WorkTablets": work_tablets +
|
"WorkTablets": work_tablets +
|
||||||
[{"Number": number, "Material": {"uuid": "730067cf07ae43849ddf4034299030e9"}} for number in slot_none],
|
[{"Number": number, "Material": {"uuid": "730067cf07ae43849ddf4034299030e9"}} for number in slot_none],
|
||||||
}
|
}
|
||||||
@@ -941,21 +950,27 @@ class PRCXI9300Handler(LiquidHandlerAbstract):
|
|||||||
|
|
||||||
# 重新计算所有槽位的位置(初始化时 deck 可能为空,此时才有资源)
|
# 重新计算所有槽位的位置(初始化时 deck 可能为空,此时才有资源)
|
||||||
pipetting_positions = []
|
pipetting_positions = []
|
||||||
plate_positions = []
|
claw_positions = []
|
||||||
for child in self.deck.children:
|
for child in self.deck.children:
|
||||||
number = self._get_slot_number(child)
|
number = self._get_slot_number(child)
|
||||||
|
|
||||||
if number is None:
|
if number is None:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
pos = self.plr_pos_to_prcxi(child)
|
pos = self.plr_pos_to_prcxi(child, self.left_2_claw)
|
||||||
plate_positions.append({"Number": number, "XPos": pos.x, "YPos": pos.y, "ZPos": pos.z})
|
slot_pos = self._slot_prcxi_positions[number]
|
||||||
|
pos.x = slot_pos[0] - child.get_size_x() / 2 + self.left_2_claw.x
|
||||||
|
pos.y = slot_pos[1] - child.get_size_y() / 2 + self.left_2_claw.y
|
||||||
|
claw_positions.append({"Number": number, "XPos": pos.x, "YPos": pos.y, "ZPos": pos.z})
|
||||||
|
|
||||||
if child.children:
|
if child.children:
|
||||||
pip_pos = self.plr_pos_to_prcxi(child.children[0], self.left_2_claw)
|
pip_pos = self.plr_pos_to_prcxi(child.children[0])
|
||||||
else:
|
else:
|
||||||
pip_pos = self.plr_pos_to_prcxi(child, Coordinate(-100, self.left_2_claw.y, self.left_2_claw.z))
|
pip_pos = self.plr_pos_to_prcxi(child)
|
||||||
half_x = child.get_size_x() / 2 * abs(1 + self.x_increase)
|
pip_pos.x = slot_pos[0] - 40
|
||||||
|
pip_pos.y = slot_pos[1] - child.get_size_y() / 2
|
||||||
|
pip_pos.z = pip_pos.z - 40
|
||||||
|
half_x = child.get_size_x() / 2
|
||||||
z_wall = child.get_size_z()
|
z_wall = child.get_size_z()
|
||||||
|
|
||||||
pipetting_positions.append({
|
pipetting_positions.append({
|
||||||
@@ -976,26 +991,78 @@ class PRCXI9300Handler(LiquidHandlerAbstract):
|
|||||||
|
|
||||||
if pipetting_positions:
|
if pipetting_positions:
|
||||||
api.update_pipetting_position(matrix_id, pipetting_positions)
|
api.update_pipetting_position(matrix_id, pipetting_positions)
|
||||||
# 更新 backend 中的 plate_positions
|
# 更新 backend 中的 claw_positions
|
||||||
backend.plate_positions = plate_positions
|
backend.claw_positions = claw_positions
|
||||||
|
|
||||||
if plate_positions:
|
if claw_positions:
|
||||||
api.update_clamp_jaw_position(matrix_id, plate_positions)
|
api.update_clamp_jaw_position(matrix_id, claw_positions)
|
||||||
|
|
||||||
|
|
||||||
print(f"Auto-matched materials and created matrix: {matrix_id}")
|
print(f"Auto-matched materials and created matrix: {matrix_id}")
|
||||||
else:
|
else:
|
||||||
raise PRCXIError(f"Failed to create auto-matched matrix: {res.get('Message', 'Unknown error')}")
|
raise PRCXIError(f"Failed to create auto-matched matrix: {res.get('Message', 'Unknown error')}")
|
||||||
|
|
||||||
def plr_pos_to_prcxi(self, resource: Resource, offset: Coordinate = Coordinate(0, 0, 0)):
|
def calibrate_from_points(
|
||||||
|
self,
|
||||||
|
calibration_points: Dict[str, List[List[float]]],
|
||||||
|
labware_type: Optional[str] = "PRCXI_300ul_Tips",
|
||||||
|
):
|
||||||
|
"""从实测 PRCXI 机器坐标直接计算每个 slot 的 PRCXI 原点坐标。
|
||||||
|
|
||||||
|
校准点是将参考物料放在各 slot 后,机器移至其 A1 位置所读取的
|
||||||
|
PRCXI 坐标。通过 ``labware_type`` 创建临时实例,取 ``children[0]``
|
||||||
|
(即 A1)的 location 作为偏移量,逆运算得 slot 原点。
|
||||||
|
line_1~line_N 依次对应 T1~T4, T5~T8, ...
|
||||||
|
|
||||||
|
Args:
|
||||||
|
calibration_points: ``{"line_1": [[px, py], ...], ...}``。
|
||||||
|
``[0, 0]`` 表示该点无效,不计入。
|
||||||
|
labware_type: prcxi_labware 中的工厂函数名(如 ``"PRCXI_300ul_Tips"``)。
|
||||||
|
为 ``None`` 时 dx=dy=0,即校准点直接作为 slot 原点。
|
||||||
|
"""
|
||||||
|
dx, dy = 0.0, 0.0
|
||||||
|
if labware_type is not None:
|
||||||
|
from . import prcxi_labware
|
||||||
|
factory = getattr(prcxi_labware, labware_type)
|
||||||
|
temp = factory("_calibration_ref")
|
||||||
|
a1 = temp.children[0]
|
||||||
|
dx, dy = a1.location.x + a1.get_size_x() / 2, a1.location.y + a1.get_size_y() / 2
|
||||||
|
|
||||||
|
|
||||||
|
sorted_keys = sorted(
|
||||||
|
calibration_points.keys(),
|
||||||
|
key=lambda k: int("".join(c for c in k if c.isdigit()) or "0"),
|
||||||
|
)
|
||||||
|
|
||||||
|
slot_number = 0
|
||||||
|
for key in sorted_keys:
|
||||||
|
for pt in calibration_points[key]:
|
||||||
|
slot_number += 1
|
||||||
|
if isinstance(pt, (list, tuple)) and len(pt) >= 2 and not (pt[0] == 0 and pt[1] == 0):
|
||||||
|
self._slot_prcxi_positions[slot_number] = (
|
||||||
|
float(pt[0]) + dx,
|
||||||
|
float(pt[1]) + dy,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _find_slot_for_resource(self, resource: Resource) -> Optional[int]:
|
||||||
|
"""沿 parent 链向上找到 Deck 的直接子节点,返回其槽位号。"""
|
||||||
|
current = resource
|
||||||
|
while current is not None:
|
||||||
|
if isinstance(current.parent, (PRCXI9300Deck, LiquidHandlerAbstract)):
|
||||||
|
return self._get_slot_number(current)
|
||||||
|
current = getattr(current, "parent", None)
|
||||||
|
return self._get_slot_number(resource)
|
||||||
|
|
||||||
|
def plr_pos_to_prcxi(self, resource: Resource, resource_offset: Coordinate = Coordinate(0, 0, 0), offset: Coordinate = Coordinate(0, 0, 0)):
|
||||||
z_pos = 'c'
|
z_pos = 'c'
|
||||||
if isinstance(resource, Tip):
|
tip_height = self.tip_height
|
||||||
z_pos = 'b'
|
if isinstance(resource, TipSpot):
|
||||||
|
z_pos = 't'
|
||||||
|
tip_height = 0
|
||||||
resource_pos = resource.get_absolute_location(x="c",y="c",z=z_pos)
|
resource_pos = resource.get_absolute_location(x="c",y="c",z=z_pos)
|
||||||
x = resource_pos.x
|
x = resource_pos.x
|
||||||
y = resource_pos.y
|
y = resource_pos.y
|
||||||
z = resource_pos.z
|
z = resource_pos.z + tip_height
|
||||||
# 如果z等于0,则递归resource.parent的高度并向z加,使用get_size_z方法
|
|
||||||
|
|
||||||
parent = resource.parent
|
parent = resource.parent
|
||||||
res_z = resource.location.z
|
res_z = resource.location.z
|
||||||
@@ -1004,13 +1071,20 @@ class PRCXI9300Handler(LiquidHandlerAbstract):
|
|||||||
res_z = parent.location.z
|
res_z = parent.location.z
|
||||||
parent = getattr(parent, "parent", None)
|
parent = getattr(parent, "parent", None)
|
||||||
|
|
||||||
prcxi_x = (self.deck_x - x)*(1+self.x_increase) + self.x_offset + self.xy_coupling * (self.deck_y - y)
|
slot_number = self._find_slot_for_resource(resource) if self._slot_prcxi_positions else None
|
||||||
prcxi_y = (self.deck_y - y)*(1+self.y_increase) + self.y_offset
|
if slot_number is not None and slot_number in self._slot_prcxi_positions and self.calibration_labware_type is not None:
|
||||||
|
slot_prcxi_x, slot_prcxi_y = self._slot_prcxi_positions[slot_number]
|
||||||
|
prcxi_x = slot_prcxi_x - resource.location.x - resource.get_size_x() / 2
|
||||||
|
prcxi_y = slot_prcxi_y - resource.location.y - resource.get_size_y() / 2
|
||||||
|
else:
|
||||||
|
prcxi_x = (self.deck_x - x)*(1+self.x_increase) + self.x_offset + self.xy_coupling * (self.deck_y - y)
|
||||||
|
prcxi_y = (self.deck_y - y)*(1+self.y_increase) + self.y_offset
|
||||||
|
|
||||||
prcxi_z = self.deck_z - z
|
prcxi_z = self.deck_z - z
|
||||||
|
|
||||||
prcxi_x = min(max(0, prcxi_x+offset.x),self.deck_x)
|
prcxi_x = min(max(0, prcxi_x+resource_offset.x),self.deck_x)
|
||||||
prcxi_y = min(max(0, prcxi_y+offset.y),self.deck_y)
|
prcxi_y = min(max(0, prcxi_y+resource_offset.y),self.deck_y)
|
||||||
prcxi_z = min(max(0, prcxi_z+offset.z),self.deck_z)
|
prcxi_z = min(max(0, prcxi_z+resource_offset.z),self.deck_z)
|
||||||
|
|
||||||
return Coordinate(prcxi_x, prcxi_y, prcxi_z)
|
return Coordinate(prcxi_x, prcxi_y, prcxi_z)
|
||||||
|
|
||||||
@@ -1152,6 +1226,55 @@ class PRCXI9300Handler(LiquidHandlerAbstract):
|
|||||||
self._first_transfer_done = True
|
self._first_transfer_done = True
|
||||||
if self.step_mode:
|
if self.step_mode:
|
||||||
await self.create_protocol(f"transfer_liquid{time.time()}")
|
await self.create_protocol(f"transfer_liquid{time.time()}")
|
||||||
|
|
||||||
|
_asp_list = asp_vols if isinstance(asp_vols, list) else [asp_vols]
|
||||||
|
_dis_list = dis_vols if isinstance(dis_vols, list) else [dis_vols]
|
||||||
|
if all(v <= 10.0 for v in _asp_list) and all(v <= 10.0 for v in _dis_list):
|
||||||
|
use_channels = [1]
|
||||||
|
mix_vol = max(min(mix_vol,10),0) if mix_vol is not None else None
|
||||||
|
sources = await self._resolve_to_plr_resources(sources)
|
||||||
|
targets = await self._resolve_to_plr_resources(targets)
|
||||||
|
tip_racks = list(await self._resolve_to_plr_resources(tip_racks))
|
||||||
|
change_slots = []
|
||||||
|
change_slots.append(sources[0].parent)
|
||||||
|
change_slots.append(targets[0].parent)
|
||||||
|
if isinstance(tip_racks[0], TipRack):
|
||||||
|
tip_rack = tip_racks[0]
|
||||||
|
else:
|
||||||
|
tip_rack = tip_racks[0].parent
|
||||||
|
|
||||||
|
change_slots.append(tip_rack)
|
||||||
|
|
||||||
|
self.tip_height = tip_rack.children[0].get_size_z()
|
||||||
|
|
||||||
|
change_slots_positions = []
|
||||||
|
for slot in change_slots:
|
||||||
|
|
||||||
|
number = self._get_slot_number(slot)
|
||||||
|
|
||||||
|
pip_pos = self.plr_pos_to_prcxi(slot.children[0])
|
||||||
|
half_x = slot.children[0].get_size_x() / 2 * abs(1 + self.x_increase)
|
||||||
|
z_wall = slot.children[0].get_size_z()
|
||||||
|
|
||||||
|
change_slots_positions.append({
|
||||||
|
"Number": number,
|
||||||
|
"XPos": pip_pos.x,
|
||||||
|
"YPos": pip_pos.y,
|
||||||
|
"ZPos": pip_pos.z,
|
||||||
|
"X_Left": half_x,
|
||||||
|
"X_Right": half_x,
|
||||||
|
"ZAgainstTheWall": pip_pos.z - z_wall,
|
||||||
|
"X2Pos": pip_pos.x + self.right_2_left.x,
|
||||||
|
"Y2Pos": pip_pos.y + self.right_2_left.y,
|
||||||
|
"Z2Pos": pip_pos.z + self.right_2_left.z,
|
||||||
|
"X2_Left": half_x,
|
||||||
|
"X2_Right": half_x,
|
||||||
|
"ZAgainstTheWall2": pip_pos.z - z_wall,
|
||||||
|
})
|
||||||
|
if change_slots_positions:
|
||||||
|
self._unilabos_backend.api_client.update_pipetting_position(self._unilabos_backend.matrix_id, change_slots_positions)
|
||||||
|
|
||||||
|
|
||||||
res = await super().transfer_liquid(
|
res = await super().transfer_liquid(
|
||||||
sources,
|
sources,
|
||||||
targets,
|
targets,
|
||||||
@@ -1165,7 +1288,7 @@ class PRCXI9300Handler(LiquidHandlerAbstract):
|
|||||||
touch_tip=touch_tip,
|
touch_tip=touch_tip,
|
||||||
liquid_height=liquid_height,
|
liquid_height=liquid_height,
|
||||||
blow_out_air_volume=blow_out_air_volume,
|
blow_out_air_volume=blow_out_air_volume,
|
||||||
blow_out_air_volume_before=blow_out_air_volume_before,
|
blow_out_air_volume_before=None,
|
||||||
spread=spread,
|
spread=spread,
|
||||||
is_96_well=is_96_well,
|
is_96_well=is_96_well,
|
||||||
mix_stage=mix_stage,
|
mix_stage=mix_stage,
|
||||||
@@ -1396,8 +1519,9 @@ class PRCXI9300Backend(LiquidHandlerBackend):
|
|||||||
offset = pickup.offset
|
offset = pickup.offset
|
||||||
pickup_distance_from_top = pickup.pickup_distance_from_top
|
pickup_distance_from_top = pickup.pickup_distance_from_top
|
||||||
direction = pickup.direction
|
direction = pickup.direction
|
||||||
|
plate = resource.parent
|
||||||
plate_number = int(resource.parent.name.replace("T", ""))
|
deck = plate.parent
|
||||||
|
plate_number = self._deck_plate_slot_no(plate, deck)
|
||||||
is_whole_plate = True
|
is_whole_plate = True
|
||||||
balance_height = 0
|
balance_height = 0
|
||||||
step = self.api_client.clamp_jaw_pick_up(plate_number, is_whole_plate, balance_height)
|
step = self.api_client.clamp_jaw_pick_up(plate_number, is_whole_plate, balance_height)
|
||||||
@@ -1410,7 +1534,9 @@ class PRCXI9300Backend(LiquidHandlerBackend):
|
|||||||
plate_number = None
|
plate_number = None
|
||||||
target_plate_number = backend_kwargs.get("target_plate_number", None)
|
target_plate_number = backend_kwargs.get("target_plate_number", None)
|
||||||
if target_plate_number is not None:
|
if target_plate_number is not None:
|
||||||
plate_number = int(target_plate_number.name.replace("T", ""))
|
plate = target_plate_number
|
||||||
|
deck = plate.parent
|
||||||
|
plate_number = self._deck_plate_slot_no(plate, deck)
|
||||||
|
|
||||||
is_whole_plate = True
|
is_whole_plate = True
|
||||||
balance_height = 0
|
balance_height = 0
|
||||||
@@ -1508,7 +1634,7 @@ class PRCXI9300Backend(LiquidHandlerBackend):
|
|||||||
await asyncio.sleep(1)
|
await asyncio.sleep(1)
|
||||||
print("PRCXI9300 reset successfully.")
|
print("PRCXI9300 reset successfully.")
|
||||||
|
|
||||||
# self.api_client.update_clamp_jaw_position(self.matrix_id, self.plate_positions)
|
# self.api_client.update_clamp_jaw_position(self.matrix_id, self.claw_positions)
|
||||||
|
|
||||||
except ConnectionRefusedError as e:
|
except ConnectionRefusedError as e:
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
@@ -1702,6 +1828,7 @@ class PRCXI9300Backend(LiquidHandlerBackend):
|
|||||||
|
|
||||||
assert mix_time > 0
|
assert mix_time > 0
|
||||||
step = self.api_client.Blending(
|
step = self.api_client.Blending(
|
||||||
|
axis=axis,
|
||||||
dosage=mix_vol,
|
dosage=mix_vol,
|
||||||
plate_no=PlateNo,
|
plate_no=PlateNo,
|
||||||
is_whole_plate=False,
|
is_whole_plate=False,
|
||||||
@@ -1716,8 +1843,6 @@ class PRCXI9300Backend(LiquidHandlerBackend):
|
|||||||
|
|
||||||
async def aspirate(self, ops: List[SingleChannelAspiration], use_channels: List[int] = None):
|
async def aspirate(self, ops: List[SingleChannelAspiration], use_channels: List[int] = None):
|
||||||
"""Aspirate liquid from the specified resources."""
|
"""Aspirate liquid from the specified resources."""
|
||||||
if ops[0].blow_out_air_volume and ops[0].volume == 0:
|
|
||||||
return
|
|
||||||
if hasattr(use_channels, "tolist"):
|
if hasattr(use_channels, "tolist"):
|
||||||
_use_channels = use_channels.tolist()
|
_use_channels = use_channels.tolist()
|
||||||
else:
|
else:
|
||||||
@@ -1759,8 +1884,11 @@ class PRCXI9300Backend(LiquidHandlerBackend):
|
|||||||
PlateNo = plate_slots[0]
|
PlateNo = plate_slots[0]
|
||||||
hole_col = tip_columns[0] + 1
|
hole_col = tip_columns[0] + 1
|
||||||
hole_row = 1
|
hole_row = 1
|
||||||
|
assist_fun1 = ""
|
||||||
if self.num_channels != 8:
|
if self.num_channels != 8:
|
||||||
hole_row = tipspot_index % ny + 1
|
hole_row = tipspot_index % ny + 1
|
||||||
|
if ops[0].blow_out_air_volume is not None:
|
||||||
|
assist_fun1 = f"反向吸液({float(min(max(ops[0].blow_out_air_volume,0),10))}ul)"
|
||||||
|
|
||||||
step = self.api_client.Imbibing(
|
step = self.api_client.Imbibing(
|
||||||
axis=axis,
|
axis=axis,
|
||||||
@@ -1770,9 +1898,10 @@ class PRCXI9300Backend(LiquidHandlerBackend):
|
|||||||
hole_row=hole_row,
|
hole_row=hole_row,
|
||||||
hole_col=hole_col,
|
hole_col=hole_col,
|
||||||
blending_times=0,
|
blending_times=0,
|
||||||
balance_height=0,
|
balance_height=int(min(max(ops[0].liquid_height,0),10)),
|
||||||
plate_or_hole=f"H{hole_col}-{ny},T{PlateNo}",
|
plate_or_hole=f"H{hole_col}-{ny},T{PlateNo}",
|
||||||
hole_numbers="1,2,3,4,5,6,7,8",
|
hole_numbers="1,2,3,4,5,6,7,8",
|
||||||
|
assist_fun1=assist_fun1,
|
||||||
)
|
)
|
||||||
self.steps_todo_list.append(step)
|
self.steps_todo_list.append(step)
|
||||||
|
|
||||||
@@ -1823,6 +1952,10 @@ class PRCXI9300Backend(LiquidHandlerBackend):
|
|||||||
if self.num_channels != 8:
|
if self.num_channels != 8:
|
||||||
hole_row = tipspot_index % ny + 1
|
hole_row = tipspot_index % ny + 1
|
||||||
|
|
||||||
|
assist_fun1 = ""
|
||||||
|
if ops[0].blow_out_air_volume is not None:
|
||||||
|
assist_fun1 = f"吹样({float(min(max(ops[0].blow_out_air_volume,0),10))}ul)"
|
||||||
|
|
||||||
step = self.api_client.Tapping(
|
step = self.api_client.Tapping(
|
||||||
axis=axis,
|
axis=axis,
|
||||||
dosage=int(volumes[0]),
|
dosage=int(volumes[0]),
|
||||||
@@ -1831,9 +1964,10 @@ class PRCXI9300Backend(LiquidHandlerBackend):
|
|||||||
hole_row=hole_row,
|
hole_row=hole_row,
|
||||||
hole_col=hole_col,
|
hole_col=hole_col,
|
||||||
blending_times=0,
|
blending_times=0,
|
||||||
balance_height=0,
|
balance_height=int(min(max(ops[0].liquid_height,0),10)),
|
||||||
plate_or_hole=f"H{hole_col}-{ny},T{PlateNo}",
|
plate_or_hole=f"H{hole_col}-{ny},T{PlateNo}",
|
||||||
hole_numbers="1,2,3,4,5,6,7,8",
|
hole_numbers="1,2,3,4,5,6,7,8",
|
||||||
|
assist_fun1=assist_fun1,
|
||||||
)
|
)
|
||||||
self.steps_todo_list.append(step)
|
self.steps_todo_list.append(step)
|
||||||
|
|
||||||
@@ -2028,10 +2162,10 @@ class PRCXI9300Api:
|
|||||||
"""GetWorkTabletMatrixById"""
|
"""GetWorkTabletMatrixById"""
|
||||||
return self.call("IMatrix", "GetWorkTabletMatrixById", [matrix_id])
|
return self.call("IMatrix", "GetWorkTabletMatrixById", [matrix_id])
|
||||||
|
|
||||||
def update_clamp_jaw_position(self, target_matrix_id: str, plate_positions: List[Dict[str, Any]]):
|
def update_clamp_jaw_position(self, target_matrix_id: str, claw_positions: List[Dict[str, Any]]):
|
||||||
position_params = {
|
position_params = {
|
||||||
"MatrixId": target_matrix_id,
|
"MatrixId": target_matrix_id,
|
||||||
"WorkTablets": plate_positions
|
"WorkTablets": claw_positions
|
||||||
}
|
}
|
||||||
return self.call("IMatrix", "UpdateClampJawPosition", [position_params])
|
return self.call("IMatrix", "UpdateClampJawPosition", [position_params])
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
1
unilabos/labware_manager/__init__.py
Normal file
1
unilabos/labware_manager/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# PRCXI 耗材管理 Web 应用
|
||||||
4
unilabos/labware_manager/__main__.py
Normal file
4
unilabos/labware_manager/__main__.py
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
"""启动入口: python -m unilabos.labware_manager"""
|
||||||
|
from unilabos.labware_manager.app import main
|
||||||
|
|
||||||
|
main()
|
||||||
196
unilabos/labware_manager/app.py
Normal file
196
unilabos/labware_manager/app.py
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
"""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()
|
||||||
451
unilabos/labware_manager/codegen.py
Normal file
451
unilabos/labware_manager/codegen.py
Normal file
@@ -0,0 +1,451 @@
|
|||||||
|
"""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)} 个工厂函数)")
|
||||||
474
unilabos/labware_manager/importer.py
Normal file
474
unilabos/labware_manager/importer.py
Normal file
@@ -0,0 +1,474 @@
|
|||||||
|
"""从现有 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}")
|
||||||
1316
unilabos/labware_manager/labware_db.json
Normal file
1316
unilabos/labware_manager/labware_db.json
Normal file
File diff suppressed because it is too large
Load Diff
126
unilabos/labware_manager/models.py
Normal file
126
unilabos/labware_manager/models.py
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
"""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)
|
||||||
292
unilabos/labware_manager/static/form_handler.js
Normal file
292
unilabos/labware_manager/static/form_handler.js
Normal file
@@ -0,0 +1,292 @@
|
|||||||
|
/**
|
||||||
|
* 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
450
unilabos/labware_manager/static/labware_viz.js
Normal file
450
unilabos/labware_manager/static/labware_viz.js
Normal file
@@ -0,0 +1,450 @@
|
|||||||
|
/**
|
||||||
|
* 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();
|
||||||
|
}
|
||||||
295
unilabos/labware_manager/static/style.css
Normal file
295
unilabos/labware_manager/static/style.css
Normal file
@@ -0,0 +1,295 @@
|
|||||||
|
/* 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; }
|
||||||
24
unilabos/labware_manager/templates/base.html
Normal file
24
unilabos/labware_manager/templates/base.html
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
<!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>
|
||||||
159
unilabos/labware_manager/templates/detail.html
Normal file
159
unilabos/labware_manager/templates/detail.html
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
{% 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 %}
|
||||||
257
unilabos/labware_manager/templates/edit.html
Normal file
257
unilabos/labware_manager/templates/edit.html
Normal file
@@ -0,0 +1,257 @@
|
|||||||
|
{% 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 %}
|
||||||
131
unilabos/labware_manager/templates/index.html
Normal file
131
unilabos/labware_manager/templates/index.html
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
{% 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 %}
|
||||||
119
unilabos/labware_manager/yaml_gen.py
Normal file
119
unilabos/labware_manager/yaml_gen.py
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
"""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
@@ -3,7 +3,7 @@ PRCXI_30mm_Adapter:
|
|||||||
- prcxi
|
- prcxi
|
||||||
- plate_adapters
|
- plate_adapters
|
||||||
class:
|
class:
|
||||||
module: unilabos.devices.liquid_handling.prcxi.prcxi_labware:PRCXI_30mm_Adapter
|
module: 'unilabos.devices.liquid_handling.prcxi.prcxi_labware:PRCXI_30mm_Adapter'
|
||||||
type: pylabrobot
|
type: pylabrobot
|
||||||
description: '30mm适配器 (Code: ZX-58-30)'
|
description: '30mm适配器 (Code: ZX-58-30)'
|
||||||
handles: []
|
handles: []
|
||||||
@@ -15,7 +15,7 @@ PRCXI_Adapter:
|
|||||||
- prcxi
|
- prcxi
|
||||||
- plate_adapters
|
- plate_adapters
|
||||||
class:
|
class:
|
||||||
module: unilabos.devices.liquid_handling.prcxi.prcxi_labware:PRCXI_Adapter
|
module: 'unilabos.devices.liquid_handling.prcxi.prcxi_labware:PRCXI_Adapter'
|
||||||
type: pylabrobot
|
type: pylabrobot
|
||||||
description: '适配器 (Code: Fhh478)'
|
description: '适配器 (Code: Fhh478)'
|
||||||
handles: []
|
handles: []
|
||||||
@@ -27,7 +27,7 @@ PRCXI_Deep10_Adapter:
|
|||||||
- prcxi
|
- prcxi
|
||||||
- plate_adapters
|
- plate_adapters
|
||||||
class:
|
class:
|
||||||
module: unilabos.devices.liquid_handling.prcxi.prcxi_labware:PRCXI_Deep10_Adapter
|
module: 'unilabos.devices.liquid_handling.prcxi.prcxi_labware:PRCXI_Deep10_Adapter'
|
||||||
type: pylabrobot
|
type: pylabrobot
|
||||||
description: '10ul专用深孔板适配器 (Code: ZX-002-10)'
|
description: '10ul专用深孔板适配器 (Code: ZX-002-10)'
|
||||||
handles: []
|
handles: []
|
||||||
@@ -39,7 +39,7 @@ PRCXI_Deep300_Adapter:
|
|||||||
- prcxi
|
- prcxi
|
||||||
- plate_adapters
|
- plate_adapters
|
||||||
class:
|
class:
|
||||||
module: unilabos.devices.liquid_handling.prcxi.prcxi_labware:PRCXI_Deep300_Adapter
|
module: 'unilabos.devices.liquid_handling.prcxi.prcxi_labware:PRCXI_Deep300_Adapter'
|
||||||
type: pylabrobot
|
type: pylabrobot
|
||||||
description: '300ul深孔板适配器 (Code: ZX-002-300)'
|
description: '300ul深孔板适配器 (Code: ZX-002-300)'
|
||||||
handles: []
|
handles: []
|
||||||
@@ -51,7 +51,7 @@ PRCXI_PCR_Adapter:
|
|||||||
- prcxi
|
- prcxi
|
||||||
- plate_adapters
|
- plate_adapters
|
||||||
class:
|
class:
|
||||||
module: unilabos.devices.liquid_handling.prcxi.prcxi_labware:PRCXI_PCR_Adapter
|
module: 'unilabos.devices.liquid_handling.prcxi.prcxi_labware:PRCXI_PCR_Adapter'
|
||||||
type: pylabrobot
|
type: pylabrobot
|
||||||
description: '全裙边 PCR适配器 (Code: ZX-58-0001)'
|
description: '全裙边 PCR适配器 (Code: ZX-58-0001)'
|
||||||
handles: []
|
handles: []
|
||||||
@@ -63,7 +63,7 @@ PRCXI_Reservoir_Adapter:
|
|||||||
- prcxi
|
- prcxi
|
||||||
- plate_adapters
|
- plate_adapters
|
||||||
class:
|
class:
|
||||||
module: unilabos.devices.liquid_handling.prcxi.prcxi_labware:PRCXI_Reservoir_Adapter
|
module: 'unilabos.devices.liquid_handling.prcxi.prcxi_labware:PRCXI_Reservoir_Adapter'
|
||||||
type: pylabrobot
|
type: pylabrobot
|
||||||
description: '储液槽 适配器 (Code: ZX-ADP-001)'
|
description: '储液槽 适配器 (Code: ZX-ADP-001)'
|
||||||
handles: []
|
handles: []
|
||||||
@@ -75,7 +75,7 @@ PRCXI_Tip10_Adapter:
|
|||||||
- prcxi
|
- prcxi
|
||||||
- plate_adapters
|
- plate_adapters
|
||||||
class:
|
class:
|
||||||
module: unilabos.devices.liquid_handling.prcxi.prcxi_labware:PRCXI_Tip10_Adapter
|
module: 'unilabos.devices.liquid_handling.prcxi.prcxi_labware:PRCXI_Tip10_Adapter'
|
||||||
type: pylabrobot
|
type: pylabrobot
|
||||||
description: '吸头10ul 适配器 (Code: ZX-58-10)'
|
description: '吸头10ul 适配器 (Code: ZX-58-10)'
|
||||||
handles: []
|
handles: []
|
||||||
@@ -87,7 +87,7 @@ PRCXI_Tip1250_Adapter:
|
|||||||
- prcxi
|
- prcxi
|
||||||
- plate_adapters
|
- plate_adapters
|
||||||
class:
|
class:
|
||||||
module: unilabos.devices.liquid_handling.prcxi.prcxi_labware:PRCXI_Tip1250_Adapter
|
module: 'unilabos.devices.liquid_handling.prcxi.prcxi_labware:PRCXI_Tip1250_Adapter'
|
||||||
type: pylabrobot
|
type: pylabrobot
|
||||||
description: 'Tip头适配器 1250uL (Code: ZX-58-1250)'
|
description: 'Tip头适配器 1250uL (Code: ZX-58-1250)'
|
||||||
handles: []
|
handles: []
|
||||||
@@ -99,7 +99,7 @@ PRCXI_Tip300_Adapter:
|
|||||||
- prcxi
|
- prcxi
|
||||||
- plate_adapters
|
- plate_adapters
|
||||||
class:
|
class:
|
||||||
module: unilabos.devices.liquid_handling.prcxi.prcxi_labware:PRCXI_Tip300_Adapter
|
module: 'unilabos.devices.liquid_handling.prcxi.prcxi_labware:PRCXI_Tip300_Adapter'
|
||||||
type: pylabrobot
|
type: pylabrobot
|
||||||
description: 'ZHONGXI 适配器 300uL (Code: ZX-58-300)'
|
description: 'ZHONGXI 适配器 300uL (Code: ZX-58-300)'
|
||||||
handles: []
|
handles: []
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ PRCXI_48_DeepWell:
|
|||||||
- prcxi
|
- prcxi
|
||||||
- plates
|
- plates
|
||||||
class:
|
class:
|
||||||
module: unilabos.devices.liquid_handling.prcxi.prcxi_labware:PRCXI_48_DeepWell
|
module: 'unilabos.devices.liquid_handling.prcxi.prcxi_labware:PRCXI_48_DeepWell'
|
||||||
type: pylabrobot
|
type: pylabrobot
|
||||||
description: '48孔深孔板 (Code: 22)'
|
description: '48孔深孔板 (Code: 22)'
|
||||||
handles: []
|
handles: []
|
||||||
@@ -15,7 +15,7 @@ PRCXI_96_DeepWell:
|
|||||||
- prcxi
|
- prcxi
|
||||||
- plates
|
- plates
|
||||||
class:
|
class:
|
||||||
module: unilabos.devices.liquid_handling.prcxi.prcxi_labware:PRCXI_96_DeepWell
|
module: 'unilabos.devices.liquid_handling.prcxi.prcxi_labware:PRCXI_96_DeepWell'
|
||||||
type: pylabrobot
|
type: pylabrobot
|
||||||
description: '96深孔板 (Code: q2)'
|
description: '96深孔板 (Code: q2)'
|
||||||
handles: []
|
handles: []
|
||||||
@@ -27,7 +27,7 @@ PRCXI_AGenBio_4_troughplate:
|
|||||||
- prcxi
|
- prcxi
|
||||||
- plates
|
- plates
|
||||||
class:
|
class:
|
||||||
module: unilabos.devices.liquid_handling.prcxi.prcxi_labware:PRCXI_AGenBio_4_troughplate
|
module: 'unilabos.devices.liquid_handling.prcxi.prcxi_labware:PRCXI_AGenBio_4_troughplate'
|
||||||
type: pylabrobot
|
type: pylabrobot
|
||||||
description: '4道储液槽 (Code: sdfrth654)'
|
description: '4道储液槽 (Code: sdfrth654)'
|
||||||
handles: []
|
handles: []
|
||||||
@@ -39,7 +39,7 @@ PRCXI_BioER_96_wellplate:
|
|||||||
- prcxi
|
- prcxi
|
||||||
- plates
|
- plates
|
||||||
class:
|
class:
|
||||||
module: unilabos.devices.liquid_handling.prcxi.prcxi_labware:PRCXI_BioER_96_wellplate
|
module: 'unilabos.devices.liquid_handling.prcxi.prcxi_labware:PRCXI_BioER_96_wellplate'
|
||||||
type: pylabrobot
|
type: pylabrobot
|
||||||
description: '2.2ml 深孔板 (Code: ZX-019-2.2)'
|
description: '2.2ml 深孔板 (Code: ZX-019-2.2)'
|
||||||
handles: []
|
handles: []
|
||||||
@@ -51,7 +51,7 @@ PRCXI_BioRad_384_wellplate:
|
|||||||
- prcxi
|
- prcxi
|
||||||
- plates
|
- plates
|
||||||
class:
|
class:
|
||||||
module: unilabos.devices.liquid_handling.prcxi.prcxi_labware:PRCXI_BioRad_384_wellplate
|
module: 'unilabos.devices.liquid_handling.prcxi.prcxi_labware:PRCXI_BioRad_384_wellplate'
|
||||||
type: pylabrobot
|
type: pylabrobot
|
||||||
description: '384板 (Code: q3)'
|
description: '384板 (Code: q3)'
|
||||||
handles: []
|
handles: []
|
||||||
@@ -63,7 +63,7 @@ PRCXI_CellTreat_96_wellplate:
|
|||||||
- prcxi
|
- prcxi
|
||||||
- plates
|
- plates
|
||||||
class:
|
class:
|
||||||
module: unilabos.devices.liquid_handling.prcxi.prcxi_labware:PRCXI_CellTreat_96_wellplate
|
module: 'unilabos.devices.liquid_handling.prcxi.prcxi_labware:PRCXI_CellTreat_96_wellplate'
|
||||||
type: pylabrobot
|
type: pylabrobot
|
||||||
description: '细菌培养皿 (Code: ZX-78-096)'
|
description: '细菌培养皿 (Code: ZX-78-096)'
|
||||||
handles: []
|
handles: []
|
||||||
@@ -75,7 +75,7 @@ PRCXI_PCR_Plate_200uL_nonskirted:
|
|||||||
- prcxi
|
- prcxi
|
||||||
- plates
|
- plates
|
||||||
class:
|
class:
|
||||||
module: unilabos.devices.liquid_handling.prcxi.prcxi_labware:PRCXI_PCR_Plate_200uL_nonskirted
|
module: 'unilabos.devices.liquid_handling.prcxi.prcxi_labware:PRCXI_PCR_Plate_200uL_nonskirted'
|
||||||
type: pylabrobot
|
type: pylabrobot
|
||||||
description: '0.2ml PCR 板 (Code: ZX-023-0.2)'
|
description: '0.2ml PCR 板 (Code: ZX-023-0.2)'
|
||||||
handles: []
|
handles: []
|
||||||
@@ -87,7 +87,7 @@ PRCXI_PCR_Plate_200uL_semiskirted:
|
|||||||
- prcxi
|
- prcxi
|
||||||
- plates
|
- plates
|
||||||
class:
|
class:
|
||||||
module: unilabos.devices.liquid_handling.prcxi.prcxi_labware:PRCXI_PCR_Plate_200uL_semiskirted
|
module: 'unilabos.devices.liquid_handling.prcxi.prcxi_labware:PRCXI_PCR_Plate_200uL_semiskirted'
|
||||||
type: pylabrobot
|
type: pylabrobot
|
||||||
description: '0.2ml PCR 板 (Code: ZX-023-0.2)'
|
description: '0.2ml PCR 板 (Code: ZX-023-0.2)'
|
||||||
handles: []
|
handles: []
|
||||||
@@ -99,7 +99,7 @@ PRCXI_PCR_Plate_200uL_skirted:
|
|||||||
- prcxi
|
- prcxi
|
||||||
- plates
|
- plates
|
||||||
class:
|
class:
|
||||||
module: unilabos.devices.liquid_handling.prcxi.prcxi_labware:PRCXI_PCR_Plate_200uL_skirted
|
module: 'unilabos.devices.liquid_handling.prcxi.prcxi_labware:PRCXI_PCR_Plate_200uL_skirted'
|
||||||
type: pylabrobot
|
type: pylabrobot
|
||||||
description: '0.2ml PCR 板 (Code: ZX-023-0.2)'
|
description: '0.2ml PCR 板 (Code: ZX-023-0.2)'
|
||||||
handles: []
|
handles: []
|
||||||
@@ -111,7 +111,7 @@ PRCXI_nest_12_troughplate:
|
|||||||
- prcxi
|
- prcxi
|
||||||
- plates
|
- plates
|
||||||
class:
|
class:
|
||||||
module: unilabos.devices.liquid_handling.prcxi.prcxi_labware:PRCXI_nest_12_troughplate
|
module: 'unilabos.devices.liquid_handling.prcxi.prcxi_labware:PRCXI_nest_12_troughplate'
|
||||||
type: pylabrobot
|
type: pylabrobot
|
||||||
description: '12道储液槽 (Code: 12道储液槽)'
|
description: '12道储液槽 (Code: 12道储液槽)'
|
||||||
handles: []
|
handles: []
|
||||||
@@ -123,7 +123,7 @@ PRCXI_nest_1_troughplate:
|
|||||||
- prcxi
|
- prcxi
|
||||||
- plates
|
- plates
|
||||||
class:
|
class:
|
||||||
module: unilabos.devices.liquid_handling.prcxi.prcxi_labware:PRCXI_nest_1_troughplate
|
module: 'unilabos.devices.liquid_handling.prcxi.prcxi_labware:PRCXI_nest_1_troughplate'
|
||||||
type: pylabrobot
|
type: pylabrobot
|
||||||
description: '储液槽 (Code: ZX-58-10000)'
|
description: '储液槽 (Code: ZX-58-10000)'
|
||||||
handles: []
|
handles: []
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ PRCXI_1000uL_Tips:
|
|||||||
- prcxi
|
- prcxi
|
||||||
- tip_racks
|
- tip_racks
|
||||||
class:
|
class:
|
||||||
module: unilabos.devices.liquid_handling.prcxi.prcxi_labware:PRCXI_1000uL_Tips
|
module: 'unilabos.devices.liquid_handling.prcxi.prcxi_labware:PRCXI_1000uL_Tips'
|
||||||
type: pylabrobot
|
type: pylabrobot
|
||||||
description: '1000μL Tip头 (Code: ZX-001-1000)'
|
description: '1000μL Tip头 (Code: ZX-001-1000)'
|
||||||
handles: []
|
handles: []
|
||||||
@@ -15,7 +15,7 @@ PRCXI_10uL_Tips:
|
|||||||
- prcxi
|
- prcxi
|
||||||
- tip_racks
|
- tip_racks
|
||||||
class:
|
class:
|
||||||
module: unilabos.devices.liquid_handling.prcxi.prcxi_labware:PRCXI_10uL_Tips
|
module: 'unilabos.devices.liquid_handling.prcxi.prcxi_labware:PRCXI_10uL_Tips'
|
||||||
type: pylabrobot
|
type: pylabrobot
|
||||||
description: '10μL Tip头 (Code: ZX-001-10)'
|
description: '10μL Tip头 (Code: ZX-001-10)'
|
||||||
handles: []
|
handles: []
|
||||||
@@ -27,7 +27,7 @@ PRCXI_10ul_eTips:
|
|||||||
- prcxi
|
- prcxi
|
||||||
- tip_racks
|
- tip_racks
|
||||||
class:
|
class:
|
||||||
module: unilabos.devices.liquid_handling.prcxi.prcxi_labware:PRCXI_10ul_eTips
|
module: 'unilabos.devices.liquid_handling.prcxi.prcxi_labware:PRCXI_10ul_eTips'
|
||||||
type: pylabrobot
|
type: pylabrobot
|
||||||
description: '10μL加长 Tip头 (Code: ZX-001-10+)'
|
description: '10μL加长 Tip头 (Code: ZX-001-10+)'
|
||||||
handles: []
|
handles: []
|
||||||
@@ -39,7 +39,7 @@ PRCXI_1250uL_Tips:
|
|||||||
- prcxi
|
- prcxi
|
||||||
- tip_racks
|
- tip_racks
|
||||||
class:
|
class:
|
||||||
module: unilabos.devices.liquid_handling.prcxi.prcxi_labware:PRCXI_1250uL_Tips
|
module: 'unilabos.devices.liquid_handling.prcxi.prcxi_labware:PRCXI_1250uL_Tips'
|
||||||
type: pylabrobot
|
type: pylabrobot
|
||||||
description: '1250μL Tip头 (Code: ZX-001-1250)'
|
description: '1250μL Tip头 (Code: ZX-001-1250)'
|
||||||
handles: []
|
handles: []
|
||||||
@@ -51,7 +51,7 @@ PRCXI_200uL_Tips:
|
|||||||
- prcxi
|
- prcxi
|
||||||
- tip_racks
|
- tip_racks
|
||||||
class:
|
class:
|
||||||
module: unilabos.devices.liquid_handling.prcxi.prcxi_labware:PRCXI_200uL_Tips
|
module: 'unilabos.devices.liquid_handling.prcxi.prcxi_labware:PRCXI_200uL_Tips'
|
||||||
type: pylabrobot
|
type: pylabrobot
|
||||||
description: '200μL Tip头 (Code: ZX-001-200)'
|
description: '200μL Tip头 (Code: ZX-001-200)'
|
||||||
handles: []
|
handles: []
|
||||||
@@ -63,10 +63,22 @@ PRCXI_300ul_Tips:
|
|||||||
- prcxi
|
- prcxi
|
||||||
- tip_racks
|
- tip_racks
|
||||||
class:
|
class:
|
||||||
module: unilabos.devices.liquid_handling.prcxi.prcxi_labware:PRCXI_300ul_Tips
|
module: 'unilabos.devices.liquid_handling.prcxi.prcxi_labware:PRCXI_300ul_Tips'
|
||||||
type: pylabrobot
|
type: pylabrobot
|
||||||
description: '300μL Tip头 (Code: ZX-001-300)'
|
description: '300μL Tip头 (Code: ZX-001-300)'
|
||||||
handles: []
|
handles: []
|
||||||
icon: ''
|
icon: ''
|
||||||
init_param_schema: {}
|
init_param_schema: {}
|
||||||
version: 1.0.0
|
version: 1.0.0
|
||||||
|
PRCXI_50uL_tips:
|
||||||
|
category:
|
||||||
|
- prcxi
|
||||||
|
- tip_racks
|
||||||
|
class:
|
||||||
|
module: 'unilabos.devices.liquid_handling.prcxi.prcxi_labware:PRCXI_50uL_tips'
|
||||||
|
type: pylabrobot
|
||||||
|
description: PRCXI_50uL_tips
|
||||||
|
handles: []
|
||||||
|
icon: ''
|
||||||
|
init_param_schema: {}
|
||||||
|
version: 1.0.0
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ PRCXI_trash:
|
|||||||
- prcxi
|
- prcxi
|
||||||
- trash
|
- trash
|
||||||
class:
|
class:
|
||||||
module: unilabos.devices.liquid_handling.prcxi.prcxi_labware:PRCXI_trash
|
module: 'unilabos.devices.liquid_handling.prcxi.prcxi_labware:PRCXI_trash'
|
||||||
type: pylabrobot
|
type: pylabrobot
|
||||||
description: '废弃槽 (Code: q1)'
|
description: '废弃槽 (Code: q1)'
|
||||||
handles: []
|
handles: []
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ PRCXI_EP_Adapter:
|
|||||||
- prcxi
|
- prcxi
|
||||||
- tube_racks
|
- tube_racks
|
||||||
class:
|
class:
|
||||||
module: unilabos.devices.liquid_handling.prcxi.prcxi_labware:PRCXI_EP_Adapter
|
module: 'unilabos.devices.liquid_handling.prcxi.prcxi_labware:PRCXI_EP_Adapter'
|
||||||
type: pylabrobot
|
type: pylabrobot
|
||||||
description: 'ep适配器 (Code: 1)'
|
description: 'ep适配器 (Code: 1)'
|
||||||
handles: []
|
handles: []
|
||||||
|
|||||||
@@ -489,7 +489,18 @@ class ResourceTreeSet(object):
|
|||||||
def resource_plr_inner(
|
def resource_plr_inner(
|
||||||
d: dict, parent_resource: Optional[ResourceDict], states: dict, uuids: list
|
d: dict, parent_resource: Optional[ResourceDict], states: dict, uuids: list
|
||||||
) -> ResourceDictInstance:
|
) -> ResourceDictInstance:
|
||||||
current_uuid, parent_uuid, extra = uuids.pop(0)
|
if uuids:
|
||||||
|
current_uuid, parent_uuid, extra = uuids.pop(0)
|
||||||
|
else:
|
||||||
|
# serialize() 树比 res.children 树多出了节点(虚拟子节点等),兜底生成 UUID
|
||||||
|
current_uuid = str(uuid.uuid4())
|
||||||
|
parent_uuid = parent_resource.get("uuid") if isinstance(parent_resource, dict) else (
|
||||||
|
getattr(parent_resource, "uuid", None) if parent_resource is not None else None
|
||||||
|
)
|
||||||
|
extra = {}
|
||||||
|
logger.warning(
|
||||||
|
f"from_plr_resources: UUID 列表耗尽,为节点 '{d.get('name', '?')}' 生成临时 UUID {current_uuid}"
|
||||||
|
)
|
||||||
|
|
||||||
raw_pos = (
|
raw_pos = (
|
||||||
{"x": d["location"]["x"], "y": d["location"]["y"], "z": d["location"]["z"]}
|
{"x": d["location"]["x"], "y": d["location"]["y"], "z": d["location"]["z"]}
|
||||||
|
|||||||
@@ -1739,13 +1739,19 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
|||||||
if arg_type == "unilabos.registry.placeholder_type:ResourceSlot":
|
if arg_type == "unilabos.registry.placeholder_type:ResourceSlot":
|
||||||
resource_data = function_args[arg_name]
|
resource_data = function_args[arg_name]
|
||||||
if isinstance(resource_data, dict) and "id" in resource_data:
|
if isinstance(resource_data, dict) and "id" in resource_data:
|
||||||
try:
|
uid = resource_data.get("uuid", "")
|
||||||
function_args[arg_name] = self._convert_resources_sync(resource_data["uuid"])[0]
|
# 优先从本地追踪器直接取(避免服务端未同步导致的空返回)
|
||||||
except Exception as e:
|
local_fast = self.resource_tracker.uuid_to_resources.get(uid) if uid else None
|
||||||
self.lab_logger().error(
|
if local_fast is not None:
|
||||||
f"转换ResourceSlot参数 {arg_name} 失败: {e}\n{traceback.format_exc()}"
|
function_args[arg_name] = local_fast
|
||||||
)
|
else:
|
||||||
raise JsonCommandInitError(f"ResourceSlot参数转换失败: {arg_name}")
|
try:
|
||||||
|
function_args[arg_name] = self._convert_resources_sync(uid)[0]
|
||||||
|
except Exception as e:
|
||||||
|
self.lab_logger().error(
|
||||||
|
f"转换ResourceSlot参数 {arg_name} 失败: {e}\n{traceback.format_exc()}"
|
||||||
|
)
|
||||||
|
raise JsonCommandInitError(f"ResourceSlot参数转换失败: {arg_name}")
|
||||||
|
|
||||||
# 处理 ResourceSlot 列表
|
# 处理 ResourceSlot 列表
|
||||||
elif isinstance(arg_type, tuple) and len(arg_type) == 2:
|
elif isinstance(arg_type, tuple) and len(arg_type) == 2:
|
||||||
@@ -1753,14 +1759,23 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
|||||||
if arg_type[0] == "list" and arg_type[1] == resource_slot_type:
|
if arg_type[0] == "list" and arg_type[1] == resource_slot_type:
|
||||||
resource_list = function_args[arg_name]
|
resource_list = function_args[arg_name]
|
||||||
if isinstance(resource_list, list):
|
if isinstance(resource_list, list):
|
||||||
try:
|
uuids = [r["uuid"] for r in resource_list if isinstance(r, dict) and "id" in r]
|
||||||
uuids = [r["uuid"] for r in resource_list if isinstance(r, dict) and "id" in r]
|
# 先尝试本地追踪器批量取
|
||||||
function_args[arg_name] = self._convert_resources_sync(*uuids) if uuids else []
|
local_hits = [
|
||||||
except Exception as e:
|
self.resource_tracker.uuid_to_resources[u]
|
||||||
self.lab_logger().error(
|
for u in uuids
|
||||||
f"转换ResourceSlot列表参数 {arg_name} 失败: {e}\n{traceback.format_exc()}"
|
if u in self.resource_tracker.uuid_to_resources
|
||||||
)
|
]
|
||||||
raise JsonCommandInitError(f"ResourceSlot列表参数转换失败: {arg_name}")
|
if len(local_hits) == len(uuids):
|
||||||
|
function_args[arg_name] = local_hits
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
function_args[arg_name] = self._convert_resources_sync(*uuids) if uuids else []
|
||||||
|
except Exception as e:
|
||||||
|
self.lab_logger().error(
|
||||||
|
f"转换ResourceSlot列表参数 {arg_name} 失败: {e}\n{traceback.format_exc()}"
|
||||||
|
)
|
||||||
|
raise JsonCommandInitError(f"ResourceSlot列表参数转换失败: {arg_name}")
|
||||||
|
|
||||||
# todo: 默认反报送
|
# todo: 默认反报送
|
||||||
return function(**function_args)
|
return function(**function_args)
|
||||||
@@ -1812,6 +1827,18 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
|||||||
# 转换为 PLR 资源
|
# 转换为 PLR 资源
|
||||||
tree_set = ResourceTreeSet.from_raw_dict_list(raw_data)
|
tree_set = ResourceTreeSet.from_raw_dict_list(raw_data)
|
||||||
if not len(tree_set.trees):
|
if not len(tree_set.trees):
|
||||||
|
# 服务端未找到时,尝试从本地追踪器兜底(create_resource 刚完成但服务端未及时同步)
|
||||||
|
local_hits = [
|
||||||
|
self.resource_tracker.uuid_to_resources[uid]
|
||||||
|
for uid in uuids_list
|
||||||
|
if uid in self.resource_tracker.uuid_to_resources
|
||||||
|
]
|
||||||
|
if local_hits:
|
||||||
|
self.lab_logger().warning(
|
||||||
|
f"资源查询服务端返回空树,已从本地追踪器找到 "
|
||||||
|
f"{len(local_hits)}/{len(uuids_list)} 个资源: {uuids_list}"
|
||||||
|
)
|
||||||
|
return local_hits
|
||||||
raise Exception(f"资源查询返回空树: {raw_data}")
|
raise Exception(f"资源查询返回空树: {raw_data}")
|
||||||
plr_resources = tree_set.to_plr_resources()
|
plr_resources = tree_set.to_plr_resources()
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,7 @@
|
|||||||
"port": 9999,
|
"port": 9999,
|
||||||
"debug": false,
|
"debug": false,
|
||||||
"setup": true,
|
"setup": true,
|
||||||
|
"matrix_id": "1ecb1b45-6aef-456b-bd68-8f538c4e5826",
|
||||||
"timeout": 10,
|
"timeout": 10,
|
||||||
"simulator": false,
|
"simulator": false,
|
||||||
"channel_num": 8
|
"channel_num": 8
|
||||||
|
|||||||
@@ -21,13 +21,20 @@
|
|||||||
},
|
},
|
||||||
"host": "10.20.30.184",
|
"host": "10.20.30.184",
|
||||||
"port": 9999,
|
"port": 9999,
|
||||||
"debug": true,
|
"debug": false,
|
||||||
"setup": true,
|
"setup": false,
|
||||||
"is_9320": true,
|
"is_9320": true,
|
||||||
"timeout": 10,
|
"timeout": 10,
|
||||||
"matrix_id": "5de524d0-3f95-406c-86dd-f83626ebc7cb",
|
"matrix_id": "",
|
||||||
"simulator": true,
|
"simulator": false,
|
||||||
"channel_num": 2
|
"channel_num": 2,
|
||||||
|
"step_mode": true,
|
||||||
|
"calibration_points": {
|
||||||
|
"line_1": [[452.07,21.19], [313.88,21.19], [176.87,21.19], [39.08,21.19]],
|
||||||
|
"line_2": [[415.67,116.68], [313.28,116.88], [176.58,116.69], [38.58,117.18]],
|
||||||
|
"line_3": [[450.87,212.18], [312.98,212.38], [176.08,212.68], [38.08,213.18]],
|
||||||
|
"line_4": [[450.08,307.68], [312.18,307.89], [175.18,308.18], [37.58,309.18]]
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"data": {
|
"data": {
|
||||||
"reset_ok": true
|
"reset_ok": true
|
||||||
@@ -49,8 +56,8 @@
|
|||||||
"type": "deck",
|
"type": "deck",
|
||||||
"class": "",
|
"class": "",
|
||||||
"position": {
|
"position": {
|
||||||
"x": 10,
|
"x": 0,
|
||||||
"y": 10,
|
"y": 0,
|
||||||
"z": 0
|
"z": 0
|
||||||
},
|
},
|
||||||
"config": {
|
"config": {
|
||||||
@@ -66,426 +73,7 @@
|
|||||||
},
|
},
|
||||||
"category": "deck",
|
"category": "deck",
|
||||||
"barcode": null,
|
"barcode": null,
|
||||||
"preferred_pickup_location": null,
|
"preferred_pickup_location": null
|
||||||
"sites": [
|
|
||||||
{
|
|
||||||
"label": "T1",
|
|
||||||
"visible": true,
|
|
||||||
"occupied_by": null,
|
|
||||||
"position": {
|
|
||||||
"x": 0,
|
|
||||||
"y": 288,
|
|
||||||
"z": 0
|
|
||||||
},
|
|
||||||
"size": {
|
|
||||||
"width": 128.0,
|
|
||||||
"height": 86,
|
|
||||||
"depth": 0
|
|
||||||
},
|
|
||||||
"content_type": [
|
|
||||||
"container",
|
|
||||||
"plate",
|
|
||||||
"tip_rack",
|
|
||||||
"plates",
|
|
||||||
"tip_racks",
|
|
||||||
"tube_rack",
|
|
||||||
"adaptor",
|
|
||||||
"plateadapter",
|
|
||||||
"module",
|
|
||||||
"trash"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"label": "T2",
|
|
||||||
"visible": true,
|
|
||||||
"occupied_by": null,
|
|
||||||
"position": {
|
|
||||||
"x": 138,
|
|
||||||
"y": 288,
|
|
||||||
"z": 0
|
|
||||||
},
|
|
||||||
"size": {
|
|
||||||
"width": 128.0,
|
|
||||||
"height": 86,
|
|
||||||
"depth": 0
|
|
||||||
},
|
|
||||||
"content_type": [
|
|
||||||
"plate",
|
|
||||||
"tip_rack",
|
|
||||||
"plates",
|
|
||||||
"tip_racks",
|
|
||||||
"tube_rack",
|
|
||||||
"adaptor",
|
|
||||||
"plateadapter",
|
|
||||||
"module",
|
|
||||||
"trash"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"label": "T3",
|
|
||||||
"visible": true,
|
|
||||||
"occupied_by": null,
|
|
||||||
"position": {
|
|
||||||
"x": 276,
|
|
||||||
"y": 288,
|
|
||||||
"z": 0
|
|
||||||
},
|
|
||||||
"size": {
|
|
||||||
"width": 128.0,
|
|
||||||
"height": 86,
|
|
||||||
"depth": 0
|
|
||||||
},
|
|
||||||
"content_type": [
|
|
||||||
"plate",
|
|
||||||
"tip_rack",
|
|
||||||
"plates",
|
|
||||||
"tip_racks",
|
|
||||||
"tube_rack",
|
|
||||||
"adaptor",
|
|
||||||
"plateadapter",
|
|
||||||
"module",
|
|
||||||
"trash"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"label": "T4",
|
|
||||||
"visible": true,
|
|
||||||
"occupied_by": null,
|
|
||||||
"position": {
|
|
||||||
"x": 414,
|
|
||||||
"y": 288,
|
|
||||||
"z": 0
|
|
||||||
},
|
|
||||||
"size": {
|
|
||||||
"width": 128.0,
|
|
||||||
"height": 86,
|
|
||||||
"depth": 0
|
|
||||||
},
|
|
||||||
"content_type": [
|
|
||||||
"plate",
|
|
||||||
"tip_rack",
|
|
||||||
"plates",
|
|
||||||
"tip_racks",
|
|
||||||
"tube_rack",
|
|
||||||
"adaptor",
|
|
||||||
"plateadapter",
|
|
||||||
"module",
|
|
||||||
"trash"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"label": "T5",
|
|
||||||
"visible": true,
|
|
||||||
"occupied_by": null,
|
|
||||||
"position": {
|
|
||||||
"x": 0,
|
|
||||||
"y": 192,
|
|
||||||
"z": 0
|
|
||||||
},
|
|
||||||
"size": {
|
|
||||||
"width": 128.0,
|
|
||||||
"height": 86,
|
|
||||||
"depth": 0
|
|
||||||
},
|
|
||||||
"content_type": [
|
|
||||||
"plate",
|
|
||||||
"tip_rack",
|
|
||||||
"plates",
|
|
||||||
"tip_racks",
|
|
||||||
"tube_rack",
|
|
||||||
"adaptor",
|
|
||||||
"plateadapter",
|
|
||||||
"module",
|
|
||||||
"trash"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"label": "T6",
|
|
||||||
"visible": true,
|
|
||||||
"occupied_by": null,
|
|
||||||
"position": {
|
|
||||||
"x": 138,
|
|
||||||
"y": 192,
|
|
||||||
"z": 0
|
|
||||||
},
|
|
||||||
"size": {
|
|
||||||
"width": 128.0,
|
|
||||||
"height": 86,
|
|
||||||
"depth": 0
|
|
||||||
},
|
|
||||||
"content_type": [
|
|
||||||
"plate",
|
|
||||||
"tip_rack",
|
|
||||||
"plates",
|
|
||||||
"tip_racks",
|
|
||||||
"tube_rack",
|
|
||||||
"adaptor",
|
|
||||||
"plateadapter",
|
|
||||||
"module",
|
|
||||||
"trash"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"label": "T7",
|
|
||||||
"visible": true,
|
|
||||||
"occupied_by": null,
|
|
||||||
"position": {
|
|
||||||
"x": 276,
|
|
||||||
"y": 192,
|
|
||||||
"z": 0
|
|
||||||
},
|
|
||||||
"size": {
|
|
||||||
"width": 128.0,
|
|
||||||
"height": 86,
|
|
||||||
"depth": 0
|
|
||||||
},
|
|
||||||
"content_type": [
|
|
||||||
"plate",
|
|
||||||
"tip_rack",
|
|
||||||
"plates",
|
|
||||||
"tip_racks",
|
|
||||||
"tube_rack",
|
|
||||||
"adaptor",
|
|
||||||
"plateadapter",
|
|
||||||
"module",
|
|
||||||
"trash"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"label": "T8",
|
|
||||||
"visible": true,
|
|
||||||
"occupied_by": null,
|
|
||||||
"position": {
|
|
||||||
"x": 414,
|
|
||||||
"y": 192,
|
|
||||||
"z": 0
|
|
||||||
},
|
|
||||||
"size": {
|
|
||||||
"width": 128.0,
|
|
||||||
"height": 86,
|
|
||||||
"depth": 0
|
|
||||||
},
|
|
||||||
"content_type": [
|
|
||||||
"plate",
|
|
||||||
"tip_rack",
|
|
||||||
"plates",
|
|
||||||
"tip_racks",
|
|
||||||
"tube_rack",
|
|
||||||
"adaptor",
|
|
||||||
"plateadapter",
|
|
||||||
"module",
|
|
||||||
"trash"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"label": "T9",
|
|
||||||
"visible": true,
|
|
||||||
"occupied_by": null,
|
|
||||||
"position": {
|
|
||||||
"x": 0,
|
|
||||||
"y": 96,
|
|
||||||
"z": 0
|
|
||||||
},
|
|
||||||
"size": {
|
|
||||||
"width": 128.0,
|
|
||||||
"height": 86,
|
|
||||||
"depth": 0
|
|
||||||
},
|
|
||||||
"content_type": [
|
|
||||||
"plate",
|
|
||||||
"tip_rack",
|
|
||||||
"plates",
|
|
||||||
"tip_racks",
|
|
||||||
"tube_rack",
|
|
||||||
"adaptor",
|
|
||||||
"plateadapter",
|
|
||||||
"module",
|
|
||||||
"trash"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"label": "T10",
|
|
||||||
"visible": true,
|
|
||||||
"occupied_by": null,
|
|
||||||
"position": {
|
|
||||||
"x": 138,
|
|
||||||
"y": 96,
|
|
||||||
"z": 0
|
|
||||||
},
|
|
||||||
"size": {
|
|
||||||
"width": 128.0,
|
|
||||||
"height": 86,
|
|
||||||
"depth": 0
|
|
||||||
},
|
|
||||||
"content_type": [
|
|
||||||
"plate",
|
|
||||||
"tip_rack",
|
|
||||||
"plates",
|
|
||||||
"tip_racks",
|
|
||||||
"tube_rack",
|
|
||||||
"adaptor",
|
|
||||||
"plateadapter",
|
|
||||||
"module",
|
|
||||||
"trash"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"label": "T11",
|
|
||||||
"visible": true,
|
|
||||||
"occupied_by": null,
|
|
||||||
"position": {
|
|
||||||
"x": 276,
|
|
||||||
"y": 96,
|
|
||||||
"z": 0
|
|
||||||
},
|
|
||||||
"size": {
|
|
||||||
"width": 128.0,
|
|
||||||
"height": 86,
|
|
||||||
"depth": 0
|
|
||||||
},
|
|
||||||
"content_type": [
|
|
||||||
"plate",
|
|
||||||
"tip_rack",
|
|
||||||
"plates",
|
|
||||||
"tip_racks",
|
|
||||||
"tube_rack",
|
|
||||||
"adaptor",
|
|
||||||
"plateadapter",
|
|
||||||
"module",
|
|
||||||
"trash"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"label": "T12",
|
|
||||||
"visible": true,
|
|
||||||
"occupied_by": null,
|
|
||||||
"position": {
|
|
||||||
"x": 414,
|
|
||||||
"y": 96,
|
|
||||||
"z": 0
|
|
||||||
},
|
|
||||||
"size": {
|
|
||||||
"width": 128.0,
|
|
||||||
"height": 86,
|
|
||||||
"depth": 0
|
|
||||||
},
|
|
||||||
"content_type": [
|
|
||||||
"plate",
|
|
||||||
"tip_rack",
|
|
||||||
"plates",
|
|
||||||
"tip_racks",
|
|
||||||
"tube_rack",
|
|
||||||
"adaptor",
|
|
||||||
"plateadapter",
|
|
||||||
"module",
|
|
||||||
"trash"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"label": "T13",
|
|
||||||
"visible": true,
|
|
||||||
"occupied_by": null,
|
|
||||||
"position": {
|
|
||||||
"x": 0,
|
|
||||||
"y": 0,
|
|
||||||
"z": 0
|
|
||||||
},
|
|
||||||
"size": {
|
|
||||||
"width": 128.0,
|
|
||||||
"height": 86,
|
|
||||||
"depth": 0
|
|
||||||
},
|
|
||||||
"content_type": [
|
|
||||||
"plate",
|
|
||||||
"tip_rack",
|
|
||||||
"plates",
|
|
||||||
"tip_racks",
|
|
||||||
"tube_rack",
|
|
||||||
"adaptor",
|
|
||||||
"plateadapter",
|
|
||||||
"module",
|
|
||||||
"trash"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"label": "T14",
|
|
||||||
"visible": true,
|
|
||||||
"occupied_by": null,
|
|
||||||
"position": {
|
|
||||||
"x": 138,
|
|
||||||
"y": 0,
|
|
||||||
"z": 0
|
|
||||||
},
|
|
||||||
"size": {
|
|
||||||
"width": 128.0,
|
|
||||||
"height": 86,
|
|
||||||
"depth": 0
|
|
||||||
},
|
|
||||||
"content_type": [
|
|
||||||
"plate",
|
|
||||||
"tip_rack",
|
|
||||||
"plates",
|
|
||||||
"tip_racks",
|
|
||||||
"tube_rack",
|
|
||||||
"adaptor",
|
|
||||||
"plateadapter",
|
|
||||||
"module",
|
|
||||||
"trash"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"label": "T15",
|
|
||||||
"visible": true,
|
|
||||||
"occupied_by": null,
|
|
||||||
"position": {
|
|
||||||
"x": 276,
|
|
||||||
"y": 0,
|
|
||||||
"z": 0
|
|
||||||
},
|
|
||||||
"size": {
|
|
||||||
"width": 128.0,
|
|
||||||
"height": 86,
|
|
||||||
"depth": 0
|
|
||||||
},
|
|
||||||
"content_type": [
|
|
||||||
"plate",
|
|
||||||
"tip_rack",
|
|
||||||
"plates",
|
|
||||||
"tip_racks",
|
|
||||||
"tube_rack",
|
|
||||||
"adaptor",
|
|
||||||
"plateadapter",
|
|
||||||
"module",
|
|
||||||
"trash"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"label": "T16",
|
|
||||||
"visible": true,
|
|
||||||
"occupied_by": null,
|
|
||||||
"position": {
|
|
||||||
"x": 414,
|
|
||||||
"y": 0,
|
|
||||||
"z": 0
|
|
||||||
},
|
|
||||||
"size": {
|
|
||||||
"width": 128.0,
|
|
||||||
"height": 86,
|
|
||||||
"depth": 0
|
|
||||||
},
|
|
||||||
"content_type": [
|
|
||||||
"plate",
|
|
||||||
"tip_rack",
|
|
||||||
"plates",
|
|
||||||
"tip_racks",
|
|
||||||
"tube_rack",
|
|
||||||
"adaptor",
|
|
||||||
"plateadapter",
|
|
||||||
"module",
|
|
||||||
"trash"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
"data": {}
|
"data": {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -217,6 +217,68 @@ def _tip_volume_hint(item: Dict[str, Any], labware_id: str) -> Optional[float]:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _flatten_transfer_vols(value: Any) -> List[float]:
|
||||||
|
"""将 asp_vols/dis_vols 标量或列表展平为 float 列表,无法转换的项跳过。"""
|
||||||
|
if value is None:
|
||||||
|
return []
|
||||||
|
if isinstance(value, (list, tuple)):
|
||||||
|
out: List[float] = []
|
||||||
|
for v in value:
|
||||||
|
try:
|
||||||
|
out.append(float(v))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
return out
|
||||||
|
try:
|
||||||
|
return [float(value)]
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def _tip_prcxi_class_for_max_ul(max_ul: float) -> str:
|
||||||
|
"""按移液最大体积分档推介 PRCXI tip 类名:≤10 µL → 10µL;<300 → 300µL;否则 1000µL。"""
|
||||||
|
if max_ul <= 10:
|
||||||
|
return "PRCXI_10uL_Tips"
|
||||||
|
if max_ul < 300:
|
||||||
|
return "PRCXI_300ul_Tips"
|
||||||
|
return "PRCXI_1000uL_Tips"
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_tip_rack_class_from_transfer_volumes(
|
||||||
|
labware_info: Dict[str, Dict[str, Any]],
|
||||||
|
protocol_steps_refactored: List[Dict[str, Any]],
|
||||||
|
) -> None:
|
||||||
|
"""根据各 ``transfer_liquid`` 的 asp_vols/dis_vols 为对应 ``tip_racks`` 写入 ``prcxi_class_name``。"""
|
||||||
|
tip_to_max_ul: Dict[str, float] = {}
|
||||||
|
|
||||||
|
for step in protocol_steps_refactored:
|
||||||
|
if step.get("template_name") != "transfer_liquid":
|
||||||
|
continue
|
||||||
|
p = step.get("param") or {}
|
||||||
|
tip_key_raw = p.get("tip_racks")
|
||||||
|
if tip_key_raw is None or str(tip_key_raw).strip() == "":
|
||||||
|
continue
|
||||||
|
tip_key = str(tip_key_raw).strip()
|
||||||
|
if tip_key not in labware_info:
|
||||||
|
continue
|
||||||
|
|
||||||
|
nums = _flatten_transfer_vols(p.get("asp_vols", p.get("asp_vol"))) + _flatten_transfer_vols(
|
||||||
|
p.get("dis_vols", p.get("dis_vol"))
|
||||||
|
)
|
||||||
|
if not nums:
|
||||||
|
continue
|
||||||
|
step_max = max(nums)
|
||||||
|
tip_to_max_ul[tip_key] = max(tip_to_max_ul.get(tip_key, 0.0), step_max)
|
||||||
|
|
||||||
|
for tip_key, max_ul in tip_to_max_ul.items():
|
||||||
|
item = labware_info.get(tip_key)
|
||||||
|
if item is None:
|
||||||
|
continue
|
||||||
|
if _infer_reagent_kind(tip_key, item) != "tip_rack":
|
||||||
|
continue
|
||||||
|
item["prcxi_class_name"] = _tip_prcxi_class_for_max_ul(max_ul)
|
||||||
|
|
||||||
|
|
||||||
def _volume_template_covers_requirement(template: Dict[str, Any], req: Optional[float], kind: str) -> bool:
|
def _volume_template_covers_requirement(template: Dict[str, Any], req: Optional[float], kind: str) -> bool:
|
||||||
"""有明确需求体积时,模板标称 Volume 必须 >= 需求;无 Volume 的模板不参与(trash 除外)。"""
|
"""有明确需求体积时,模板标称 Volume 必须 >= 需求;无 Volume 的模板不参与(trash 除外)。"""
|
||||||
if kind == "trash":
|
if kind == "trash":
|
||||||
@@ -624,7 +686,7 @@ def build_protocol_graph(
|
|||||||
workstation_name: str,
|
workstation_name: str,
|
||||||
action_resource_mapping: Optional[Dict[str, str]] = None,
|
action_resource_mapping: Optional[Dict[str, str]] = None,
|
||||||
labware_defs: Optional[List[Dict[str, Any]]] = None,
|
labware_defs: Optional[List[Dict[str, Any]]] = None,
|
||||||
preserve_tip_rack_incoming_class: bool = True,
|
preserve_tip_rack_incoming_class: bool = False,
|
||||||
) -> WorkflowGraph:
|
) -> WorkflowGraph:
|
||||||
"""统一的协议图构建函数,根据设备类型自动选择构建逻辑
|
"""统一的协议图构建函数,根据设备类型自动选择构建逻辑
|
||||||
|
|
||||||
@@ -636,11 +698,18 @@ def build_protocol_graph(
|
|||||||
labware_defs: 可选,``[{"id": "...", "num_wells": 96, "max_volume": 2200}, ...]`` 等,辅助 PRCXI 模板匹配
|
labware_defs: 可选,``[{"id": "...", "num_wells": 96, "max_volume": 2200}, ...]`` 等,辅助 PRCXI 模板匹配
|
||||||
preserve_tip_rack_incoming_class: 默认 True 时**仅 tip_rack** 不跑模板匹配(类名由传入的 class/labware 决定);
|
preserve_tip_rack_incoming_class: 默认 True 时**仅 tip_rack** 不跑模板匹配(类名由传入的 class/labware 决定);
|
||||||
**其它载体**仍按 PRCXI 模板匹配。False 时 **全部**(含 tip_rack)都走模板匹配。
|
**其它载体**仍按 PRCXI 模板匹配。False 时 **全部**(含 tip_rack)都走模板匹配。
|
||||||
|
|
||||||
|
会先 ``refactor_data`` 规范化步骤,再根据 ``transfer_liquid`` 的 ``asp_vols``/``dis_vols`` 为对应
|
||||||
|
``tip_racks`` 写入 ``prcxi_class_name``(最大体积 ``≤10`` → ``PRCXI_10uL_Tips``,``<300`` → ``PRCXI_300ul_Tips``,
|
||||||
|
否则 ``PRCXI_1000uL_Tips``);无有效体积的步骤不覆盖。
|
||||||
"""
|
"""
|
||||||
G = WorkflowGraph()
|
G = WorkflowGraph()
|
||||||
resource_last_writer = {} # reagent_name -> "node_id:port"
|
resource_last_writer = {} # reagent_name -> "node_id:port"
|
||||||
slot_to_create_resource = {} # slot -> create_resource node_id
|
slot_to_create_resource = {} # slot -> create_resource node_id
|
||||||
|
|
||||||
|
protocol_steps = refactor_data(protocol_steps, action_resource_mapping)
|
||||||
|
_apply_tip_rack_class_from_transfer_volumes(labware_info, protocol_steps)
|
||||||
|
|
||||||
_apply_prcxi_labware_auto_match(
|
_apply_prcxi_labware_auto_match(
|
||||||
labware_info,
|
labware_info,
|
||||||
labware_defs,
|
labware_defs,
|
||||||
@@ -651,8 +720,6 @@ def build_protocol_graph(
|
|||||||
preserve_tip_rack_incoming_class=preserve_tip_rack_incoming_class,
|
preserve_tip_rack_incoming_class=preserve_tip_rack_incoming_class,
|
||||||
)
|
)
|
||||||
|
|
||||||
protocol_steps = refactor_data(protocol_steps, action_resource_mapping)
|
|
||||||
|
|
||||||
# ==================== 第一步:按 slot 去重创建 create_resource 节点 ====================
|
# ==================== 第一步:按 slot 去重创建 create_resource 节点 ====================
|
||||||
# 按槽聚合:同一 slot 多条 reagent 时不能只取遍历顺序第一条,否则 tip 的 prcxi_class_name / object 会被其它条目盖住
|
# 按槽聚合:同一 slot 多条 reagent 时不能只取遍历顺序第一条,否则 tip 的 prcxi_class_name / object 会被其它条目盖住
|
||||||
by_slot: Dict[str, List[Tuple[str, Dict[str, Any]]]] = {}
|
by_slot: Dict[str, List[Tuple[str, Dict[str, Any]]]] = {}
|
||||||
|
|||||||
@@ -210,7 +210,7 @@ def convert_from_json(
|
|||||||
data: Union[str, PathLike, Dict[str, Any]],
|
data: Union[str, PathLike, Dict[str, Any]],
|
||||||
workstation_name: str = DEFAULT_WORKSTATION,
|
workstation_name: str = DEFAULT_WORKSTATION,
|
||||||
validate: bool = True,
|
validate: bool = True,
|
||||||
preserve_tip_rack_incoming_class: bool = True,
|
preserve_tip_rack_incoming_class: bool = False,
|
||||||
) -> WorkflowGraph:
|
) -> WorkflowGraph:
|
||||||
"""
|
"""
|
||||||
从 JSON 数据或文件转换为 WorkflowGraph
|
从 JSON 数据或文件转换为 WorkflowGraph
|
||||||
@@ -295,7 +295,7 @@ def convert_from_json(
|
|||||||
def convert_json_to_node_link(
|
def convert_json_to_node_link(
|
||||||
data: Union[str, PathLike, Dict[str, Any]],
|
data: Union[str, PathLike, Dict[str, Any]],
|
||||||
workstation_name: str = DEFAULT_WORKSTATION,
|
workstation_name: str = DEFAULT_WORKSTATION,
|
||||||
preserve_tip_rack_incoming_class: bool = True,
|
preserve_tip_rack_incoming_class: bool = False,
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
将 JSON 数据转换为 node-link 格式的字典
|
将 JSON 数据转换为 node-link 格式的字典
|
||||||
|
|||||||
@@ -234,7 +234,7 @@ def convert_from_json(
|
|||||||
data: Union[str, PathLike, Dict[str, Any]],
|
data: Union[str, PathLike, Dict[str, Any]],
|
||||||
workstation_name: str = "PRCXi",
|
workstation_name: str = "PRCXi",
|
||||||
validate: bool = True,
|
validate: bool = True,
|
||||||
preserve_tip_rack_incoming_class: bool = True,
|
preserve_tip_rack_incoming_class: bool = False,
|
||||||
) -> WorkflowGraph:
|
) -> WorkflowGraph:
|
||||||
"""
|
"""
|
||||||
从 JSON 数据或文件转换为 WorkflowGraph
|
从 JSON 数据或文件转换为 WorkflowGraph
|
||||||
|
|||||||
Reference in New Issue
Block a user