Files
Uni-Lab-OS/unilabos/devices/liquid_handling/liquid_history.py
q434343 5be601177e 更新transfer部分
框选set liquid
跨slot的工作流合并
工作流名称,tag修勾
液体名称使用真实液体名称
2026-05-25 16:03:55 +08:00

222 lines
9.3 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.
"""P9 — liquid_history schema v3 与 helper 函数。
独立模块,**不依赖 pylabrobot**,可在 PLR 环境缺失时单独单测。
模块由 ``liquid_handler_abstract.py`` 在 runtime 挂载点set_liquid / aspirate /
dispense调用且由 ``resource_tracker._augment_states_with_liquid_history`` 在
serialize 链路使用。
详见 ``product_designs/protocol_convert/09-liquid-history-unknown-debug.md``。
"""
from __future__ import annotations
from typing import Any, List, Tuple
from typing_extensions import TypedDict
# liquid_history 元素 schema v3
# 详见 ``product_designs/protocol_convert/09-liquid-history-unknown-debug.md`` §6.1。
# 旧格式v2 ``(name, vol)`` 元组、list[str])由 ``normalize_liquid_history`` 升级。
class LiquidHistoryEntry(TypedDict, total=False):
name: str # 液体名(如 "Plasma";与 P8 reagent.liquid_name 联动;缺省 ""
volume: float # 操作体积µLaspirate 为负dispense / set 为正)
action: str # "set" / "aspirate" / "dispense" / "legacy" / "auto_init"
timestamp: str # ISO8601 UTCOS runtime 写入时填,前端写入时可省略)
# liquid_history 单 well 上限:超过则滚动丢弃头部
# 既限制内存(典型 8 通道 transfer 一次产生 ≤16 条),也防止极端 batch 拖慢前端渲染
LIQUID_HISTORY_MAX_ENTRIES = 1000
def well_current_liquid_name(well: Any) -> str:
"""从 ``well.tracker.liquids`` 末项读取当前液体名PLR ``Liquid`` enum / str / None 兼容)。
P9作为 ``aspirate`` 写入 history 时 ``name`` 字段的来源。
返回 ``""`` 表示未知(不写字面 "unknown",避免被前端误展示)。
"""
tracker = getattr(well, "tracker", None)
if tracker is None:
return ""
liquids = getattr(tracker, "liquids", None)
if not liquids:
# PLR 提供 get_liquids() 时优先用之(返回 list[(Liquid|None, vol)]
try:
liquids = tracker.get_liquids() # type: ignore[attr-defined]
except Exception:
liquids = None
if not liquids:
return ""
last = liquids[-1]
if isinstance(last, (list, tuple)) and last:
candidate = last[0]
else:
candidate = last
if candidate is None:
return ""
name = getattr(candidate, "name", None)
if isinstance(name, str) and name:
return name
if isinstance(candidate, str):
return candidate
return ""
def append_liquid_history(
well: Any,
liquid_name: str,
volume: float,
action: str,
) -> None:
"""P9 — 统一写入 ``well.tracker.liquid_history``PLR 扩展属性)。
设计要点:
- 元素为 v3 dict 形态 ``{name, volume, action, timestamp}``,与
:class:`LiquidHistoryEntry` schema 一致。
- ``aspirate`` 的 ``volume`` 应为**负数**(与 dispense/set 正数对称,
``sum(history.volume)`` ≈ 当前残量)。
- ``well`` 无 tracker 或 tracker 不可写时 graceful 静默(避免污染主流程)。
- 滚动上限 ``LIQUID_HISTORY_MAX_ENTRIES``:超出时丢弃**头部**(保留最近)。
详见 ``product_designs/protocol_convert/09-liquid-history-unknown-debug.md`` §6.2。
"""
tracker = getattr(well, "tracker", None)
if tracker is None:
return
history = getattr(tracker, "liquid_history", None)
if not isinstance(history, list):
history = []
try:
tracker.liquid_history = history # type: ignore[attr-defined]
except Exception:
return # tracker 拒绝写扩展属性(极少见);静默放弃
# 兼容修复PLR VolumeTracker.current_liquids 依赖 tracker.liquid_history 为
# list[(name, vol)];若写入 dict 会在 `for name, vol in liquid_history` 时崩溃。
# 这里把历史就地归一为 tuple 形态,再 append tuple避免 unpack ValueError。
normalized_pairs: List[Tuple[str, float]] = []
for item in history:
if isinstance(item, (list, tuple)) and len(item) >= 2:
name_val = str(item[0] or "")
try:
vol_val = float(item[1])
except (TypeError, ValueError):
vol_val = 0.0
normalized_pairs.append((name_val, vol_val))
elif isinstance(item, dict):
name_val = str(item.get("name", ""))
try:
vol_val = float(item.get("volume", 0.0) or 0.0)
except (TypeError, ValueError):
vol_val = 0.0
normalized_pairs.append((name_val, vol_val))
elif isinstance(item, str):
normalized_pairs.append((item, 0.0))
history[:] = normalized_pairs
entry = (str(liquid_name or ""), float(volume))
history.append(entry)
overflow = len(history) - LIQUID_HISTORY_MAX_ENTRIES
if overflow > 0:
del history[:overflow]
# ---------------------------------------------------------------------------
# P10 v2 — Tip 复用 ``tracker.liquids`` 等价 helper详见
# ``product_designs/protocol_convert/10-tip-reuse-by-liquid-history.md`` §3.2
#
# 设计原则:
# - 信号源使用 PLR 原生 ``well.tracker.liquids`` 末项("well 此刻顶层液体"
# 而非 P9 扩展属性 ``liquid_history``P10 v2 因此不依赖 P9 是否落地。
# - 名称比较使用严格字符串相等;空 / "unknown" / "none" 一律保守视为未知 →
# 不触发 liquids 复用,落回 identity-only 现状(零回归)。
# - 与 P9 现有 ``liquid_names_before_aspirate`` 同模式aspirate 之前预读
# source 当前液体名,避免 PLR 顶层归零时 pop ``liquids`` 拿不到身份。
# - 4 个 helper 共同居于本 PLR-free 模块,方便单元测试在不安装 pylabrobot
# 的环境下独立运行。
# ---------------------------------------------------------------------------
def is_known_liquid_name(name: Any) -> bool:
"""空字符串 / "unknown" / "none" / None 一律视为未知,不触发 liquids 复用。"""
if not name:
return False
if not isinstance(name, str):
return False
return name.strip().lower() not in {"unknown", "none"}
def same_liquid_via_liquids(well: Any, tip_liquid_name: Any) -> bool:
"""tip 残留液体名 vs ``well.tracker.liquids`` 末项 name 严格相等。
用于 pick_up 决策:判断下一轮要 aspirate 的 well 当前液体是否与 tip 残液同名。
任一侧未知(空 / "unknown")→ 返回 ``False``(保守换 tip
"""
if not is_known_liquid_name(tip_liquid_name):
return False
well_name = well_current_liquid_name(well)
if not is_known_liquid_name(well_name):
return False
return well_name == tip_liquid_name
def same_liquid_via_liquids_pair(cur_well: Any, next_well: Any) -> bool:
"""两个 source well 当前 ``tracker.liquids`` 末项是否同名(用于决定 drop 时机)。
注:必须在 cur_well 的 aspirate **之前**调用aspirate 不改
``liquids[-1].name`` 只改顶层 vol或顶层归零时 pop故 cur/next 的判等
"将要被抽的那一层" 为准。
"""
cur_name = well_current_liquid_name(cur_well)
next_name = well_current_liquid_name(next_well)
if not is_known_liquid_name(cur_name) or not is_known_liquid_name(next_name):
return False
return cur_name == next_name
def capture_tip_liquid_name(source_well: Any) -> "str | None":
"""**aspirate 之前** 把 source well 的当前液体名捕获下来,作为本轮 aspirate
完成后 tip 上残留液体的身份。
必须在 ``super().aspirate`` / ``_transfer_base_method`` 调用前读取PLR
aspirate 会从顶层扣减体积,体积归零时 PLR ``VolumeTracker`` 会 pop 顶层
``(Liquid, vol)``,事后再读 ``liquids[-1]`` 可能拿到 prev layer 或空 list。
详见 ``liquid_handler_abstract.aspirate`` 中 ``liquid_names_before_aspirate``
同样的 "预读" 模式。
"""
name = well_current_liquid_name(source_well)
return name if is_known_liquid_name(name) else None
def normalize_liquid_history(raw: Any) -> List[Tuple[str, float]]:
"""P9 — 把任意旧形态的 liquid_history 升级为 v3 dict 列表。
兼容输入:
- v3 dict ``[{name, volume, action, timestamp?}, ...]`` 原样返回(字段补全)
- v2 tuple ``[(name, vol), ...]`` → ``action="legacy"``
- list[str] ``["A", "B"]`` → ``volume=0, action="legacy"``
- 其它:丢弃该 entry
详见 ``product_designs/protocol_convert/09-liquid-history-unknown-debug.md`` §6.4。
"""
if not isinstance(raw, list):
return []
result: List[Tuple[str, float]] = []
for entry in raw:
if isinstance(entry, dict):
try:
vol_val = float(entry.get("volume", 0.0) or 0.0)
except (TypeError, ValueError):
vol_val = 0.0
result.append((str(entry.get("name", "")), vol_val))
elif isinstance(entry, (list, tuple)) and len(entry) >= 2:
try:
vol_val = float(entry[1])
except (TypeError, ValueError):
vol_val = 0.0
result.append((str(entry[0] or ""), vol_val))
elif isinstance(entry, str):
result.append((entry, 0.0))
# 其它类型静默丢弃
return result