diff --git a/CLAUDE.md b/CLAUDE.md index 16fa5732..a251c9af 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -24,10 +24,15 @@ unilab --skip_env_check # skip auto-install of dependencies unilab --visual rviz|web|disable # visualization mode unilab --is_slave # run as slave node unilab --restart_mode # auto-restart on config changes (supervisor/child process) +unilab --external_devices_only # only load external device packages +unilab --extra_resource # load extra lab_ prefixed labware resources # Workflow upload subcommand unilab workflow_upload -f -n --tags tag1 tag2 +# Labware Manager (standalone web UI for PRCXI labware CRUD, port 8010) +python -m unilabos.labware_manager + # Tests pytest tests/ # all tests pytest tests/resources/test_resourcetreeset.py # single test file @@ -35,6 +40,12 @@ pytest tests/resources/test_resourcetreeset.py::TestClassName::test_method # si # CI check (matches .github/workflows/ci-check.yml) python -m unilabos --check_mode --skip_env_check + +# If registry YAML/Python files changed, regenerate before committing: +python -m unilabos --complete_registry + +# Documentation build +cd docs && python -m sphinx -b html . _build/html -v ``` ## Architecture @@ -45,7 +56,22 @@ python -m unilabos --check_mode --skip_env_check ### 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`. @@ -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. +**Labware Manager** (`unilabos/labware_manager/`): Standalone FastAPI web app (port 8010) for PRCXI labware CRUD. Pydantic models in `models.py`, JSON database in `labware_db.json`. Supports importing from existing Python/YAML (`importer.py`), code generation (`codegen.py`), and YAML generation (`yaml_gen.py`). Web UI with SVG visualization (`static/labware_viz.js`), dynamic form handling (`static/form_handler.js`), and Jinja2 templates. + ### Configuration System - **Config classes** in `unilabos/config/config.py`: `BasicConfig`, `WSConfig`, `HTTPConfig`, `ROSConfig` — class-level attributes, loaded from Python `.py` config files (see `config/example_config.py`) @@ -88,6 +116,7 @@ Example device graphs and experiment configs are in `unilabos/test/experiments/` - CLI argument dashes auto-converted to underscores for consistency - No linter/formatter configuration enforced (no ruff, black, flake8, mypy configs) - Documentation built with Sphinx (Chinese language, `sphinx_rtd_theme`, `myst_parser`) +- CI runs on Windows (`windows-latest`); if registry files change, run `python -m unilabos --complete_registry` locally before committing ## Licensing diff --git a/unilabos/labware_manager/__init__.py b/unilabos/labware_manager/__init__.py new file mode 100644 index 00000000..5ac68ca7 --- /dev/null +++ b/unilabos/labware_manager/__init__.py @@ -0,0 +1 @@ +# PRCXI 耗材管理 Web 应用 diff --git a/unilabos/labware_manager/__main__.py b/unilabos/labware_manager/__main__.py new file mode 100644 index 00000000..85274fbb --- /dev/null +++ b/unilabos/labware_manager/__main__.py @@ -0,0 +1,4 @@ +"""启动入口: python -m unilabos.labware_manager""" +from unilabos.labware_manager.app import main + +main() diff --git a/unilabos/labware_manager/app.py b/unilabos/labware_manager/app.py new file mode 100644 index 00000000..a40c1897 --- /dev/null +++ b/unilabos/labware_manager/app.py @@ -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() diff --git a/unilabos/labware_manager/codegen.py b/unilabos/labware_manager/codegen.py new file mode 100644 index 00000000..dd8c1552 --- /dev/null +++ b/unilabos/labware_manager/codegen.py @@ -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)} 个工厂函数)") diff --git a/unilabos/labware_manager/importer.py b/unilabos/labware_manager/importer.py new file mode 100644 index 00000000..aa3b0874 --- /dev/null +++ b/unilabos/labware_manager/importer.py @@ -0,0 +1,469 @@ +"""从现有 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, + ) + + 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}") diff --git a/unilabos/labware_manager/labware_db.json b/unilabos/labware_manager/labware_db.json new file mode 100644 index 00000000..4856cda0 --- /dev/null +++ b/unilabos/labware_manager/labware_db.json @@ -0,0 +1,1261 @@ +{ + "version": "1.0", + "items": [ + { + "id": "bioer_96", + "type": "plate", + "function_name": "PRCXI_BioER_96_wellplate", + "docstring": "对应 JSON Code: ZX-019-2.2 (2.2ml 深孔板)\n原型: pylabrobot.resources.bioer.BioER_96_wellplate_Vb_2200uL", + "size_x": 127.1, + "size_y": 85.0, + "size_z": 44.2, + "model": "PRCXI_BioER_96_wellplate", + "category": "plate", + "plate_type": null, + "material_info": { + "uuid": "ca877b8b114a4310b429d1de4aae96ee", + "Code": "ZX-019-2.2", + "Name": "2.2ml 深孔板", + "materialEnum": 0, + "SupplyType": 1 + }, + "registry_category": [ + "prcxi", + "plates" + ], + "registry_description": "2.2ml 深孔板 (Code: ZX-019-2.2)", + "grid": { + "num_items_x": 12, + "num_items_y": 8, + "dx": 9.5, + "dy": 7.5, + "dz": 6.0, + "item_dx": 9.0, + "item_dy": 9.0 + }, + "well": { + "size_x": 8.25, + "size_y": 8.25, + "size_z": 39.3, + "max_volume": 2200.0, + "bottom_type": "V", + "cross_section_type": "RECTANGLE", + "material_z_thickness": 0.8 + }, + "volume_functions": null, + "tip": null, + "tube": null, + "adapter": null, + "include_in_template_matching": true, + "template_kind": "plate" + }, + { + "id": "nest_1_t", + "type": "plate", + "function_name": "PRCXI_nest_1_troughplate", + "docstring": "对应 JSON Code: ZX-58-10000 (储液槽)\n原型: pylabrobot.resources.nest.nest_1_troughplate_195000uL_Vb", + "size_x": 127.76, + "size_y": 85.48, + "size_z": 31.4, + "model": "PRCXI_Nest_1_troughplate", + "category": "plate", + "plate_type": null, + "material_info": { + "uuid": "04211a2dc93547fe9bf6121eac533650", + "Code": "ZX-58-10000", + "Name": "储液槽", + "materialEnum": 0, + "SupplyType": 1 + }, + "registry_category": [ + "prcxi", + "plates" + ], + "registry_description": "储液槽 (Code: ZX-58-10000)", + "grid": { + "num_items_x": 1, + "num_items_y": 1, + "dx": 9.88, + "dy": 6.74, + "dz": 3.55, + "item_dx": 9.0, + "item_dy": 9.0 + }, + "well": { + "size_x": 108.0, + "size_y": 72.0, + "size_z": 26.85, + "max_volume": 208785.6, + "bottom_type": "V", + "cross_section_type": "CIRCLE", + "material_z_thickness": 0.9999999999999973 + }, + "volume_functions": { + "type": "rectangle", + "well_length": 108.0, + "well_width": 72.0 + }, + "tip": null, + "tube": null, + "adapter": null, + "include_in_template_matching": true, + "template_kind": "plate" + }, + { + "id": "biorad_3", + "type": "plate", + "function_name": "PRCXI_BioRad_384_wellplate", + "docstring": "对应 JSON Code: q3 (384板)\n原型: pylabrobot.resources.biorad.BioRad_384_wellplate_50uL_Vb", + "size_x": 127.76, + "size_y": 85.48, + "size_z": 10.4, + "model": "BioRad_384_wellplate_50uL_Vb", + "category": "plate", + "plate_type": null, + "material_info": { + "uuid": "853dcfb6226f476e8b23c250217dc7da", + "Code": "q3", + "Name": "384板", + "materialEnum": null, + "SupplyType": 1 + }, + "registry_category": [ + "prcxi", + "plates" + ], + "registry_description": "384板 (Code: q3)", + "grid": { + "num_items_x": 24, + "num_items_y": 16, + "dx": 10.58, + "dy": 7.44, + "dz": 1.05, + "item_dx": 4.5, + "item_dy": 4.5 + }, + "well": { + "size_x": 3.1, + "size_y": 3.1, + "size_z": 9.35, + "max_volume": 50.0, + "bottom_type": "V", + "cross_section_type": "CIRCLE", + "material_z_thickness": 1.0 + }, + "volume_functions": null, + "tip": null, + "tube": null, + "adapter": null, + "include_in_template_matching": true, + "template_kind": "plate" + }, + { + "id": "agenbio_", + "type": "plate", + "function_name": "PRCXI_AGenBio_4_troughplate", + "docstring": "对应 JSON Code: sdfrth654 (4道储液槽)\n原型: pylabrobot.resources.agenbio.AGenBio_4_troughplate_75000uL_Vb", + "size_x": 127.76, + "size_y": 85.48, + "size_z": 43.8, + "model": "PRCXI_AGenBio_4_troughplate", + "category": "plate", + "plate_type": null, + "material_info": { + "uuid": "01953864f6f140ccaa8ddffd4f3e46f5", + "Code": "sdfrth654", + "Name": "4道储液槽", + "materialEnum": 0, + "SupplyType": 1 + }, + "registry_category": [ + "prcxi", + "plates" + ], + "registry_description": "4道储液槽 (Code: sdfrth654)", + "grid": { + "num_items_x": 4, + "num_items_y": 1, + "dx": 9.8, + "dy": 7.2, + "dz": 0.9, + "item_dx": 27.1, + "item_dy": 9.0 + }, + "well": { + "size_x": 26.0, + "size_y": 71.2, + "size_z": 42.55, + "max_volume": 79071.516, + "bottom_type": "FLAT", + "cross_section_type": "RECTANGLE", + "material_z_thickness": 1.0 + }, + "volume_functions": { + "type": "rectangle", + "well_length": 26.0, + "well_width": 71.2 + }, + "tip": null, + "tube": null, + "adapter": null, + "include_in_template_matching": true, + "template_kind": "plate" + }, + { + "id": "nest_12_", + "type": "plate", + "function_name": "PRCXI_nest_12_troughplate", + "docstring": "对应 JSON Code: 12道储液槽 (12道储液槽)\n原型: pylabrobot.resources.nest.nest_12_troughplate_15000uL_Vb", + "size_x": 127.76, + "size_y": 85.48, + "size_z": 31.4, + "model": "PRCXI_nest_12_troughplate", + "category": "plate", + "plate_type": null, + "material_info": { + "uuid": "0f1639987b154e1fac78f4fb29a1f7c1", + "Code": "12道储液槽", + "Name": "12道储液槽", + "materialEnum": 0, + "SupplyType": 1 + }, + "registry_category": [ + "prcxi", + "plates" + ], + "registry_description": "12道储液槽 (Code: 12道储液槽)", + "grid": { + "num_items_x": 12, + "num_items_y": 1, + "dx": 10.28, + "dy": 7.14, + "dz": 3.55, + "item_dx": 9.0, + "item_dy": 9.0 + }, + "well": { + "size_x": 8.2, + "size_y": 71.2, + "size_z": 26.85, + "max_volume": 15676.104, + "bottom_type": "V", + "cross_section_type": "CIRCLE", + "material_z_thickness": 0.9999999999999973 + }, + "volume_functions": { + "type": "rectangle", + "well_length": 8.2, + "well_width": 71.2 + }, + "tip": null, + "tube": null, + "adapter": null, + "include_in_template_matching": true, + "template_kind": "plate" + }, + { + "id": "celltrea", + "type": "plate", + "function_name": "PRCXI_CellTreat_96_wellplate", + "docstring": "对应 JSON Code: ZX-78-096 (细菌培养皿)\n原型: pylabrobot.resources.celltreat.CellTreat_96_wellplate_350ul_Fb", + "size_x": 127.61, + "size_y": 85.24, + "size_z": 14.3, + "model": "PRCXI_CellTreat_96_wellplate", + "category": "plate", + "plate_type": null, + "material_info": { + "uuid": "b05b3b2aafd94ec38ea0cd3215ecea8f", + "Code": "ZX-78-096", + "Name": "细菌培养皿", + "materialEnum": 4, + "SupplyType": 1 + }, + "registry_category": [ + "prcxi", + "plates" + ], + "registry_description": "细菌培养皿 (Code: ZX-78-096)", + "grid": { + "num_items_x": 12, + "num_items_y": 8, + "dx": 10.83, + "dy": 7.67, + "dz": 4.05, + "item_dx": 9.0, + "item_dy": 9.0 + }, + "well": { + "size_x": 6.96, + "size_y": 6.96, + "size_z": 10.04, + "max_volume": 300.0, + "bottom_type": "FLAT", + "cross_section_type": "CIRCLE", + "material_z_thickness": 1.75 + }, + "volume_functions": null, + "tip": null, + "tube": null, + "adapter": null, + "include_in_template_matching": true, + "template_kind": "plate" + }, + { + "id": "10ul_eti", + "type": "tip_rack", + "function_name": "PRCXI_10ul_eTips", + "docstring": "对应 JSON Code: ZX-001-10+", + "size_x": 122.11, + "size_y": 85.48, + "size_z": 58.23, + "model": "PRCXI_10ul_eTips", + "category": "tip_rack", + "plate_type": null, + "material_info": { + "uuid": "068b3815e36b4a72a59bae017011b29f", + "Code": "ZX-001-10+", + "Name": "10μL加长 Tip头", + "materialEnum": null, + "SupplyType": 1 + }, + "registry_category": [ + "prcxi", + "tip_racks" + ], + "registry_description": "10μL加长 Tip头 (Code: ZX-001-10+)", + "grid": { + "num_items_x": 12, + "num_items_y": 8, + "dx": 7.97, + "dy": 5.0, + "dz": 2.0, + "item_dx": 9.0, + "item_dy": 9.0 + }, + "well": null, + "volume_functions": null, + "tip": { + "spot_size_x": 7.0, + "spot_size_y": 7.0, + "spot_size_z": 0.0, + "tip_volume": 300.0, + "tip_length": 60.0, + "tip_fitting_depth": 51.0, + "has_filter": false + }, + "tube": null, + "adapter": null, + "include_in_template_matching": true, + "template_kind": "tip_rack" + }, + { + "id": "300ul_ti", + "type": "tip_rack", + "function_name": "PRCXI_300ul_Tips", + "docstring": "对应 JSON Code: ZX-001-300\n吸头盒通常比较特殊,需要定义 Tip 对象", + "size_x": 122.11, + "size_y": 85.48, + "size_z": 58.23, + "model": "PRCXI_300ul_Tips", + "category": "tip_rack", + "plate_type": null, + "material_info": { + "uuid": "076250742950465b9d6ea29a225dfb00", + "Code": "ZX-001-300", + "Name": "300μL Tip头", + "materialEnum": null, + "SupplyType": 1 + }, + "registry_category": [ + "prcxi", + "tip_racks" + ], + "registry_description": "300μL Tip头 (Code: ZX-001-300)", + "grid": { + "num_items_x": 12, + "num_items_y": 8, + "dx": 7.97, + "dy": 5.0, + "dz": 2.0, + "item_dx": 9.0, + "item_dy": 9.0 + }, + "well": null, + "volume_functions": null, + "tip": { + "spot_size_x": 7.0, + "spot_size_y": 7.0, + "spot_size_z": 0.0, + "tip_volume": 300.0, + "tip_length": 60.0, + "tip_fitting_depth": 51.0, + "has_filter": false + }, + "tube": null, + "adapter": null, + "include_in_template_matching": true, + "template_kind": "tip_rack" + }, + { + "id": "pcr_plat", + "type": "plate", + "function_name": "PRCXI_PCR_Plate_200uL_nonskirted", + "docstring": "对应 JSON Code: ZX-023-0.2 (0.2ml PCR 板)", + "size_x": 119.5, + "size_y": 80.0, + "size_z": 26.0, + "model": "PRCXI_PCR_Plate_200uL_nonskirted", + "category": "plate", + "plate_type": "non-skirted", + "material_info": { + "uuid": "73bb9b10bc394978b70e027bf45ce2d3", + "Code": "ZX-023-0.2", + "Name": "0.2ml PCR 板", + "materialEnum": 0, + "SupplyType": 1 + }, + "registry_category": [ + "prcxi", + "plates" + ], + "registry_description": "0.2ml PCR 板 (Code: ZX-023-0.2)", + "grid": { + "num_items_x": 12, + "num_items_y": 8, + "dx": 7.0, + "dy": 5.0, + "dz": 0.0, + "item_dx": 9.0, + "item_dy": 9.0 + }, + "well": { + "size_x": 6.0, + "size_y": 6.0, + "size_z": 15.17, + "max_volume": 428.92164499461444, + "bottom_type": "V", + "cross_section_type": "CIRCLE", + "material_z_thickness": null + }, + "volume_functions": null, + "tip": null, + "tube": null, + "adapter": null, + "include_in_template_matching": true, + "template_kind": "plate" + }, + { + "id": "pcr_plat", + "type": "plate", + "function_name": "PRCXI_PCR_Plate_200uL_semiskirted", + "docstring": "对应 JSON Code: ZX-023-0.2 (0.2ml PCR 板)", + "size_x": 126.0, + "size_y": 86.0, + "size_z": 21.2, + "model": "PRCXI_PCR_Plate_200uL_semiskirted", + "category": "plate", + "plate_type": "semi-skirted", + "material_info": { + "uuid": "73bb9b10bc394978b70e027bf45ce2d3", + "Code": "ZX-023-0.2", + "Name": "0.2ml PCR 板", + "materialEnum": 0, + "SupplyType": 1 + }, + "registry_category": [ + "prcxi", + "plates" + ], + "registry_description": "0.2ml PCR 板 (Code: ZX-023-0.2)", + "grid": { + "num_items_x": 12, + "num_items_y": 8, + "dx": 11.0, + "dy": 8.0, + "dz": 0.0, + "item_dx": 9.0, + "item_dy": 9.0 + }, + "well": { + "size_x": 6.0, + "size_y": 6.0, + "size_z": 15.17, + "max_volume": 428.92164499461444, + "bottom_type": "V", + "cross_section_type": "CIRCLE", + "material_z_thickness": null + }, + "volume_functions": null, + "tip": null, + "tube": null, + "adapter": null, + "include_in_template_matching": true, + "template_kind": "plate" + }, + { + "id": "pcr_plat", + "type": "plate", + "function_name": "PRCXI_PCR_Plate_200uL_skirted", + "docstring": "对应 JSON Code: ZX-023-0.2 (0.2ml PCR 板)", + "size_x": 127.76, + "size_y": 86.0, + "size_z": 16.1, + "model": "PRCXI_PCR_Plate_200uL_skirted", + "category": "plate", + "plate_type": "skirted", + "material_info": { + "uuid": "73bb9b10bc394978b70e027bf45ce2d3", + "Code": "ZX-023-0.2", + "Name": "0.2ml PCR 板", + "materialEnum": 0, + "SupplyType": 1 + }, + "registry_category": [ + "prcxi", + "plates" + ], + "registry_description": "0.2ml PCR 板 (Code: ZX-023-0.2)", + "grid": { + "num_items_x": 12, + "num_items_y": 8, + "dx": 11.0, + "dy": 8.49, + "dz": 0.8, + "item_dx": 9.0, + "item_dy": 9.0 + }, + "well": { + "size_x": 6.0, + "size_y": 6.0, + "size_z": 15.1, + "max_volume": 426.94244162285287, + "bottom_type": "V", + "cross_section_type": "CIRCLE", + "material_z_thickness": null + }, + "volume_functions": null, + "tip": null, + "tube": null, + "adapter": null, + "include_in_template_matching": true, + "template_kind": "plate" + }, + { + "id": "trash", + "type": "trash", + "function_name": "PRCXI_trash", + "docstring": "对应 JSON Code: q1 (废弃槽)", + "size_x": 126.59, + "size_y": 84.87, + "size_z": 89.5, + "model": "PRCXI_trash", + "category": "trash", + "plate_type": null, + "material_info": { + "uuid": "730067cf07ae43849ddf4034299030e9", + "Code": "q1", + "Name": "废弃槽", + "materialEnum": 0, + "SupplyType": 1 + }, + "registry_category": [ + "prcxi", + "trash" + ], + "registry_description": "废弃槽 (Code: q1)", + "grid": null, + "well": null, + "volume_functions": null, + "tip": null, + "tube": null, + "adapter": null, + "include_in_template_matching": true, + "template_kind": "trash" + }, + { + "id": "96_deepw", + "type": "plate", + "function_name": "PRCXI_96_DeepWell", + "docstring": "对应 JSON Code: q2 (96深孔板)", + "size_x": 127.3, + "size_y": 85.35, + "size_z": 45.0, + "model": "PRCXI_96_DeepWell", + "category": "plate", + "plate_type": null, + "material_info": { + "uuid": "57b1e4711e9e4a32b529f3132fc5931f", + "Code": "q2", + "Name": "96深孔板", + "materialEnum": 0, + "SupplyType": null + }, + "registry_category": [ + "prcxi", + "plates" + ], + "registry_description": "96深孔板 (Code: q2)", + "grid": { + "num_items_x": 12, + "num_items_y": 8, + "dx": 10.9, + "dy": 8.25, + "dz": 2.0, + "item_dx": 9.0, + "item_dy": 9.0 + }, + "well": { + "size_x": 8.2, + "size_y": 8.2, + "size_z": 42.0, + "max_volume": 2200.0, + "bottom_type": "UNKNOWN", + "cross_section_type": "CIRCLE", + "material_z_thickness": null + }, + "volume_functions": null, + "tip": null, + "tube": null, + "adapter": null, + "include_in_template_matching": true, + "template_kind": "plate" + }, + { + "id": "ep_adapt", + "type": "tube_rack", + "function_name": "PRCXI_EP_Adapter", + "docstring": "对应 JSON Code: 1 (ep适配器)\n这是一个 4x6 的 EP 管架,适配 1.5mL/2.0mL 离心管", + "size_x": 128.04, + "size_y": 85.8, + "size_z": 42.66, + "model": "PRCXI_EP_Adapter", + "category": null, + "plate_type": null, + "material_info": { + "uuid": "e146697c395e4eabb3d6b74f0dd6aaf7", + "Code": "1", + "Name": "ep适配器", + "materialEnum": 0, + "SupplyType": 1 + }, + "registry_category": [ + "prcxi", + "tube_racks" + ], + "registry_description": "ep适配器 (Code: 1)", + "grid": { + "num_items_x": 6, + "num_items_y": 4, + "dx": 3.54, + "dy": 10.7, + "dz": 4.58, + "item_dx": 21.0, + "item_dy": 18.0 + }, + "well": null, + "volume_functions": null, + "tip": null, + "tube": { + "size_x": 10.6, + "size_y": 10.6, + "size_z": 40.0, + "max_volume": 1500.0 + }, + "adapter": null, + "include_in_template_matching": true, + "template_kind": "tube_rack" + }, + { + "id": "tip1250_", + "type": "plate_adapter", + "function_name": "PRCXI_Tip1250_Adapter", + "docstring": "Code: ZX-58-1250", + "size_x": 128.0, + "size_y": 85.0, + "size_z": 20.0, + "model": null, + "category": "plate_adapter", + "plate_type": null, + "material_info": { + "uuid": "3b6f33ffbf734014bcc20e3c63e124d4", + "Code": "ZX-58-1250", + "Name": "Tip头适配器 1250uL", + "materialEnum": null, + "SupplyType": 2 + }, + "registry_category": [ + "prcxi", + "plate_adapters" + ], + "registry_description": "Tip头适配器 1250uL (Code: ZX-58-1250)", + "grid": null, + "well": null, + "volume_functions": null, + "tip": null, + "tube": null, + "adapter": { + "adapter_hole_size_x": 127.76, + "adapter_hole_size_y": 85.48, + "adapter_hole_size_z": 20.0, + "dx": 0.11999999999999744, + "dy": -0.240000000000002, + "dz": 0.0 + }, + "include_in_template_matching": false, + "template_kind": null + }, + { + "id": "tip300_a", + "type": "plate_adapter", + "function_name": "PRCXI_Tip300_Adapter", + "docstring": "Code: ZX-58-300", + "size_x": 127.0, + "size_y": 85.0, + "size_z": 81.0, + "model": null, + "category": "plate_adapter", + "plate_type": null, + "material_info": { + "uuid": "7c822592b360451fb59690e49ac6b181", + "Code": "ZX-58-300", + "Name": "ZHONGXI 适配器 300uL", + "materialEnum": null, + "SupplyType": 2 + }, + "registry_category": [ + "prcxi", + "plate_adapters" + ], + "registry_description": "ZHONGXI 适配器 300uL (Code: ZX-58-300)", + "grid": null, + "well": null, + "volume_functions": null, + "tip": null, + "tube": null, + "adapter": { + "adapter_hole_size_x": 127.76, + "adapter_hole_size_y": 85.48, + "adapter_hole_size_z": 81.0, + "dx": -0.38000000000000256, + "dy": -0.240000000000002, + "dz": 0.0 + }, + "include_in_template_matching": false, + "template_kind": null + }, + { + "id": "tip10_ad", + "type": "plate_adapter", + "function_name": "PRCXI_Tip10_Adapter", + "docstring": "Code: ZX-58-10", + "size_x": 128.0, + "size_y": 85.0, + "size_z": 72.3, + "model": null, + "category": "plate_adapter", + "plate_type": null, + "material_info": { + "uuid": "8cc3dce884ac41c09f4570d0bcbfb01c", + "Code": "ZX-58-10", + "Name": "吸头10ul 适配器", + "materialEnum": null, + "SupplyType": 2 + }, + "registry_category": [ + "prcxi", + "plate_adapters" + ], + "registry_description": "吸头10ul 适配器 (Code: ZX-58-10)", + "grid": null, + "well": null, + "volume_functions": null, + "tip": null, + "tube": null, + "adapter": { + "adapter_hole_size_x": 127.76, + "adapter_hole_size_y": 85.48, + "adapter_hole_size_z": 72.3, + "dx": 0.11999999999999744, + "dy": -0.240000000000002, + "dz": 0.0 + }, + "include_in_template_matching": false, + "template_kind": null + }, + { + "id": "1250ul_t", + "type": "tip_rack", + "function_name": "PRCXI_1250uL_Tips", + "docstring": "Code: ZX-001-1250", + "size_x": 118.09, + "size_y": 80.7, + "size_z": 107.67, + "model": "PRCXI_1250uL_Tips", + "category": "tip_rack", + "plate_type": null, + "material_info": { + "uuid": "7960f49ddfe9448abadda89bd1556936", + "Code": "ZX-001-1250", + "Name": "1250μL Tip头", + "materialEnum": null, + "SupplyType": 1 + }, + "registry_category": [ + "prcxi", + "tip_racks" + ], + "registry_description": "1250μL Tip头 (Code: ZX-001-1250)", + "grid": { + "num_items_x": 12, + "num_items_y": 8, + "dx": 5.57, + "dy": 4.875, + "dz": 2.0, + "item_dx": 9.0, + "item_dy": 9.0 + }, + "well": null, + "volume_functions": null, + "tip": { + "spot_size_x": 7.0, + "spot_size_y": 7.0, + "spot_size_z": 0.0, + "tip_volume": 300.0, + "tip_length": 60.0, + "tip_fitting_depth": 51.0, + "has_filter": false + }, + "tube": null, + "adapter": null, + "include_in_template_matching": true, + "template_kind": "tip_rack" + }, + { + "id": "10ul_tip", + "type": "tip_rack", + "function_name": "PRCXI_10uL_Tips", + "docstring": "Code: ZX-001-10", + "size_x": 120.98, + "size_y": 82.12, + "size_z": 67.0, + "model": "PRCXI_10uL_Tips", + "category": "tip_rack", + "plate_type": null, + "material_info": { + "uuid": "45f2ed3ad925484d96463d675a0ebf66", + "Code": "ZX-001-10", + "Name": "10μL Tip头", + "materialEnum": null, + "SupplyType": 1 + }, + "registry_category": [ + "prcxi", + "tip_racks" + ], + "registry_description": "10μL Tip头 (Code: ZX-001-10)", + "grid": { + "num_items_x": 12, + "num_items_y": 8, + "dx": 8.49, + "dy": 7.06, + "dz": 2.0, + "item_dx": 9.0, + "item_dy": 9.0 + }, + "well": null, + "volume_functions": null, + "tip": { + "spot_size_x": 7.0, + "spot_size_y": 7.0, + "spot_size_z": 0.0, + "tip_volume": 300.0, + "tip_length": 60.0, + "tip_fitting_depth": 51.0, + "has_filter": false + }, + "tube": null, + "adapter": null, + "include_in_template_matching": true, + "template_kind": "tip_rack" + }, + { + "id": "1000ul_t", + "type": "tip_rack", + "function_name": "PRCXI_1000uL_Tips", + "docstring": "Code: ZX-001-1000", + "size_x": 128.09, + "size_y": 85.8, + "size_z": 98.0, + "model": "PRCXI_1000uL_Tips", + "category": "tip_rack", + "plate_type": null, + "material_info": { + "uuid": "80652665f6a54402b2408d50b40398df", + "Code": "ZX-001-1000", + "Name": "1000μL Tip头", + "materialEnum": null, + "SupplyType": 1 + }, + "registry_category": [ + "prcxi", + "tip_racks" + ], + "registry_description": "1000μL Tip头 (Code: ZX-001-1000)", + "grid": { + "num_items_x": 12, + "num_items_y": 8, + "dx": 10.525, + "dy": 7.425, + "dz": 2.0, + "item_dx": 9.0, + "item_dy": 9.0 + }, + "well": null, + "volume_functions": null, + "tip": { + "spot_size_x": 7.0, + "spot_size_y": 7.0, + "spot_size_z": 0.0, + "tip_volume": 300.0, + "tip_length": 60.0, + "tip_fitting_depth": 51.0, + "has_filter": false + }, + "tube": null, + "adapter": null, + "include_in_template_matching": true, + "template_kind": "tip_rack" + }, + { + "id": "200ul_ti", + "type": "tip_rack", + "function_name": "PRCXI_200uL_Tips", + "docstring": "Code: ZX-001-200", + "size_x": 120.98, + "size_y": 82.12, + "size_z": 66.9, + "model": "PRCXI_200uL_Tips", + "category": "tip_rack", + "plate_type": null, + "material_info": { + "uuid": "7a73bb9e5c264515a8fcbe88aed0e6f7", + "Code": "ZX-001-200", + "Name": "200μL Tip头", + "materialEnum": null, + "SupplyType": 1 + }, + "registry_category": [ + "prcxi", + "tip_racks" + ], + "registry_description": "200μL Tip头 (Code: ZX-001-200)", + "grid": { + "num_items_x": 12, + "num_items_y": 8, + "dx": 8.24, + "dy": 6.81, + "dz": 2.0, + "item_dx": 9.0, + "item_dy": 9.0 + }, + "well": null, + "volume_functions": null, + "tip": { + "spot_size_x": 7.0, + "spot_size_y": 7.0, + "spot_size_z": 0.0, + "tip_volume": 300.0, + "tip_length": 60.0, + "tip_fitting_depth": 51.0, + "has_filter": false + }, + "tube": null, + "adapter": null, + "include_in_template_matching": true, + "template_kind": "tip_rack" + }, + { + "id": "pcr_adap", + "type": "plate_adapter", + "function_name": "PRCXI_PCR_Adapter", + "docstring": "对应 JSON Code: ZX-58-0001 (全裙边 PCR适配器)", + "size_x": 127.76, + "size_y": 85.48, + "size_z": 21.69, + "model": "PRCXI_PCR_Adapter", + "category": "plate_adapter", + "plate_type": null, + "material_info": { + "uuid": "4a043a07c65a4f9bb97745e1f129b165", + "Code": "ZX-58-0001", + "Name": "全裙边 PCR适配器", + "materialEnum": 3, + "SupplyType": 2 + }, + "registry_category": [ + "prcxi", + "plate_adapters" + ], + "registry_description": "全裙边 PCR适配器 (Code: ZX-58-0001)", + "grid": null, + "well": null, + "volume_functions": null, + "tip": null, + "tube": null, + "adapter": { + "adapter_hole_size_x": 127.76, + "adapter_hole_size_y": 85.48, + "adapter_hole_size_z": 21.69, + "dx": 0.0, + "dy": 0.0, + "dz": 0.0 + }, + "include_in_template_matching": false, + "template_kind": null + }, + { + "id": "reservoi", + "type": "plate_adapter", + "function_name": "PRCXI_Reservoir_Adapter", + "docstring": "Code: ZX-ADP-001", + "size_x": 133.0, + "size_y": 91.8, + "size_z": 70.0, + "model": null, + "category": "plate_adapter", + "plate_type": null, + "material_info": { + "uuid": "6bdfdd7069df453896b0806df50f2f4d", + "Code": "ZX-ADP-001", + "Name": "储液槽 适配器", + "materialEnum": null, + "SupplyType": 2 + }, + "registry_category": [ + "prcxi", + "plate_adapters" + ], + "registry_description": "储液槽 适配器 (Code: ZX-ADP-001)", + "grid": null, + "well": null, + "volume_functions": null, + "tip": null, + "tube": null, + "adapter": { + "adapter_hole_size_x": 127.76, + "adapter_hole_size_y": 85.48, + "adapter_hole_size_z": 70.0, + "dx": 2.6199999999999974, + "dy": 3.1599999999999966, + "dz": 0.0 + }, + "include_in_template_matching": false, + "template_kind": null + }, + { + "id": "deep300_", + "type": "plate_adapter", + "function_name": "PRCXI_Deep300_Adapter", + "docstring": "Code: ZX-002-300", + "size_x": 136.4, + "size_y": 93.8, + "size_z": 96.0, + "model": null, + "category": "plate_adapter", + "plate_type": null, + "material_info": { + "uuid": "9a439bed8f3344549643d6b3bc5a5eb4", + "Code": "ZX-002-300", + "Name": "300ul深孔板适配器", + "materialEnum": null, + "SupplyType": 2 + }, + "registry_category": [ + "prcxi", + "plate_adapters" + ], + "registry_description": "300ul深孔板适配器 (Code: ZX-002-300)", + "grid": null, + "well": null, + "volume_functions": null, + "tip": null, + "tube": null, + "adapter": { + "adapter_hole_size_x": 127.76, + "adapter_hole_size_y": 85.48, + "adapter_hole_size_z": 96.0, + "dx": 4.32, + "dy": 4.159999999999997, + "dz": 0.0 + }, + "include_in_template_matching": false, + "template_kind": null + }, + { + "id": "deep10_a", + "type": "plate_adapter", + "function_name": "PRCXI_Deep10_Adapter", + "docstring": "Code: ZX-002-10", + "size_x": 136.5, + "size_y": 93.8, + "size_z": 121.5, + "model": null, + "category": "plate_adapter", + "plate_type": null, + "material_info": { + "uuid": "4dc8d6ecfd0449549683b8ef815a861b", + "Code": "ZX-002-10", + "Name": "10ul专用深孔板适配器", + "materialEnum": null, + "SupplyType": 2 + }, + "registry_category": [ + "prcxi", + "plate_adapters" + ], + "registry_description": "10ul专用深孔板适配器 (Code: ZX-002-10)", + "grid": null, + "well": null, + "volume_functions": null, + "tip": null, + "tube": null, + "adapter": { + "adapter_hole_size_x": 127.76, + "adapter_hole_size_y": 85.48, + "adapter_hole_size_z": 121.5, + "dx": 4.369999999999997, + "dy": 4.159999999999997, + "dz": 0.0 + }, + "include_in_template_matching": false, + "template_kind": null + }, + { + "id": "adapter", + "type": "plate_adapter", + "function_name": "PRCXI_Adapter", + "docstring": "Code: Fhh478", + "size_x": 120.0, + "size_y": 90.0, + "size_z": 86.0, + "model": null, + "category": "plate_adapter", + "plate_type": null, + "material_info": { + "uuid": "adfabfffa8f24af5abfbba67b8d0f973", + "Code": "Fhh478", + "Name": "适配器", + "materialEnum": null, + "SupplyType": 2 + }, + "registry_category": [ + "prcxi", + "plate_adapters" + ], + "registry_description": "适配器 (Code: Fhh478)", + "grid": null, + "well": null, + "volume_functions": null, + "tip": null, + "tube": null, + "adapter": { + "adapter_hole_size_x": 127.76, + "adapter_hole_size_y": 85.48, + "adapter_hole_size_z": 86.0, + "dx": -3.8800000000000026, + "dy": 2.259999999999998, + "dz": 0.0 + }, + "include_in_template_matching": false, + "template_kind": null + }, + { + "id": "48_deepw", + "type": "plate", + "function_name": "PRCXI_48_DeepWell", + "docstring": "Code: 22 (48孔深孔板)", + "size_x": 127.0, + "size_y": 85.0, + "size_z": 44.0, + "model": "PRCXI_48_DeepWell", + "category": "plate", + "plate_type": null, + "material_info": { + "uuid": "026c5d5cf3d94e56b4e16b7fb53a995b", + "Code": "22", + "Name": "48孔深孔板", + "materialEnum": null, + "SupplyType": 1 + }, + "registry_category": [ + "prcxi", + "plates" + ], + "registry_description": "48孔深孔板 (Code: 22)", + "grid": { + "num_items_x": 6, + "num_items_y": 8, + "dx": 10.0, + "dy": 10.0, + "dz": 1.0, + "item_dx": 18.5, + "item_dy": 9.0 + }, + "well": { + "size_x": 8.0, + "size_y": 8.0, + "size_z": 40.0, + "max_volume": 2010.6192982974676, + "bottom_type": "UNKNOWN", + "cross_section_type": "CIRCLE", + "material_z_thickness": null + }, + "volume_functions": null, + "tip": null, + "tube": null, + "adapter": null, + "include_in_template_matching": true, + "template_kind": "plate" + }, + { + "id": "30mm_ada", + "type": "plate_adapter", + "function_name": "PRCXI_30mm_Adapter", + "docstring": "Code: ZX-58-30", + "size_x": 132.0, + "size_y": 93.5, + "size_z": 30.0, + "model": null, + "category": "plate_adapter", + "plate_type": null, + "material_info": { + "uuid": "a0757a90d8e44e81a68f306a608694f2", + "Code": "ZX-58-30", + "Name": "30mm适配器", + "materialEnum": null, + "SupplyType": 2 + }, + "registry_category": [ + "prcxi", + "plate_adapters" + ], + "registry_description": "30mm适配器 (Code: ZX-58-30)", + "grid": null, + "well": null, + "volume_functions": null, + "tip": null, + "tube": null, + "adapter": { + "adapter_hole_size_x": 127.76, + "adapter_hole_size_y": 85.48, + "adapter_hole_size_z": 30.0, + "dx": 2.1199999999999974, + "dy": 4.009999999999998, + "dz": 0.0 + }, + "include_in_template_matching": false, + "template_kind": null + } + ] +} \ No newline at end of file diff --git a/unilabos/labware_manager/models.py b/unilabos/labware_manager/models.py new file mode 100644 index 00000000..c206af30 --- /dev/null +++ b/unilabos/labware_manager/models.py @@ -0,0 +1,125 @@ +"""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 + 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) diff --git a/unilabos/labware_manager/static/form_handler.js b/unilabos/labware_manager/static/form_handler.js new file mode 100644 index 00000000..6fa878be --- /dev/null +++ b/unilabos/labware_manager/static/form_handler.js @@ -0,0 +1,237 @@ +/** + * 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, + 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); +}); + +// 自动居中:根据板尺寸和孔阵列参数计算 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); + } +} diff --git a/unilabos/labware_manager/static/labware_viz.js b/unilabos/labware_manager/static/labware_viz.js new file mode 100644 index 00000000..a2d0af9c --- /dev/null +++ b/unilabos/labware_manager/static/labware_viz.js @@ -0,0 +1,358 @@ +/** + * 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')); + } + } + } 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); // 让较矮的板子不会太小 + const drawW = sx; + const drawH = sz; + const w = drawW + pad * 2 + 30; // 额外空间给标注 + const h = drawH + pad * 2 + 10; + const svg = _makeSVG(w, h); + + const color = TYPE_COLORS[data.type] || '#3b82f6'; + const baseY = pad + drawH; // 底部 Y + + // 板壳矩形 + _rect(svg, pad, pad, 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, pad, pad + drawW + 5, baseY, '#333'); + const zt = document.createElementNS(_svgNS(), 'text'); + zt.setAttribute('x', pad + drawW + 12); + zt.setAttribute('y', pad + 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}, ${pad + drawH / 2})`); + zt.textContent = `${sz} mm`; + svg.appendChild(zt); + + const grid = data.grid; + const well = data.well; + const tip = data.tip; + 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 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, '#999', '1,1'); + _text(svg, lx + 6, baseY - dz / 2, `dz=${dz}`, '2.5', 'start', '#999'); + } + } + + if (childTip) { + // 枪头截面 + const nDraw = Math.min(nx, 12); + for (let i = 0; i < nDraw; i++) { + const cx = pad + dx + 3.5 + i * idx; + const topZ = pad + dz; + const tipLen = childTip.tip_length || 50; + const drawLen = Math.min(tipLen, sz - dz); + + // 枪头轮廓 (梯形) + const topW = 4; + const botW = 1.5; + const p = document.createElementNS(_svgNS(), 'polygon'); + p.setAttribute('points', + `${cx - topW / 2},${topZ} ${cx + topW / 2},${topZ} ${cx + botW / 2},${topZ + drawLen} ${cx - botW / 2},${topZ + drawLen}`); + p.setAttribute('fill', '#10b98133'); + p.setAttribute('stroke', '#10b981'); + p.setAttribute('stroke-width', '0.3'); + svg.appendChild(p); + } + } + } 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, pad + adz, ahx, ahz, '#ede9fe', '#8b5cf6'); + _text(svg, pad + adx_val + ahx / 2, pad + adz + ahz / 2 + 1, `hole: ${ahz}mm deep`, '3', 'middle', '#8b5cf6'); + } else if (data.type === 'trash') { + _text(svg, pad + drawW / 2, pad + 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 origW = vw, origH = vh; + const MIN_SCALE = 0.5, MAX_SCALE = 5; + + function applyViewBox() { + svgEl.setAttribute('viewBox', `${vx} ${vy} ${vw} ${vh}`); + } + + 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 }); +} diff --git a/unilabos/labware_manager/static/style.css b/unilabos/labware_manager/static/style.css new file mode 100644 index 00000000..e0276cd8 --- /dev/null +++ b/unilabos/labware_manager/static/style.css @@ -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; } diff --git a/unilabos/labware_manager/templates/base.html b/unilabos/labware_manager/templates/base.html new file mode 100644 index 00000000..1bbba02a --- /dev/null +++ b/unilabos/labware_manager/templates/base.html @@ -0,0 +1,24 @@ + + + + + + {% block title %}PRCXI 耗材管理{% endblock %} + + {% block head_extra %}{% endblock %} + + + + +
+ {% block content %}{% endblock %} +
+ + {% block scripts %}{% endblock %} + + diff --git a/unilabos/labware_manager/templates/detail.html b/unilabos/labware_manager/templates/detail.html new file mode 100644 index 00000000..9219fc96 --- /dev/null +++ b/unilabos/labware_manager/templates/detail.html @@ -0,0 +1,150 @@ +{% extends "base.html" %} +{% block title %}{{ item.function_name }} - PRCXI{% endblock %} + +{% block content %} + + +
+ +
+
+

基本信息

+ + + + + {% if item.plate_type %} + + {% endif %} + +
类型{{ item.type }}
函数名{{ item.function_name }}
Model{{ item.model or '-' }}
Plate Type{{ item.plate_type }}
Docstring{{ item.docstring or '-' }}
+
+ +
+

物理尺寸 (mm)

+ + + + +
X{{ item.size_x }}
Y{{ item.size_y }}
Z{{ item.size_z }}
+
+ +
+

材料信息

+ + + + + {% if item.material_info.materialEnum is not none %} + + {% endif %} + {% if item.material_info.SupplyType is not none %} + + {% endif %} +
UUID{{ item.material_info.uuid }}
Code{{ item.material_info.Code }}
Name{{ item.material_info.Name }}
materialEnum{{ item.material_info.materialEnum }}
SupplyType{{ item.material_info.SupplyType }}
+
+ + {% if item.grid %} +
+

网格排列

+ + + + +
列 x 行{{ item.grid.num_items_x }} x {{ item.grid.num_items_y }}
dx, dy, dz{{ item.grid.dx }}, {{ item.grid.dy }}, {{ item.grid.dz }}
item_dx, item_dy{{ item.grid.item_dx }}, {{ item.grid.item_dy }}
+
+ {% endif %} + + {% if item.well %} +
+

孔参数 (Well)

+ + + {% if item.well.max_volume is not none %} + + {% endif %} + + + {% if item.well.material_z_thickness is not none %} + + {% endif %} +
尺寸{{ item.well.size_x }} x {{ item.well.size_y }} x {{ item.well.size_z }}
最大体积{{ item.well.max_volume }} uL
底部类型{{ item.well.bottom_type }}
截面类型{{ item.well.cross_section_type }}
材料Z厚度{{ item.well.material_z_thickness }}
+
+ {% endif %} + + {% if item.tip %} +
+

枪头参数 (Tip)

+ + + + + + +
Spot 尺寸{{ item.tip.spot_size_x }} x {{ item.tip.spot_size_y }} x {{ item.tip.spot_size_z }}
容量{{ item.tip.tip_volume }} uL
长度{{ item.tip.tip_length }} mm
配合深度{{ item.tip.tip_fitting_depth }} mm
有滤芯{{ item.tip.has_filter }}
+
+ {% endif %} + + {% if item.tube %} +
+

管参数 (Tube)

+ + + +
尺寸{{ item.tube.size_x }} x {{ item.tube.size_y }} x {{ item.tube.size_z }}
最大体积{{ item.tube.max_volume }} uL
+
+ {% endif %} + + {% if item.adapter %} +
+

适配器参数

+ + + +
Hole 尺寸{{ item.adapter.adapter_hole_size_x }} x {{ item.adapter.adapter_hole_size_y }} x {{ item.adapter.adapter_hole_size_z }}
dx, dy, dz{{ item.adapter.dx }}, {{ item.adapter.dy }}, {{ item.adapter.dz }}
+
+ {% endif %} + +
+

Registry

+ + + + + {% if item.template_kind %} + + {% endif %} +
分类{{ item.registry_category | join(' / ') }}
描述{{ item.registry_description }}
模板匹配{{ item.include_in_template_matching }}
模板类型{{ item.template_kind }}
+
+
+ + +
+
+

俯视图 (Top-Down)

+
+
+
+

侧面截面图 (Side Profile)

+
+
+
+
+{% endblock %} + +{% block scripts %} + + +{% endblock %} diff --git a/unilabos/labware_manager/templates/edit.html b/unilabos/labware_manager/templates/edit.html new file mode 100644 index 00000000..5d5eb2c1 --- /dev/null +++ b/unilabos/labware_manager/templates/edit.html @@ -0,0 +1,244 @@ +{% extends "base.html" %} +{% block title %}{% if is_new %}新建耗材{% else %}编辑 {{ item.function_name }}{% endif %} - PRCXI{% endblock %} + +{% block content %} + + + + +
+ +
+
+ +
+

基本信息

+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+ + +
+

物理尺寸 Physical Dimensions (mm)

+
+
+
+
+
+
+ + +
+

材料信息

+
+ + +
+
+
+
+
+
+
+
+
+
+ + + + + + + + + + + + + + + + + +
+

Registry / Template

+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ +
+ + 取消 +
+
+
+ + +
+
+

预览: 俯视图

+
+
+
+

预览: 侧面截面图

+
+
+
+
+{% endblock %} + +{% block scripts %} + + + +{% endblock %} diff --git a/unilabos/labware_manager/templates/index.html b/unilabos/labware_manager/templates/index.html new file mode 100644 index 00000000..7374cdde --- /dev/null +++ b/unilabos/labware_manager/templates/index.html @@ -0,0 +1,131 @@ +{% extends "base.html" %} +{% block title %}耗材列表 - PRCXI{% endblock %} + +{% block content %} + + + + +{% 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 %} +
+

+ + {{ type_labels[type_key] }} + {{ groups[type_key]|length }} +

+
+ {% for item in groups[type_key] %} +
+
+ {{ item.function_name }} + {% if item.include_in_template_matching %} + TPL + {% endif %} +
+
+
+ Code: {{ item.material_info.Code or '-' }} +
+
+ 名称: {{ item.material_info.Name or '-' }} +
+
+ 尺寸: + {{ item.size_x }} x {{ item.size_y }} x {{ item.size_z }} mm +
+ {% if item.grid %} +
+ 网格: + {{ item.grid.num_items_x }} x {{ item.grid.num_items_y }} +
+ {% endif %} +
+ +
+ {% endfor %} +
+
+{% endif %} +{% endfor %} +{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/unilabos/labware_manager/yaml_gen.py b/unilabos/labware_manager/yaml_gen.py new file mode 100644 index 00000000..e5e41d55 --- /dev/null +++ b/unilabos/labware_manager/yaml_gen.py @@ -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}")