Files
Uni-Lab-OS/unilabos/devices/liquid_handling/prcxi/abstract_protocol.py
Xuwznln 9aeffebde1 0.10.7 Update (#101)
* Cleanup registry to be easy-understanding (#76)

* delete deprecated mock devices

* rename categories

* combine chromatographic devices

* rename rviz simulation nodes

* organic virtual devices

* parse vessel_id

* run registry completion before merge

---------

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

* fix: workstation handlers and vessel_id parsing

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

* modify default discovery_interval to 15s

* feat: add trace log level

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

* fix: drop_tips not using auto resource select

* fix: discard_tips error

* fix: discard_tips

* fix: prcxi_res

* add: prcxi res
fix: startup slow

* feat: workstation example

* fix pumps and liquid_handler handle

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

* fix all protocol_compilers and remove deprecated devices

* feat: 新增use_remote_resource参数

* fix and remove redundant info

* bugfixes on organic protocols

* fix filter protocol

* fix protocol node

* 临时兼容错误的driver写法

* fix: prcxi import error

* use call_async in all service to avoid deadlock

* fix: figure_resource

* Update recipe.yaml

* add workstation template and battery example

* feat: add sk & ak

* update workstation base

* Create workstation_architecture.md

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

* refactor: ProtocolNode→WorkstationNode

* Add:msgs.action (#83)

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

* Add:msgs.action

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

* simplify resource system

* uncompleted refactor

* example for use WorkstationBase

* feat: websocket

* feat: websocket test

* feat: workstation example

* feat: action status

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

* fix: 还原protocol node处理方法

* fix: build

* fix: missing job_id key

* ws test version 1

* ws test version 2

* ws protocol

* 增加物料关系上传日志

* 增加物料关系上传日志

* 修正物料关系上传

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

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

* 修复event loop错误

* 修复edge上报错误

* 修复async错误

* 更新schema的title字段

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

* 注册表编辑器

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

* 增加addr参数

* fix: addr param

* fix: addr param

* 取消labid 和 强制config输入

* Add action definitions for LiquidHandlerSetGroup and LiquidHandlerTransferGroup

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

* Add LiquidHandlerSetGroup and LiquidHandlerTransferGroup actions to CMakeLists

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

* result_info改为字典类型

* 新增uat的地址替换

* runze multiple pump support

(cherry picked from commit 49354fcf39)

* remove runze multiple software obtainer

(cherry picked from commit 8bcc92a394)

* support multiple backbone

(cherry picked from commit 4771ff2347)

* Update runze pump format

* Correct runze multiple backbone

* Update runze_multiple_backbone

* Correct runze pump multiple receive method.

* Correct runze pump multiple receive method.

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

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

* fix import error

* fix dupe upload registry

* refactor ws client

* add server timeout

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

* fix run_column

* Update run_column_protocol.py

(cherry picked from commit e5aa4d940a)

* resource_update use resource_add

* 新增版位推荐功能

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

* update registry with nested obj

* fix protocol node log_message, added create_resource return value

* fix protocol node log_message, added create_resource return value

* try fix add protocol

* fix resource_add

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

* Feature/xprbalance-zhida (#80)

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

* feat(devices): add mettler_toledo xpr balance

* balance

* 重新补全zhida注册表

* PRCXI9320 json

* PRCXI9320 json

* PRCXI9320 json

* fix resource download

* remove class for resource

* bump version to 0.10.6

* 更新所有注册表

* 修复protocolnode的兼容性

* 修复protocolnode的兼容性

* Update install md

* Add Defaultlayout

* 更新物料接口

* fix dict to tree/nested-dict converter

* coin_cell_station draft

* refactor: rename "station_resource" to "deck"

* add standardized BIOYOND resources: bottle_carrier, bottle

* refactor and add BIOYOND resources tests

* add BIOYOND deck assignment and pass all tests

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

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

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

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

* add bioyond studio draft

* bioyond station with communication init and resource sync

* fix bioyond station and registry

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

* frontend_docs

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

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

* refactor: add itemized_carrier instead of carrier consists of ResourceHolder

* create warehouse by factory func

* update bioyond launch json

* add child_size for itemized_carrier

* fix bioyond resource io

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

* coin_cell_station draft

* refactor: rename "station_resource" to "deck"

* add standardized BIOYOND resources: bottle_carrier, bottle

* refactor and add BIOYOND resources tests

* add BIOYOND deck assignment and pass all tests

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

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

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

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

* add bioyond studio draft

* bioyond station with communication init and resource sync

* fix bioyond station and registry

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

* refactor: add itemized_carrier instead of carrier consists of ResourceHolder

* create warehouse by factory func

* update bioyond launch json

* add child_size for itemized_carrier

* fix bioyond resource io

---------

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

* 更新物料接口

* Workstation dev yb2 (#100)

* Refactor and extend reaction station action messages

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

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

* 修复to_plr_resources

* add update remove

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

* 修复资源添加

* 修复transfer_resource_to_another生成

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

* 新增test_resource动作

* fix host_node error

* fix host_node test_resource error

* fix host_node test_resource error

* 过滤本地动作

* 移动内部action以兼容host node

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

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

* update todo

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

* pass the tests

* update todo

* add conda-pack-build.yml

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

(cherry picked from commit 172599adcf)

* update conda-pack-build.yml

* update conda-pack-build.yml

* update conda-pack-build.yml

* update conda-pack-build.yml

* update conda-pack-build.yml

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

* Update conda-pack-build.yml

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

* Update conda-pack-build.yml

* Fix FileNotFoundError

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

* Fix unilabos msgs search error

* Fix environment_check.py

* Update recipe.yaml

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

* Fix nested conda pack

* Fix one-key installation path error

* Bump version to 0.10.7

* Workshop bj (#99)

* Add LaiYu Liquid device integration and tests

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

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

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

* add

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

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

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

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

* 修复大小写文件夹名字

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

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

* Update intro.md

* 物料教程

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

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

* Update prcxi driver & fix transfer_liquid mix_times

* fix: correct mix_times type

* Update liquid_handler registry

* test: prcxi.py

* Update registry from pr

* fix ony-key script not exist

* clean files

---------

Co-authored-by: Junhan Chang <changjh@dp.tech>
Co-authored-by: ZiWei <131428629+ZiWei09@users.noreply.github.com>
Co-authored-by: Guangxin Zhang <guangxin.zhang.bio@gmail.com>
Co-authored-by: Xie Qiming <97236197+Andy6M@users.noreply.github.com>
Co-authored-by: h840473807 <47357934+h840473807@users.noreply.github.com>
Co-authored-by: LccLink <1951855008@qq.com>
Co-authored-by: lixinyu1011 <61094742+lixinyu1011@users.noreply.github.com>
Co-authored-by: shiyubo0410 <shiyubo@dp.tech>
2025-10-12 23:34:26 +08:00

622 lines
25 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import asyncio
import collections
import contextlib
import json
import socket
import time
from typing import Any, List, Dict, Optional, TypedDict, Union, Sequence, Iterator, Literal
import pprint as pp
from pylabrobot.liquid_handling import (
LiquidHandlerBackend,
Pickup,
SingleChannelAspiration,
Drop,
SingleChannelDispense,
PickupTipRack,
DropTipRack,
MultiHeadAspirationPlate, ChatterBoxBackend, LiquidHandlerChatterboxBackend,
)
from pylabrobot.liquid_handling.standard import (
MultiHeadAspirationContainer,
MultiHeadDispenseContainer,
MultiHeadDispensePlate,
ResourcePickup,
ResourceMove,
ResourceDrop,
)
from pylabrobot.resources import Tip, Deck, Plate, Well, TipRack, Resource, Container, Coordinate, TipSpot, Trash
from traitlets import Int
from unilabos.devices.liquid_handling.liquid_handler_abstract import LiquidHandlerAbstract
class MaterialResource:
"""统一的液体/反应器资源支持多孔wells场景
- wells: 列表每个元素代表一个物料孔unit
- units: 与 wells 对齐的列表,每个元素是 {liquid_id: volume}
- 若传入 liquid_id + volume 或 composition总量将**等分**到各 unit
"""
def __init__(
self,
resource_name: str,
slot: int,
well: List[int],
composition: Optional[Dict[str, float]] = None,
liquid_id: Optional[str] = None,
volume: Union[float, int] = 0.0,
is_supply: Optional[bool] = None,
):
self.resource_name = resource_name
self.slot = int(slot)
self.well = list(well or [])
self.is_supply = bool(is_supply) if is_supply is not None else (bool(composition) or (liquid_id is not None))
# 规范化:至少有 1 个 unit
n = max(1, len(self.well))
self.units: List[Dict[str, float]] = [dict() for _ in range(n)]
# 初始化内容:等分到各 unit
if composition:
for k, v in composition.items():
share = float(v) / n
for u in self.units:
if share > 0:
u[k] = u.get(k, 0.0) + share
elif liquid_id is not None and float(volume) > 0:
share = float(volume) / n
for u in self.units:
u[liquid_id] = u.get(liquid_id, 0.0) + share
# 位置描述
def location(self) -> Dict[str, Any]:
return {"slot": self.slot, "well": self.well}
def unit_count(self) -> int:
return len(self.units)
def unit_volume(self, idx: int) -> float:
return float(sum(self.units[idx].values()))
def total_volume(self) -> float:
return float(sum(self.unit_volume(i) for i in range(self.unit_count())))
def add_to_unit(self, idx: int, liquid_id: str, vol: Union[float, int]):
v = float(vol)
if v < 0:
return
u = self.units[idx]
if liquid_id not in u:
u[liquid_id] = 0.0
if v > 0:
u[liquid_id] += v
def remove_from_unit(self, idx: int, total: Union[float, int]) -> Dict[str, float]:
take = float(total)
if take <= 0: return {}
u = self.units[idx]
avail = sum(u.values())
if avail <= 0: return {}
take = min(take, avail)
ratio = take / avail
removed: Dict[str, float] = {}
for k, v in list(u.items()):
dv = v * ratio
nv = v - dv
if nv < 1e-9: nv = 0.0
u[k] = nv
removed[k] = dv
self.units[idx] = {k: v for k, v in u.items() if v > 0}
return removed
def transfer_unit_to(self, src_idx: int, other: "MaterialResource", dst_idx: int, total: Union[float, int]):
moved = self.remove_from_unit(src_idx, total)
for k, v in moved.items():
other.add_to_unit(dst_idx, k, v)
def get_resource(self) -> Dict[str, Any]:
return {
"resource_name": self.resource_name,
"slot": self.slot,
"well": self.well,
"units": [dict(u) for u in self.units],
"total_volume": self.total_volume(),
"is_supply": self.is_supply,
}
def transfer_liquid(
sources: MaterialResource,
targets: MaterialResource,
unit_volume: Optional[Union[float, int]] = None,
tip: Optional[str] = None, #这里应该是指定种类的
) -> Dict[str, Any]:
try:
vol_each = float(unit_volume)
except (TypeError, ValueError):
return {"action": "transfer_liquid", "error": "invalid unit_volume"}
if vol_each <= 0:
return {"action": "transfer_liquid", "error": "non-positive volume"}
ns, nt = sources.unit_count(), targets.unit_count()
# one-to-many: 从单个 source unit(0) 扇出到目标各 unit
if ns == 1 and nt >= 1:
for j in range(nt):
sources.transfer_unit_to(0, targets, j, vol_each)
# many-to-many: 数量相同,逐一对应
elif ns == nt and ns > 0:
for i in range(ns):
sources.transfer_unit_to(i, targets, i, vol_each)
else:
raise ValueError(f"Unsupported mapping: sources={ns} units, targets={nt} units. Only 1->N or N->N are allowed.")
return {
"action": "transfer_liquid",
"sources": sources.get_resource(),
"targets": targets.get_resource(),
"unit_volume": unit_volume,
"tip": tip,
}
def plan_transfer(pm: "ProtocolManager", **kwargs) -> Dict[str, Any]:
"""Shorthand to add a non-committing transfer to a ProtocolManager.
Accepts the same kwargs as ProtocolManager.add_transfer.
"""
return pm.add_transfer(**kwargs)
class ProtocolManager:
"""Plan/track transfers and backsolve minimum initial volumes.
Use add_transfer(...) to register steps (no mutation).
Use compute_min_initials(...) to infer the minimal starting volume of each liquid
per resource required to execute the plan in order.
"""
# ---------- lifecycle ----------
def __init__(self):
# queued logical steps (keep live refs to MaterialResource)
self.steps: List[Dict[str, Any]] = []
# simple tip catalog; choose the smallest that meets min_aspirate and capacity*safety
self.tip_catalog = [
{"name": "TIP_10uL", "capacity": 10.0, "min_aspirate": 0.5},
{"name": "TIP_20uL", "capacity": 20.0, "min_aspirate": 1.0},
{"name": "TIP_50uL", "capacity": 50.0, "min_aspirate": 2.0},
{"name": "TIP_200uL", "capacity": 200.0, "min_aspirate": 5.0},
{"name": "TIP_300uL", "capacity": 300.0, "min_aspirate": 10.0},
{"name": "TIP_1000uL", "capacity": 1000.0, "min_aspirate": 20.0},
]
# stable labels for unknown liquids per resource (A, B, C, ..., AA, AB, ...)
self._unknown_labels: Dict[MaterialResource, str] = {}
self._unknown_label_counter: int = 0
# ---------- public API ----------
def recommend_tip(self, unit_volume: float, safety: float = 1.10) -> str:
v = float(unit_volume)
# prefer: meets min_aspirate and capacity with safety margin; else fallback to capacity-only; else max capacity
eligible = [t for t in self.tip_catalog if t["min_aspirate"] <= v and t["capacity"] >= v * safety]
if not eligible:
eligible = [t for t in self.tip_catalog if t["capacity"] >= v]
return min(eligible or self.tip_catalog, key=lambda t: t["capacity"]) ["name"]
def get_tip_capacity(self, tip_name: str) -> Optional[float]:
for t in self.tip_catalog:
if t["name"] == tip_name:
return t["capacity"]
return None
def add_transfer(
self,
sources: MaterialResource,
targets: MaterialResource,
unit_volume: Union[float, int],
tip: Optional[str] = None,
) -> Dict[str, Any]:
step = {
"action": "transfer_liquid",
"sources": sources,
"targets": targets,
"unit_volume": float(unit_volume),
"tip": tip or self.recommend_tip(unit_volume),
}
self.steps.append(step)
# return a serializable shadow (no mutation)
return {
"action": "transfer_liquid",
"sources": sources.get_resource(),
"targets": targets.get_resource(),
"unit_volume": step["unit_volume"],
"tip": step["tip"],
}
@staticmethod
def _liquid_keys_of(resource: MaterialResource) -> List[str]:
keys: set[str] = set()
for u in resource.units:
keys.update(u.keys())
return sorted(keys)
@staticmethod
def _fanout_multiplier(ns: int, nt: int) -> Optional[int]:
"""Return the number of liquid movements for a mapping shape.
1->N: N moves; N->N: N moves; otherwise unsupported (None).
"""
if ns == 1 and nt >= 1:
return nt
if ns == nt and ns > 0:
return ns
return None
# ---------- planning core ----------
def compute_min_initials(
self,
use_initial: bool = False,
external_only: bool = True,
) -> Dict[str, Dict[str, float]]:
"""Simulate the plan (nonmutating) and return minimal starting volumes per resource/liquid."""
ledger: Dict[MaterialResource, Dict[str, float]] = {}
min_seen: Dict[MaterialResource, Dict[str, float]] = {}
def _ensure(res: MaterialResource) -> None:
if res in ledger:
return
declared = self._liquid_keys_of(res)
if use_initial:
# sum actual held amounts across units
totals = {k: 0.0 for k in declared}
for u in res.units:
for k, v in u.items():
totals[k] = totals.get(k, 0.0) + float(v)
ledger[res] = totals
else:
ledger[res] = {k: 0.0 for k in declared}
min_seen[res] = {k: ledger[res].get(k, 0.0) for k in ledger[res]}
def _proportions(src: MaterialResource, src_bal: Dict[str, float]) -> tuple[List[str], Dict[str, float]]:
keys = list(src_bal.keys())
total_pos = sum(x for x in src_bal.values() if x > 0)
# if ledger has no keys yet, seed from declared types on the resource
if not keys:
keys = self._liquid_keys_of(src)
for k in keys:
src_bal.setdefault(k, 0.0)
min_seen[src].setdefault(k, 0.0)
if total_pos > 0:
# proportional to current positive balances
props = {k: (src_bal.get(k, 0.0) / total_pos) for k in keys}
return keys, props
# no material currently: evenly from known keys, or assign an unknown label
if keys:
eq = 1.0 / len(keys)
return keys, {k: eq for k in keys}
unk = self._label_for_unknown(src)
keys = [unk]
src_bal.setdefault(unk, 0.0)
min_seen[src].setdefault(unk, 0.0)
return keys, {unk: 1.0}
for step in self.steps:
if step.get("action") != "transfer_liquid":
continue
src: MaterialResource = step["sources"]
dst: MaterialResource = step["targets"]
vol = float(step["unit_volume"])
if vol <= 0:
continue
_ensure(src)
_ensure(dst)
mult = self._fanout_multiplier(src.unit_count(), dst.unit_count())
if not mult:
continue # unsupported mapping shape for this planner
eff_vol = vol * mult
src_bal = ledger[src]
keys, props = _proportions(src, src_bal)
# subtract from src; track minima; accumulate to dst
moved: Dict[str, float] = {}
for k in keys:
dv = eff_vol * props[k]
src_bal[k] = src_bal.get(k, 0.0) - dv
moved[k] = dv
prev_min = min_seen[src].get(k, 0.0)
if src_bal[k] < prev_min:
min_seen[src][k] = src_bal[k]
dst_bal = ledger[dst]
for k, dv in moved.items():
dst_bal[k] = dst_bal.get(k, 0.0) + dv
min_seen[dst].setdefault(k, dst_bal[k])
# convert minima (negative) to required initials
result: Dict[str, Dict[str, float]] = {}
for res, mins in min_seen.items():
if external_only and not getattr(res, "is_supply", False):
continue
need = {liq: max(0.0, -mn) for liq, mn in mins.items() if mn < 0.0}
if need:
result[res.resource_name] = need
return result
def compute_tip_consumption(self) -> Dict[str, Any]:
"""Compute how many tips are consumed at each transfer step, and aggregate by tip type.
Rule: each liquid movement (source unit -> target unit) consumes one tip.
For supported shapes: 1->N uses N tips; N->N uses N tips.
"""
per_step: List[Dict[str, Any]] = []
totals_by_tip: Dict[str, int] = {}
for i, s in enumerate(self.steps):
if s.get("action") != "transfer_liquid":
continue
ns = s["sources"].unit_count()
nt = s["targets"].unit_count()
moves = self._fanout_multiplier(ns, nt) or 0
tip_name = s.get("tip") or self.recommend_tip(s["unit_volume"]) # per-step tip may vary
per_step.append({
"idx": i,
"tip": tip_name,
"tips_used": moves,
"moves": moves,
})
totals_by_tip[tip_name] = totals_by_tip.get(tip_name, 0) + int(moves)
return {"per_step": per_step, "totals_by_tip": totals_by_tip}
def compute_min_initials_with_tips(
self,
use_initial: bool = False,
external_only: bool = True,
) -> Dict[str, Any]:
needs = self.compute_min_initials(use_initial=use_initial, external_only=external_only)
step_tips: List[Dict[str, Any]] = []
totals_by_tip: Dict[str, int] = {}
for i, s in enumerate(self.steps):
if s.get("action") != "transfer_liquid":
continue
ns = s["sources"].unit_count()
nt = s["targets"].unit_count()
moves = self._fanout_multiplier(ns, nt) or 0
tip_name = s.get("tip") or self.recommend_tip(s["unit_volume"]) # step-specific tip
totals_by_tip[self.get_tip_capacity(tip_name)] = totals_by_tip.get(tip_name, 0) + int(moves)
step_tips.append({
"idx": i,
"tip": tip_name,
"tip_capacity": self.get_tip_capacity(tip_name),
"unit_volume": s["unit_volume"],
"tips_used": moves,
})
return {"liquid_setup": needs, "step_tips": step_tips, "totals_by_tip": totals_by_tip}
# ---------- unknown labels ----------
def _index_to_letters(self, idx: int) -> str:
"""0->A, 1->B, ... 25->Z, 26->AA, 27->AB ... (Excel-like)"""
s: List[str] = []
idx = int(idx)
while True:
idx, r = divmod(idx, 26)
s.append(chr(ord('A') + r))
if idx == 0:
break
idx -= 1 # Excel-style carry
return "".join(reversed(s))
def _label_for_unknown(self, res: MaterialResource) -> str:
"""Assign a stable unknown-liquid label (A/B/C/...) per resource."""
if res not in self._unknown_labels:
lab = self._index_to_letters(self._unknown_label_counter)
self._unknown_label_counter += 1
self._unknown_labels[res] = lab
return self._unknown_labels[res]
# 在这一步传输目前有的物料
class LabResource:
def __init__(self):
self.tipracks = []
self.plates = []
self.trash = []
def add_tipracks(self, tiprack: List[TipRack]):
self.tipracks.extend(tiprack)
def add_plates(self, plate: List[Plate]):
self.plates.extend(plate)
def add_trash(self, trash: List[Plate]):
self.trash.extend(trash)
def get_resources_info(self) -> Dict[str, Any]:
tipracks = [{"name": tr.name, "max_volume": tr.children[0].tracker._tip.maximal_volume, "count": len(tr.children)} for tr in self.tipracks]
plates = [{"name": pl.name, "max_volume": pl.children[0].max_volume, "count": len(pl.children)} for pl in self.plates]
trash = [{"name": t.name, "max_volume": t.children[0].max_volume, "count": len(t.children)} for t in self.trash]
return {
"tipracks": tipracks,
"plates": plates,
"trash": trash
}
from typing import Dict, Any
import time
class DefaultLayout:
def __init__(self, product_name: str = "PRCXI9300"):
self.labresource = {}
if product_name not in ["PRCXI9300", "PRCXI9320"]:
raise ValueError(f"Unsupported product_name: {product_name}. Only 'PRCXI9300' and 'PRCXI9320' are supported.")
if product_name == "PRCXI9300":
self.rows = 2
self.columns = 3
self.layout = [1, 2, 3, 4, 5, 6]
self.trash_slot = 3
self.waste_liquid_slot = 6
elif product_name == "PRCXI9320":
self.rows = 3
self.columns = 4
self.layout = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16]
self.trash_slot = 16
self.waste_liquid_slot = 12
self.default_layout = {"MatrixId":f"{time.time()}","MatrixName":f"{time.time()}","MatrixCount":16,"WorkTablets":
[{"Number": 1, "Code": "T1", "Material": {"uuid": "57b1e4711e9e4a32b529f3132fc5931f", "materialEnum": 0}},
{"Number": 2, "Code": "T2", "Material": {"uuid": "57b1e4711e9e4a32b529f3132fc5931f", "materialEnum": 0}},
{"Number": 3, "Code": "T3", "Material": {"uuid": "57b1e4711e9e4a32b529f3132fc5931f", "materialEnum": 0}},
{"Number": 4, "Code": "T4", "Material": {"uuid": "57b1e4711e9e4a32b529f3132fc5931f", "materialEnum": 0}},
{"Number": 5, "Code": "T5", "Material": {"uuid": "57b1e4711e9e4a32b529f3132fc5931f", "materialEnum": 0}},
{"Number": 6, "Code": "T6", "Material": {"uuid": "57b1e4711e9e4a32b529f3132fc5931f", "materialEnum": 0}},
{"Number": 7, "Code": "T7", "Material": {"uuid": "57b1e4711e9e4a32b529f3132fc5931f", "materialEnum": 0}},
{"Number": 8, "Code": "T8", "Material": {"uuid": "57b1e4711e9e4a32b529f3132fc5931f", "materialEnum": 0}},
{"Number": 9, "Code": "T9", "Material": {"uuid": "57b1e4711e9e4a32b529f3132fc5931f", "materialEnum": 0}},
{"Number": 10, "Code": "T10", "Material": {"uuid": "57b1e4711e9e4a32b529f3132fc5931f", "materialEnum": 0}},
{"Number": 11, "Code": "T11", "Material": {"uuid": "57b1e4711e9e4a32b529f3132fc5931f", "materialEnum": 0}},
{"Number": 12, "Code": "T12", "Material": {"uuid": "730067cf07ae43849ddf4034299030e9", "materialEnum": 0}}, # 这个设置成废液槽,用储液槽表示
{"Number": 13, "Code": "T13", "Material": {"uuid": "57b1e4711e9e4a32b529f3132fc5931f", "materialEnum": 0}},
{"Number": 14, "Code": "T14", "Material": {"uuid": "57b1e4711e9e4a32b529f3132fc5931f", "materialEnum": 0}},
{"Number": 15, "Code": "T15", "Material": {"uuid": "57b1e4711e9e4a32b529f3132fc5931f", "materialEnum": 0}},
{"Number": 16, "Code": "T16", "Material": {"uuid": "730067cf07ae43849ddf4034299030e9", "materialEnum": 0}} # 这个设置成垃圾桶,用储液槽表示
]
}
def get_layout(self) -> Dict[str, Any]:
return {
"rows": self.rows,
"columns": self.columns,
"layout": self.layout,
"trash_slot": self.trash_slot,
"waste_liquid_slot": self.waste_liquid_slot
}
def get_trash_slot(self) -> int:
return self.trash_slot
def get_waste_liquid_slot(self) -> int:
return self.waste_liquid_slot
def add_lab_resource(self, material_info):
self.labresource = material_info
def recommend_layout(self, needs: Dict[str, int]) -> Dict[str, Any]:
"""根据 needs 推荐布局"""
for k, v in needs.items():
if k not in self.labresource:
raise ValueError(f"Material {k} not found in lab resources.")
# 预留位置12和16不动
reserved_positions = {12, 16}
available_positions = [i for i in range(1, 17) if i not in reserved_positions]
# 计算总需求
total_needed = sum(needs.values())
if total_needed > len(available_positions):
raise ValueError(f"需要 {total_needed} 个位置,但只有 {len(available_positions)} 个可用位置排除位置12和16")
# 依次分配位置
current_pos = 0
for material_name, count in needs.items():
material_uuid = self.labresource[material_name]['uuid']
material_enum = self.labresource[material_name]['materialEnum']
for _ in range(count):
if current_pos >= len(available_positions):
raise ValueError("位置不足,无法分配更多物料")
position = available_positions[current_pos]
# 找到对应的tablet并更新
for tablet in self.default_layout['WorkTablets']:
if tablet['Number'] == position:
tablet['Material']['uuid'] = material_uuid
tablet['Material']['materialEnum'] = material_enum
break
current_pos += 1
return self.default_layout
if __name__ == "__main__":
with open("prcxi_material.json", "r") as f:
material_info = json.load(f)
layout = DefaultLayout("PRCXI9320")
layout.add_lab_resource(material_info)
plan = layout.recommend_layout({
"10μL加长 Tip头": 2,
"300μL Tip头": 2,
"96深孔板": 2,
})
# if __name__ == "__main__":
# # ---- 资源SUP 供液X中间板 R14 孔空),目标板 R24 孔空)----
# # sup = MaterialResource("SUP", slot=5, well=[1], liquid_id="X", volume=10000)
# # r1 = MaterialResource("R1", slot=6, well=[1,2,3,4,5,6,7,8])
# # r2 = MaterialResource("R2", slot=7, well=[1,2,3,4,5,6,7,8])
# # pm = ProtocolManager()
# # # 步骤1SUP -> R11->N 扇出,每孔 50 uL总 200 uL
# # pm.add_transfer(sup, r1, unit_volume=10.0)
# # # 步骤2R1 -> R2N->N 对应,每对 25 uL总 100 uL来自 R1 中已存在的混合物 X
# # pm.add_transfer(r1, r2, unit_volume=120.0)
# # out = pm.compute_min_initials_with_tips()
# # # layout_planer = DefaultLayout('PRCXI9320')
# # # print(layout_planer.get_layout())
# # # print("回推最小需求:", out["liquid_setup"]) # {'SUP': {'X': 200.0}}
# # # print("步骤枪头建议:", out["step_tips"]) # [{'idx':0,'tip':'TIP_200uL','unit_volume':50.0}, {'idx':1,'tip':'TIP_50uL','unit_volume':25.0}]
# # # # 实际执行(可选)
# # # transfer_liquid(sup, r1, unit_volume=50.0)
# # # transfer_liquid(r1, r2, unit_volume=25.0)
# # # print("执行后 SUP", sup.get_resource()) # 总体积 -200
# # # print("执行后 R1", r1.get_resource()) # 每孔 25 uL50 进 -25 出)
# # # print("执行后 R2", r2.get_resource()) # 每孔 25 uL
# # from pylabrobot.resources.opentrons.tube_racks import *
# # from pylabrobot.resources.opentrons.plates import *
# # from pylabrobot.resources.opentrons.tip_racks import *
# # from pylabrobot.resources.opentrons.reservoirs import *
# # plate = [locals()['nest_96_wellplate_2ml_deep'](name="thermoscientificnunc_96_wellplate_2000ul"), locals()['corning_96_wellplate_360ul_flat'](name="corning_96_wellplate_360ul_flat")]
# # tiprack = [locals()['opentrons_96_tiprack_300ul'](name="opentrons_96_tiprack_300ul"), locals()['opentrons_96_tiprack_1000ul'](name="opentrons_96_tiprack_1000ul")]
# # trash = [locals()['axygen_1_reservoir_90ml'](name="axygen_1_reservoir_90ml")]
# # from pprint import pprint
# # lab_resource = LabResource()
# # lab_resource.add_tipracks(tiprack)
# # lab_resource.add_plates(plate)
# # lab_resource.add_trash(trash)
# # layout_planer = DefaultLayout('PRCXI9300')
# # layout_planer.add_lab_resource(lab_resource)
# # layout_planer.recommend_layout(out)
# with open("prcxi_material.json", "r") as f:
# material_info = json.load(f)
# # print("当前实验物料信息:", material_info)
# layout = DefaultLayout("PRCXI9320")
# layout.add_lab_resource(material_info)
# print(layout.default_layout['WorkTablets'])
# # plan = layout.recommend_layout({
# # "10μL加长 Tip头": 2,
# # "300μL Tip头": 2,
# # "96深孔板": 2,
# # })