diff --git a/tests/app/__init__.py b/tests/app/__init__.py new file mode 100644 index 000000000..ca7ee9bc9 --- /dev/null +++ b/tests/app/__init__.py @@ -0,0 +1,172 @@ +"""normalize_model_for_upload 单元测试""" + +import unittest +import sys +import os + +# 添加项目根目录到 sys.path +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..")) + +from unilabos.app.register import normalize_model_for_upload + + +class TestNormalizeModelForUpload(unittest.TestCase): + """测试 Registry YAML model 字段标准化""" + + def test_empty_input(self): + """空 dict 直接返回""" + self.assertEqual(normalize_model_for_upload({}), {}) + self.assertIsNone(normalize_model_for_upload(None)) + + def test_format_infer_xacro(self): + """自动从 path 后缀推断 format=xacro""" + model = { + "path": "https://oss.example.com/devices/arm/macro_device.xacro", + "mesh": "arm_slider", + "type": "device", + } + result = normalize_model_for_upload(model) + self.assertEqual(result["format"], "xacro") + + def test_format_infer_urdf(self): + """自动推断 format=urdf""" + model = {"path": "https://example.com/robot.urdf", "type": "device"} + result = normalize_model_for_upload(model) + self.assertEqual(result["format"], "urdf") + + def test_format_infer_stl(self): + """自动推断 format=stl""" + model = {"path": "https://example.com/part.stl"} + result = normalize_model_for_upload(model) + self.assertEqual(result["format"], "stl") + + def test_format_infer_gltf(self): + """自动推断 format=gltf(.gltf 和 .glb)""" + for ext in [".gltf", ".glb"]: + model = {"path": f"https://example.com/model{ext}"} + result = normalize_model_for_upload(model) + self.assertEqual(result["format"], "gltf", f"failed for {ext}") + + def test_format_not_overwritten(self): + """已有 format 字段时不覆盖""" + model = { + "path": "https://example.com/model.xacro", + "format": "custom", + } + result = normalize_model_for_upload(model) + self.assertEqual(result["format"], "custom") + + def test_format_no_path(self): + """没有 path 时不推断 format""" + model = {"mesh": "arm_slider", "type": "device"} + result = normalize_model_for_upload(model) + self.assertNotIn("format", result) + + def test_children_mesh_string_to_struct(self): + """将 children_mesh 字符串(旧格式)转为结构化对象""" + model = { + "path": "https://example.com/rack.xacro", + "type": "resource", + "children_mesh": "tip/meshes/tip.stl", + "children_mesh_tf": [0.0045, 0.0045, 0, 0, 0, 1.57], + "children_mesh_path": "https://oss.example.com/tip.stl", + } + result = normalize_model_for_upload(model) + + # children_mesh 应变为 dict + cm = result["children_mesh"] + self.assertIsInstance(cm, dict) + self.assertEqual(cm["path"], "https://oss.example.com/tip.stl") # 优先使用 OSS URL + self.assertEqual(cm["format"], "stl") + self.assertTrue(cm["default_visible"]) + self.assertEqual(cm["local_offset"], [0.0045, 0.0045, 0]) + self.assertEqual(cm["local_rotation"], [0, 0, 1.57]) + + # 旧字段应被移除 + self.assertNotIn("children_mesh_tf", result) + self.assertNotIn("children_mesh_path", result) + + def test_children_mesh_no_oss_fallback(self): + """children_mesh 无 OSS URL 时 fallback 到本地路径""" + model = { + "path": "https://example.com/rack.xacro", + "children_mesh": "plate_96/meshes/plate_96.stl", + } + result = normalize_model_for_upload(model) + cm = result["children_mesh"] + self.assertEqual(cm["path"], "plate_96/meshes/plate_96.stl") + self.assertEqual(cm["format"], "stl") + + def test_children_mesh_gltf_format(self): + """children_mesh .glb 文件推断 format=gltf""" + model = { + "path": "https://example.com/rack.xacro", + "children_mesh": "meshes/child.glb", + } + result = normalize_model_for_upload(model) + self.assertEqual(result["children_mesh"]["format"], "gltf") + + def test_children_mesh_partial_tf(self): + """children_mesh_tf 只有 3 个值时只有 offset 无 rotation""" + model = { + "path": "https://example.com/rack.xacro", + "children_mesh": "tip.stl", + "children_mesh_tf": [0.01, 0.02, 0.03], + } + result = normalize_model_for_upload(model) + cm = result["children_mesh"] + self.assertEqual(cm["local_offset"], [0.01, 0.02, 0.03]) + self.assertNotIn("local_rotation", cm) + + def test_children_mesh_no_tf(self): + """children_mesh 无 tf 时不加 offset/rotation""" + model = { + "path": "https://example.com/rack.xacro", + "children_mesh": "tip.stl", + } + result = normalize_model_for_upload(model) + cm = result["children_mesh"] + self.assertNotIn("local_offset", cm) + self.assertNotIn("local_rotation", cm) + + def test_children_mesh_already_dict(self): + """children_mesh 已经是 dict 时不重新映射""" + model = { + "path": "https://example.com/rack.xacro", + "children_mesh": { + "path": "https://example.com/tip.stl", + "format": "stl", + "default_visible": False, + }, + } + result = normalize_model_for_upload(model) + cm = result["children_mesh"] + self.assertIsInstance(cm, dict) + self.assertFalse(cm["default_visible"]) + + def test_original_not_mutated(self): + """原始 dict 不被修改""" + original = { + "path": "https://example.com/model.xacro", + "mesh": "arm", + } + original_copy = {**original} + normalize_model_for_upload(original) + self.assertEqual(original, original_copy) + + def test_preserves_existing_fields(self): + """所有原始字段都被保留""" + model = { + "path": "https://example.com/model.xacro", + "mesh": "arm_slider", + "type": "device", + "mesh_tf": [0, 0, 0, 0, 0, 0], + "custom_field": "should_survive", + } + result = normalize_model_for_upload(model) + self.assertEqual(result["custom_field"], "should_survive") + self.assertEqual(result["mesh_tf"], [0, 0, 0, 0, 0, 0]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/app/test_model_upload.py b/tests/app/test_model_upload.py new file mode 100644 index 000000000..a40f873ab --- /dev/null +++ b/tests/app/test_model_upload.py @@ -0,0 +1,496 @@ +"""model_upload.py 单元测试(upload_device_model / download_model_from_oss / XOR 加解密)""" + +import unittest +import tempfile +import os +import sys +from pathlib import Path +from unittest.mock import patch, MagicMock + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..")) + +from unilabos.app.model_upload import ( + upload_device_model, + download_model_from_oss, + _MODEL_EXTENSIONS, + _MESH_ENCRYPT_EXTENSIONS, + _xor_transform, +) + + +class TestUploadDeviceModel(unittest.TestCase): + """测试本地模型文件上传到 OSS""" + + def setUp(self): + self.tmp_dir = tempfile.mkdtemp() + self.mock_client = MagicMock() + + def _create_model_files(self, subdir: str, filenames: list[str]): + """在临时目录中创建设备模型文件""" + model_dir = Path(self.tmp_dir) / "devices" / subdir + model_dir.mkdir(parents=True, exist_ok=True) + for name in filenames: + p = model_dir / name + p.parent.mkdir(parents=True, exist_ok=True) + p.write_text("dummy content") + return model_dir + + @patch("unilabos.app.model_upload._MESH_BASE_DIR") + def test_upload_success(self, mock_base): + """正常上传流程""" + mock_base.__truediv__ = lambda self, x: Path(self.tmp_dir) / x + # 直接 patch _MESH_BASE_DIR 为 Path(tmp_dir) + with patch("unilabos.app.model_upload._MESH_BASE_DIR", Path(self.tmp_dir)): + self._create_model_files("arm_slider", ["macro_device.xacro", "meshes/link1.stl"]) + + self.mock_client.get_model_upload_urls.return_value = { + "files": [ + {"name": "macro_device.xacro", "upload_url": "https://oss.example.com/put1"}, + {"name": "meshes/link1.stl", "upload_url": "https://oss.example.com/put2"}, + ] + } + self.mock_client.publish_model.return_value = { + "path": "https://oss.example.com/arm_slider/macro_device.xacro" + } + + with patch("unilabos.app.model_upload._put_upload") as mock_put: + result = upload_device_model( + http_client=self.mock_client, + template_uuid="test-uuid", + mesh_name="arm_slider", + model_type="device", + version="1.0.0", + ) + + self.assertEqual(result, "https://oss.example.com/arm_slider/macro_device.xacro") + self.mock_client.get_model_upload_urls.assert_called_once() + self.mock_client.publish_model.assert_called_once() + + @patch("unilabos.app.model_upload._MESH_BASE_DIR") + def test_upload_dir_not_exists(self, mock_base): + """本地目录不存在时返回 None""" + with patch("unilabos.app.model_upload._MESH_BASE_DIR", Path(self.tmp_dir)): + result = upload_device_model( + http_client=self.mock_client, + template_uuid="test-uuid", + mesh_name="nonexistent", + model_type="device", + ) + self.assertIsNone(result) + + @patch("unilabos.app.model_upload._MESH_BASE_DIR") + def test_upload_no_valid_files(self, mock_base): + """目录中无有效模型文件时返回 None""" + with patch("unilabos.app.model_upload._MESH_BASE_DIR", Path(self.tmp_dir)): + model_dir = Path(self.tmp_dir) / "devices" / "empty_model" + model_dir.mkdir(parents=True, exist_ok=True) + (model_dir / "readme.txt").write_text("not a model") + + result = upload_device_model( + http_client=self.mock_client, + template_uuid="test-uuid", + mesh_name="empty_model", + model_type="device", + ) + self.assertIsNone(result) + + @patch("unilabos.app.model_upload._MESH_BASE_DIR") + def test_upload_urls_failure(self, mock_base): + """获取上传 URL 失败时返回 None""" + with patch("unilabos.app.model_upload._MESH_BASE_DIR", Path(self.tmp_dir)): + self._create_model_files("arm", ["device.xacro"]) + self.mock_client.get_model_upload_urls.return_value = None + + result = upload_device_model( + http_client=self.mock_client, + template_uuid="test-uuid", + mesh_name="arm", + model_type="device", + ) + self.assertIsNone(result) + + +class TestDownloadModelFromOss(unittest.TestCase): + """测试从 OSS 下载模型文件到本地""" + + def setUp(self): + self.tmp_dir = tempfile.mkdtemp() + + def test_skip_no_mesh_name(self): + """缺少 mesh 名称时跳过""" + result = download_model_from_oss({"type": "device", "path": "https://x.com/a.xacro"}) + self.assertFalse(result) + + def test_skip_no_oss_path(self): + """缺少 OSS path 时跳过""" + result = download_model_from_oss({"mesh": "arm", "type": "device"}) + self.assertFalse(result) + + def test_skip_local_path(self): + """非 https:// 路径时跳过""" + result = download_model_from_oss({ + "mesh": "arm", + "type": "device", + "path": "file:///local/model.xacro", + }) + self.assertFalse(result) + + def test_already_exists(self): + """本地已有文件时跳过下载""" + device_dir = Path(self.tmp_dir) / "devices" / "arm" + device_dir.mkdir(parents=True, exist_ok=True) + (device_dir / "model.xacro").write_text("existing") + + result = download_model_from_oss( + {"mesh": "arm", "type": "device", "path": "https://oss.example.com/model.xacro"}, + mesh_base_dir=Path(self.tmp_dir), + ) + self.assertTrue(result) + + @patch("unilabos.app.model_upload._download_file") + def test_download_device(self, mock_download): + """下载 device 模型到 devices/ 目录""" + result = download_model_from_oss( + {"mesh": "new_arm", "type": "device", "path": "https://oss.example.com/new_arm/macro_device.xacro"}, + mesh_base_dir=Path(self.tmp_dir), + ) + self.assertTrue(result) + mock_download.assert_called_once() + call_args = mock_download.call_args + self.assertIn("macro_device.xacro", str(call_args[0][1])) + + @patch("unilabos.app.model_upload._download_file") + def test_download_resource(self, mock_download): + """下载 resource 模型到 resources/ 目录""" + result = download_model_from_oss( + { + "mesh": "plate_96/meshes/plate_96.stl", + "type": "resource", + "path": "https://oss.example.com/plate_96/modal.xacro", + }, + mesh_base_dir=Path(self.tmp_dir), + ) + self.assertTrue(result) + target_dir = Path(self.tmp_dir) / "resources" / "plate_96" + self.assertTrue(target_dir.exists()) + + @patch("unilabos.app.model_upload._download_file") + def test_download_with_children_mesh(self, mock_download): + """下载包含 children_mesh 的模型""" + result = download_model_from_oss( + { + "mesh": "tip_rack", + "type": "device", + "path": "https://oss.example.com/tip_rack/model.xacro", + "children_mesh": { + "path": "https://oss.example.com/tip_rack/meshes/tip.stl", + "format": "stl", + }, + }, + mesh_base_dir=Path(self.tmp_dir), + ) + self.assertTrue(result) + # 应调用两次:入口文件 + children_mesh + self.assertEqual(mock_download.call_count, 2) + + @patch("unilabos.app.model_upload._download_file", side_effect=Exception("network error")) + def test_download_failure_graceful(self, mock_download): + """下载失败时返回 False(不抛异常)""" + result = download_model_from_oss( + {"mesh": "broken", "type": "device", "path": "https://oss.example.com/broken.xacro"}, + mesh_base_dir=Path(self.tmp_dir), + ) + self.assertFalse(result) + + +class TestModelExtensions(unittest.TestCase): + """测试支持的模型文件后缀集合""" + + def test_standard_extensions(self): + """确认标准 3D 格式在支持列表中""" + expected = {".stl", ".gltf", ".glb", ".xacro", ".urdf", ".obj", ".dae"} + for ext in expected: + self.assertIn(ext, _MODEL_EXTENSIONS, f"{ext} should be supported") + + def test_non_model_excluded(self): + """非模型文件后缀不在列表中""" + excluded = {".txt", ".json", ".py", ".png", ".jpg"} + for ext in excluded: + self.assertNotIn(ext, _MODEL_EXTENSIONS, f"{ext} should not be supported") + + +class TestXorTransform(unittest.TestCase): + """XOR 加密/解密核心函数测试。""" + + def test_roundtrip_symmetry(self): + """XOR 加密后再解密恢复原始数据(对称性)。""" + original = b"Hello, this is a test model file content." + encrypted = _xor_transform(original) + self.assertNotEqual(encrypted, original) + decrypted = _xor_transform(encrypted) + self.assertEqual(decrypted, original) + + def test_empty_data(self): + """空数据加密后仍为空。""" + result = _xor_transform(b"") + self.assertEqual(result, b"") + + def test_single_byte(self): + """单字节数据正确加解密。""" + original = b"\xff" + encrypted = _xor_transform(original) + decrypted = _xor_transform(encrypted) + self.assertEqual(decrypted, original) + + def test_data_longer_than_key(self): + """超过密钥长度(32 字节)的数据正确循环 XOR。""" + original = bytes(range(256)) * 2 # 512 字节 + encrypted = _xor_transform(original) + self.assertNotEqual(encrypted, original) + decrypted = _xor_transform(encrypted) + self.assertEqual(decrypted, original) + + def test_data_exactly_key_length(self): + """恰好 32 字节(密钥长度)的数据正确处理。""" + original = bytes(range(32)) + encrypted = _xor_transform(original) + decrypted = _xor_transform(encrypted) + self.assertEqual(decrypted, original) + + def test_all_zeros_produces_key(self): + """全零数据 XOR 后结果应为密钥本身。""" + zeros = b"\x00" * 32 + result = _xor_transform(zeros) + key = os.environ.get( + "UNILAB_MESH_XOR_KEY", "unilab3d-model-protection-key-v1" + ).encode() + self.assertEqual(result, key) + + def test_custom_key(self): + """自定义密钥正确加解密。""" + custom_key = b"custom-key-12345" + original = b"test data for custom key" + encrypted = _xor_transform(original, key=custom_key) + decrypted = _xor_transform(encrypted, key=custom_key) + self.assertEqual(decrypted, original) + + def test_different_keys_produce_different_results(self): + """不同密钥产生不同加密结果。""" + data = b"same data" + key1 = b"key-one-is-here!" + key2 = b"key-two-is-here!" + self.assertNotEqual(_xor_transform(data, key1), _xor_transform(data, key2)) + + def test_binary_stl_header(self): + """二进制内容(模拟 STL 文件头)正确加解密。""" + stl_header = b"\x00" * 80 + b"\x03\x00\x00\x00" + encrypted = _xor_transform(stl_header) + decrypted = _xor_transform(encrypted) + self.assertEqual(decrypted, stl_header) + + def test_large_data_roundtrip(self): + """大数据(1MB)加解密正确性。""" + original = os.urandom(1024 * 1024) + encrypted = _xor_transform(original) + decrypted = _xor_transform(encrypted) + self.assertEqual(decrypted, original) + + def test_consistency_with_frontend_key(self): + """验证 Python 端与前端使用相同的默认密钥。""" + frontend_key = b"unilab3d-model-protection-key-v1" + data = b"cross-platform test data" + encrypted = _xor_transform(data, key=frontend_key) + # 用默认密钥解密(应一致) + decrypted = _xor_transform(encrypted) + self.assertEqual(decrypted, data) + + +class TestEncryptExtensions(unittest.TestCase): + """加密文件扩展名配置测试。""" + + def test_all_mesh_formats_in_encrypt_set(self): + """所有 mesh 格式都在加密扩展名集合中。""" + expected = {".stl", ".dae", ".obj", ".fbx", ".gltf", ".glb"} + self.assertEqual(_MESH_ENCRYPT_EXTENSIONS, expected) + + def test_xml_formats_not_encrypted(self): + """XACRO/URDF/YAML 文件不加密。""" + for ext in {".xacro", ".urdf", ".yaml", ".yml"}: + self.assertNotIn(ext, _MESH_ENCRYPT_EXTENSIONS) + + def test_encrypt_is_subset_of_model_extensions(self): + """加密扩展名是模型扩展名的子集。""" + self.assertTrue(_MESH_ENCRYPT_EXTENSIONS.issubset(_MODEL_EXTENSIONS)) + + +class TestPutUploadEncryption(unittest.TestCase): + """_put_upload 中的条件加密测试。""" + + @patch("unilabos.app.model_upload.requests.put") + def test_stl_file_encrypted_before_upload(self, mock_put): + """STL 文件上传前自动 XOR 加密。""" + from unilabos.app.model_upload import _put_upload + + original_data = b"solid test\nfacet normal 0 0 1\n" + with tempfile.NamedTemporaryFile(suffix=".stl", delete=False) as f: + f.write(original_data) + f.flush() + tmp_path = Path(f.name) + + try: + mock_put.return_value = MagicMock(status_code=200) + mock_put.return_value.raise_for_status = MagicMock() + _put_upload(tmp_path, "https://oss.example.com/upload") + + uploaded_data = mock_put.call_args.kwargs.get("data") + self.assertIsNotNone(uploaded_data) + self.assertNotEqual(uploaded_data, original_data) + # 解密后应恢复原始数据 + self.assertEqual(_xor_transform(uploaded_data), original_data) + finally: + tmp_path.unlink(missing_ok=True) + + @patch("unilabos.app.model_upload.requests.put") + def test_xacro_file_not_encrypted(self, mock_put): + """XACRO 文件上传时不加密。""" + from unilabos.app.model_upload import _put_upload + + original_data = b'' + with tempfile.NamedTemporaryFile(suffix=".xacro", delete=False) as f: + f.write(original_data) + f.flush() + tmp_path = Path(f.name) + + try: + mock_put.return_value = MagicMock(status_code=200) + mock_put.return_value.raise_for_status = MagicMock() + _put_upload(tmp_path, "https://oss.example.com/upload") + + uploaded_data = mock_put.call_args.kwargs.get("data") + self.assertEqual(uploaded_data, original_data) + finally: + tmp_path.unlink(missing_ok=True) + + @patch("unilabos.app.model_upload.requests.put") + def test_all_mesh_formats_encrypted(self, mock_put): + """所有 mesh 格式上传前都加密。""" + from unilabos.app.model_upload import _put_upload + + original_data = b"test mesh binary data content" + for ext in [".stl", ".dae", ".obj", ".fbx", ".gltf", ".glb"]: + with tempfile.NamedTemporaryFile(suffix=ext, delete=False) as f: + f.write(original_data) + f.flush() + tmp_path = Path(f.name) + try: + mock_put.reset_mock() + mock_put.return_value = MagicMock(status_code=200) + mock_put.return_value.raise_for_status = MagicMock() + _put_upload(tmp_path, "https://oss.example.com/upload") + + uploaded_data = mock_put.call_args.kwargs.get("data") + self.assertNotEqual(uploaded_data, original_data, f"{ext} 文件应被加密") + finally: + tmp_path.unlink(missing_ok=True) + + @patch("unilabos.app.model_upload.requests.put") + def test_uppercase_extension_encrypted(self, mock_put): + """大写扩展名 .STL 也被加密(大小写不敏感)。""" + from unilabos.app.model_upload import _put_upload + + original_data = b"uppercase ext test" + with tempfile.NamedTemporaryFile(suffix=".STL", delete=False) as f: + f.write(original_data) + f.flush() + tmp_path = Path(f.name) + try: + mock_put.return_value = MagicMock(status_code=200) + mock_put.return_value.raise_for_status = MagicMock() + _put_upload(tmp_path, "https://oss.example.com/upload") + + uploaded_data = mock_put.call_args.kwargs.get("data") + self.assertNotEqual(uploaded_data, original_data) + finally: + tmp_path.unlink(missing_ok=True) + + +class TestDownloadFileDecryption(unittest.TestCase): + """_download_file 中的条件解密测试。""" + + @patch("unilabos.app.model_upload.requests.get") + def test_mesh_file_decrypted_on_download(self, mock_get): + """下载的 mesh 文件自动 XOR 解密后存本地。""" + from unilabos.app.model_upload import _download_file + + original_data = b"original stl content here" + encrypted_data = _xor_transform(original_data) + + mock_response = MagicMock() + mock_response.content = encrypted_data + mock_response.raise_for_status = MagicMock() + mock_get.return_value = mock_response + + with tempfile.TemporaryDirectory() as tmpdir: + local_path = Path(tmpdir) / "model.stl" + _download_file("https://oss.example.com/model.stl", local_path) + self.assertEqual(local_path.read_bytes(), original_data) + + @patch("unilabos.app.model_upload.requests.get") + def test_xacro_file_not_decrypted(self, mock_get): + """下载的 XACRO 文件不做解密处理。""" + from unilabos.app.model_upload import _download_file + + xml_data = b'' + + mock_response = MagicMock() + mock_response.content = xml_data + mock_response.raise_for_status = MagicMock() + mock_get.return_value = mock_response + + with tempfile.TemporaryDirectory() as tmpdir: + local_path = Path(tmpdir) / "macro.xacro" + _download_file("https://oss.example.com/macro.xacro", local_path) + self.assertEqual(local_path.read_bytes(), xml_data) + + @patch("unilabos.app.model_upload.requests.get") + def test_upload_download_roundtrip(self, mock_get): + """上传加密 → 下载解密的完整 round-trip。""" + from unilabos.app.model_upload import _download_file + + original_data = b"binary stl mesh \x00\xff\x80 special bytes" + encrypted_data = _xor_transform(original_data) + + mock_response = MagicMock() + mock_response.content = encrypted_data + mock_response.raise_for_status = MagicMock() + mock_get.return_value = mock_response + + with tempfile.TemporaryDirectory() as tmpdir: + local_path = Path(tmpdir) / "mesh.stl" + _download_file("https://oss.example.com/mesh.stl", local_path) + self.assertEqual(local_path.read_bytes(), original_data) + + @patch("unilabos.app.model_upload.requests.get") + def test_all_mesh_formats_decrypted(self, mock_get): + """所有 mesh 格式下载后都解密。""" + from unilabos.app.model_upload import _download_file + + original_data = b"mesh content for roundtrip" + encrypted_data = _xor_transform(original_data) + + for ext in [".stl", ".dae", ".obj", ".fbx", ".gltf", ".glb"]: + mock_response = MagicMock() + mock_response.content = encrypted_data + mock_response.raise_for_status = MagicMock() + mock_get.return_value = mock_response + + with tempfile.TemporaryDirectory() as tmpdir: + local_path = Path(tmpdir) / f"model{ext}" + _download_file(f"https://oss.example.com/model{ext}", local_path) + self.assertEqual( + local_path.read_bytes(), original_data, f"{ext} 文件应被解密" + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/app/test_normalize_model.py b/tests/app/test_normalize_model.py new file mode 100644 index 000000000..0d45f3b5c --- /dev/null +++ b/tests/app/test_normalize_model.py @@ -0,0 +1,170 @@ +"""normalize_model_for_upload 单元测试""" + +import unittest +import sys +import os + +# 添加项目根目录到 sys.path +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..")) + +from unilabos.app.register import normalize_model_for_upload + + +class TestNormalizeModelForUpload(unittest.TestCase): + """测试 Registry YAML model 字段标准化""" + + def test_empty_input(self): + """空 dict 直接返回""" + self.assertEqual(normalize_model_for_upload({}), {}) + self.assertIsNone(normalize_model_for_upload(None)) + + def test_format_infer_xacro(self): + """自动从 path 后缀推断 format=xacro""" + model = { + "path": "https://oss.example.com/devices/arm/macro_device.xacro", + "mesh": "arm_slider", + "type": "device", + } + result = normalize_model_for_upload(model) + self.assertEqual(result["format"], "xacro") + + def test_format_infer_urdf(self): + """自动推断 format=urdf""" + model = {"path": "https://example.com/robot.urdf", "type": "device"} + result = normalize_model_for_upload(model) + self.assertEqual(result["format"], "urdf") + + def test_format_infer_stl(self): + """自动推断 format=stl""" + model = {"path": "https://example.com/part.stl"} + result = normalize_model_for_upload(model) + self.assertEqual(result["format"], "stl") + + def test_format_infer_gltf(self): + """自动推断 format=gltf(.gltf 和 .glb)""" + for ext in [".gltf", ".glb"]: + model = {"path": f"https://example.com/model{ext}"} + result = normalize_model_for_upload(model) + self.assertEqual(result["format"], "gltf", f"failed for {ext}") + + def test_format_not_overwritten(self): + """已有 format 字段时不覆盖""" + model = { + "path": "https://example.com/model.xacro", + "format": "custom", + } + result = normalize_model_for_upload(model) + self.assertEqual(result["format"], "custom") + + def test_format_no_path(self): + """没有 path 时不推断 format""" + model = {"mesh": "arm_slider", "type": "device"} + result = normalize_model_for_upload(model) + self.assertNotIn("format", result) + + def test_children_mesh_string_to_struct(self): + """将 children_mesh 字符串(旧格式)转为结构化对象""" + model = { + "path": "https://example.com/rack.xacro", + "type": "resource", + "children_mesh": "tip/meshes/tip.stl", + "children_mesh_tf": [0.0045, 0.0045, 0, 0, 0, 1.57], + "children_mesh_path": "https://oss.example.com/tip.stl", + } + result = normalize_model_for_upload(model) + + cm = result["children_mesh"] + self.assertIsInstance(cm, dict) + self.assertEqual(cm["path"], "https://oss.example.com/tip.stl") + self.assertEqual(cm["format"], "stl") + self.assertTrue(cm["default_visible"]) + self.assertEqual(cm["local_offset"], [0.0045, 0.0045, 0]) + self.assertEqual(cm["local_rotation"], [0, 0, 1.57]) + + self.assertNotIn("children_mesh_tf", result) + self.assertNotIn("children_mesh_path", result) + + def test_children_mesh_no_oss_fallback(self): + """children_mesh 无 OSS URL 时 fallback 到本地路径""" + model = { + "path": "https://example.com/rack.xacro", + "children_mesh": "plate_96/meshes/plate_96.stl", + } + result = normalize_model_for_upload(model) + cm = result["children_mesh"] + self.assertEqual(cm["path"], "plate_96/meshes/plate_96.stl") + self.assertEqual(cm["format"], "stl") + + def test_children_mesh_gltf_format(self): + """children_mesh .glb 文件推断 format=gltf""" + model = { + "path": "https://example.com/rack.xacro", + "children_mesh": "meshes/child.glb", + } + result = normalize_model_for_upload(model) + self.assertEqual(result["children_mesh"]["format"], "gltf") + + def test_children_mesh_partial_tf(self): + """children_mesh_tf 只有 3 个值时只有 offset 无 rotation""" + model = { + "path": "https://example.com/rack.xacro", + "children_mesh": "tip.stl", + "children_mesh_tf": [0.01, 0.02, 0.03], + } + result = normalize_model_for_upload(model) + cm = result["children_mesh"] + self.assertEqual(cm["local_offset"], [0.01, 0.02, 0.03]) + self.assertNotIn("local_rotation", cm) + + def test_children_mesh_no_tf(self): + """children_mesh 无 tf 时不加 offset/rotation""" + model = { + "path": "https://example.com/rack.xacro", + "children_mesh": "tip.stl", + } + result = normalize_model_for_upload(model) + cm = result["children_mesh"] + self.assertNotIn("local_offset", cm) + self.assertNotIn("local_rotation", cm) + + def test_children_mesh_already_dict(self): + """children_mesh 已经是 dict 时不重新映射""" + model = { + "path": "https://example.com/rack.xacro", + "children_mesh": { + "path": "https://example.com/tip.stl", + "format": "stl", + "default_visible": False, + }, + } + result = normalize_model_for_upload(model) + cm = result["children_mesh"] + self.assertIsInstance(cm, dict) + self.assertFalse(cm["default_visible"]) + + def test_original_not_mutated(self): + """原始 dict 不被修改""" + original = { + "path": "https://example.com/model.xacro", + "mesh": "arm", + } + original_copy = {**original} + normalize_model_for_upload(original) + self.assertEqual(original, original_copy) + + def test_preserves_existing_fields(self): + """所有原始字段都被保留""" + model = { + "path": "https://example.com/model.xacro", + "mesh": "arm_slider", + "type": "device", + "mesh_tf": [0, 0, 0, 0, 0, 0], + "custom_field": "should_survive", + } + result = normalize_model_for_upload(model) + self.assertEqual(result["custom_field"], "should_survive") + self.assertEqual(result["mesh_tf"], [0, 0, 0, 0, 0, 0]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/ros/test_joint_state_bridge.py b/tests/ros/test_joint_state_bridge.py new file mode 100644 index 000000000..8bbf8c3de --- /dev/null +++ b/tests/ros/test_joint_state_bridge.py @@ -0,0 +1,939 @@ +""" +P1 关节数据 & 资源跟随桥接测试 — 全面覆盖 HostNode 关节回调 + resource_pose 回调的边缘 case。 + +不依赖 ROS2 运行时,通过 mock 模拟 msg 和 bridge。 + +测试分组: + E1: JointRepublisher JSON 输出格式 (已修复 str→json.dumps) + E2: 关节状态回调 — 从 /joint_states (JointState msg) 直接读取 name/position + E3: 资源跟随 (resource_pose) — 夹爪抓取/释放/多资源 + E4: 联合流程 — 关节 + 资源一并通过 bridge 发送 + E5: Bridge 调用验证 + E6: 同类型设备多实例 — 重复关节名场景 + E7: 吞吐优化 — 死区过滤、抑频、增量 resource_poses +""" + +import json +import time +import pytest +from unittest.mock import MagicMock +from types import SimpleNamespace +from typing import Dict, Optional + + +# ==================== 辅助: 模拟 JointState msg ==================== + + +def _make_joint_state_msg(names: list, positions: list, velocities=None, efforts=None): + """构造模拟的 sensor_msgs/JointState 消息(不依赖 ROS2)""" + msg = SimpleNamespace() + msg.name = names + msg.position = positions + msg.velocity = velocities or [0.0] * len(names) + msg.effort = efforts or [0.0] * len(names) + return msg + + +def _make_string_msg(data: str): + """构造模拟的 std_msgs/String 消息""" + msg = SimpleNamespace() + msg.data = data + return msg + + +# ==================== 辅助: 提取 HostNode 核心逻辑用于隔离测试 ==================== + + +class JointBridgeSimulator: + """ + 模拟 HostNode 的关节桥接核心逻辑(提取自 host_node.py), + 不依赖 ROS2 Node、subscription 等基础设施。 + + 包含吞吐优化逻辑: + - 死区过滤 (dead band): 关节变化 < 阈值时不发送 + - 抑频 (throttle): 限制最大发送频率 + - 增量 resource_poses: 仅在变化时附带 + """ + + JOINT_DEAD_BAND: float = 1e-4 + JOINT_MIN_INTERVAL: float = 0.05 # 秒 + + def __init__(self, device_uuid_map: Dict[str, str], + dead_band: Optional[float] = None, + min_interval: Optional[float] = None): + self.device_uuid_map = device_uuid_map + self._device_ids_sorted = sorted(device_uuid_map.keys(), key=len, reverse=True) + self._resource_poses: Dict[str, str] = {} + self._resource_poses_dirty: bool = False + self._last_joint_values: Dict[str, float] = {} + self._last_send_time: float = -float("inf") # 确保首条消息总是通过 + # 允许测试覆盖优化参数 + if dead_band is not None: + self.JOINT_DEAD_BAND = dead_band + if min_interval is not None: + self.JOINT_MIN_INTERVAL = min_interval + + def resource_pose_callback(self, msg) -> None: + """模拟 HostNode._resource_pose_callback(含变化检测)""" + try: + data = json.loads(msg.data) + except (json.JSONDecodeError, ValueError): + return + if not isinstance(data, dict) or not data: + return + has_change = False + for k, v in data.items(): + if self._resource_poses.get(k) != v: + has_change = True + break + if has_change: + self._resource_poses.update(data) + self._resource_poses_dirty = True + + def joint_state_callback(self, msg, now: Optional[float] = None) -> dict: + """ + 模拟 HostNode._joint_state_callback 核心逻辑(含优化)。 + now 参数允许测试控制时间。 + 返回 {device_id: {"node_uuid": ..., "joint_states": {...}, "resource_poses": {...}}}。 + 返回 {} 表示被优化过滤。 + """ + names = list(msg.name) + positions = list(msg.position) + if not names or len(names) != len(positions): + return {} + + if now is None: + now = time.time() + resource_dirty = self._resource_poses_dirty + + # 抑频检查 + if not resource_dirty and (now - self._last_send_time) < self.JOINT_MIN_INTERVAL: + return {} + + # 死区过滤 + has_significant_change = False + for name, pos in zip(names, positions): + last_val = self._last_joint_values.get(name) + if last_val is None or abs(float(pos) - last_val) >= self.JOINT_DEAD_BAND: + has_significant_change = True + break + + if not has_significant_change and not resource_dirty: + return {} + + # 更新状态 + for name, pos in zip(names, positions): + self._last_joint_values[name] = float(pos) + self._last_send_time = now + + # 按设备 ID 分组关节数据 + device_joints: Dict[str, Dict[str, float]] = {} + for name, pos in zip(names, positions): + matched_device = None + for device_id in self._device_ids_sorted: + if name.startswith(device_id + "_"): + matched_device = device_id + break + if matched_device: + if matched_device not in device_joints: + device_joints[matched_device] = {} + device_joints[matched_device][name] = float(pos) + elif len(self.device_uuid_map) == 1: + fallback_id = self._device_ids_sorted[0] + if fallback_id not in device_joints: + device_joints[fallback_id] = {} + device_joints[fallback_id][name] = float(pos) + + # 构建设备级 resource_poses(仅 dirty 时附带) + device_resource_poses: Dict[str, Dict[str, str]] = {} + if resource_dirty: + for resource_id, link_name in self._resource_poses.items(): + matched_device = None + for device_id in self._device_ids_sorted: + if link_name.startswith(device_id + "_"): + matched_device = device_id + break + if matched_device: + if matched_device not in device_resource_poses: + device_resource_poses[matched_device] = {} + device_resource_poses[matched_device][resource_id] = link_name + elif len(self.device_uuid_map) == 1: + fallback_id = self._device_ids_sorted[0] + if fallback_id not in device_resource_poses: + device_resource_poses[fallback_id] = {} + device_resource_poses[fallback_id][resource_id] = link_name + self._resource_poses_dirty = False + + result = {} + for device_id, joint_states in device_joints.items(): + node_uuid = self.device_uuid_map.get(device_id) + if not node_uuid: + continue + result[device_id] = { + "node_uuid": node_uuid, + "joint_states": joint_states, + "resource_poses": device_resource_poses.get(device_id, {}), + } + return result + + +# 功能测试中禁用优化(dead_band=0, min_interval=0),确保逻辑正确性 +def _make_sim(device_uuid_map: Dict[str, str]) -> JointBridgeSimulator: + """创建禁用吞吐优化的模拟器(用于功能正确性测试)""" + return JointBridgeSimulator(device_uuid_map, dead_band=0.0, min_interval=0.0) + + +# ==================== E1: JointRepublisher JSON 输出 ==================== + + +class TestJointRepublisherFormat: + """验证 JointRepublisher 输出标准 JSON(双引号)而非 Python repr(单引号)""" + + def test_output_is_valid_json(self): + """str() 产生单引号,json.dumps() 产生双引号""" + joint_dict = { + "name": ["joint1", "joint2"], + "position": [0.1, 0.2], + "velocity": [0.0, 0.0], + "effort": [0.0, 0.0], + } + result = json.dumps(joint_dict) + parsed = json.loads(result) + assert parsed["name"] == ["joint1", "joint2"] + assert parsed["position"] == [0.1, 0.2] + assert "'" not in result + + def test_str_produces_invalid_json(self): + """对比: str() 不是合法 JSON""" + joint_dict = {"name": ["joint1"], "position": [0.1]} + result = str(joint_dict) + with pytest.raises(json.JSONDecodeError): + json.loads(result) + + +# ==================== E2: 关节状态回调(JointState msg 直接读取)==================== + + +class TestJointStateCallback: + """测试从 JointState msg 直接读取 name/position 的分组逻辑""" + + def test_single_device_simple(self): + """单设备,关节名有设备前缀""" + sim = _make_sim({"panda": "uuid-panda"}) + msg = _make_joint_state_msg( + ["panda_joint1", "panda_joint2"], [0.5, 1.0] + ) + result = sim.joint_state_callback(msg) + assert "panda" in result + assert result["panda"]["joint_states"]["panda_joint1"] == 0.5 + assert result["panda"]["joint_states"]["panda_joint2"] == 1.0 + + def test_single_device_no_prefix_fallback(self): + """单设备,关节名无设备前缀 → 应归入唯一设备""" + sim = _make_sim({"robot1": "uuid-r1"}) + msg = _make_joint_state_msg(["joint_a", "joint_b"], [1.0, 2.0]) + result = sim.joint_state_callback(msg) + assert "robot1" in result + assert result["robot1"]["joint_states"]["joint_a"] == 1.0 + assert result["robot1"]["joint_states"]["joint_b"] == 2.0 + + def test_multi_device_distinct_prefixes(self): + """多设备,不同前缀,正确分组""" + sim = _make_sim({"arm1": "uuid-arm1", "arm2": "uuid-arm2"}) + msg = _make_joint_state_msg( + ["arm1_j1", "arm1_j2", "arm2_j1", "arm2_j2"], + [0.1, 0.2, 0.3, 0.4], + ) + result = sim.joint_state_callback(msg) + assert result["arm1"]["joint_states"]["arm1_j1"] == 0.1 + assert result["arm1"]["joint_states"]["arm1_j2"] == 0.2 + assert result["arm2"]["joint_states"]["arm2_j1"] == 0.3 + assert result["arm2"]["joint_states"]["arm2_j2"] == 0.4 + + def test_ambiguous_prefix_longest_wins(self): + """前缀歧义: arm 和 arm_left — 最长前缀优先""" + sim = _make_sim({"arm": "uuid-arm", "arm_left": "uuid-arm-left"}) + msg = _make_joint_state_msg( + ["arm_j1", "arm_left_j1", "arm_left_j2"], + [0.1, 0.2, 0.3], + ) + result = sim.joint_state_callback(msg) + assert result["arm"]["joint_states"]["arm_j1"] == 0.1 + assert result["arm_left"]["joint_states"]["arm_left_j1"] == 0.2 + assert result["arm_left"]["joint_states"]["arm_left_j2"] == 0.3 + + def test_multi_device_unmatched_joints_dropped(self): + """多设备时,无法匹配前缀的关节应被丢弃(不 fallback)""" + sim = _make_sim({"arm1": "uuid-arm1", "arm2": "uuid-arm2"}) + msg = _make_joint_state_msg( + ["arm1_j1", "unknown_j1"], + [0.1, 0.9], + ) + result = sim.joint_state_callback(msg) + assert result["arm1"]["joint_states"]["arm1_j1"] == 0.1 + for device_id, data in result.items(): + assert "unknown_j1" not in data["joint_states"] + + def test_empty_names(self): + """空 name 列表""" + sim = _make_sim({"dev": "uuid-dev"}) + msg = _make_joint_state_msg([], []) + result = sim.joint_state_callback(msg) + assert result == {} + + def test_mismatched_lengths(self): + """name 和 position 长度不一致""" + sim = _make_sim({"dev": "uuid-dev"}) + msg = _make_joint_state_msg(["j1", "j2"], [0.1]) + result = sim.joint_state_callback(msg) + assert result == {} + + def test_no_devices(self): + """无设备 UUID 映射""" + sim = _make_sim({}) + msg = _make_joint_state_msg(["j1"], [0.1]) + result = sim.joint_state_callback(msg) + assert result == {} + + def test_numeric_prefix_device_ids(self): + """数字化设备 ID (如 deck1, deck12) — deck12_slot1 不应匹配 deck1""" + sim = _make_sim({"deck1": "uuid-d1", "deck12": "uuid-d12"}) + msg = _make_joint_state_msg( + ["deck1_slot1", "deck12_slot1"], + [1.0, 2.0], + ) + result = sim.joint_state_callback(msg) + assert result["deck1"]["joint_states"]["deck1_slot1"] == 1.0 + assert result["deck12"]["joint_states"]["deck12_slot1"] == 2.0 + + def test_position_float_conversion(self): + """position 值应强制转为 float(即使输入为 int)""" + sim = _make_sim({"arm": "uuid-arm"}) + msg = _make_joint_state_msg(["arm_j1"], [1]) + result = sim.joint_state_callback(msg) + assert result["arm"]["joint_states"]["arm_j1"] == 1.0 + assert isinstance(result["arm"]["joint_states"]["arm_j1"], float) + + def test_node_uuid_in_result(self): + """结果中应携带正确的 node_uuid""" + sim = _make_sim({"panda": "uuid-panda-123"}) + msg = _make_joint_state_msg(["panda_j1"], [0.5]) + result = sim.joint_state_callback(msg) + assert result["panda"]["node_uuid"] == "uuid-panda-123" + + def test_device_with_no_uuid_skipped(self): + """device_uuid_map 中存在映射但值为空 → 跳过""" + sim = _make_sim({"arm": ""}) + msg = _make_joint_state_msg(["arm_j1"], [0.5]) + result = sim.joint_state_callback(msg) + assert result == {} + + def test_many_joints_single_device(self): + """单设备大量关节(如 7-DOF arm)""" + sim = _make_sim({"panda": "uuid-panda"}) + names = [f"panda_joint{i}" for i in range(1, 8)] + positions = [float(i) * 0.1 for i in range(1, 8)] + msg = _make_joint_state_msg(names, positions) + result = sim.joint_state_callback(msg) + assert len(result["panda"]["joint_states"]) == 7 + assert result["panda"]["joint_states"]["panda_joint7"] == pytest.approx(0.7) + + def test_duplicate_joint_names_last_wins(self): + """同类型设备多个实例时,如果关节名完全重复(bug 场景),后出现的值覆盖前者""" + sim = _make_sim({"dev": "uuid-dev"}) + msg = _make_joint_state_msg(["dev_j1", "dev_j1"], [1.0, 2.0]) + result = sim.joint_state_callback(msg) + assert result["dev"]["joint_states"]["dev_j1"] == 2.0 + + def test_negative_positions(self): + """关节角度为负数""" + sim = _make_sim({"arm": "uuid-arm"}) + msg = _make_joint_state_msg(["arm_j1", "arm_j2"], [-1.57, -3.14]) + result = sim.joint_state_callback(msg) + assert result["arm"]["joint_states"]["arm_j1"] == pytest.approx(-1.57) + assert result["arm"]["joint_states"]["arm_j2"] == pytest.approx(-3.14) + + +# ==================== E3: 资源跟随 (resource_pose) ==================== + + +class TestResourcePoseCallback: + """测试 resource_pose 回调 — 夹爪抓取/释放/多资源""" + + def test_single_resource_attach(self): + """单个资源挂载到夹爪 link""" + sim = _make_sim({"panda": "uuid-panda"}) + msg = _make_string_msg(json.dumps({"plate_1": "panda_gripper_link"})) + sim.resource_pose_callback(msg) + assert sim._resource_poses == {"plate_1": "panda_gripper_link"} + assert sim._resource_poses_dirty is True + + def test_multiple_resource_attach(self): + """多个资源同时挂载到不同 link""" + sim = _make_sim({"panda": "uuid-panda"}) + msg = _make_string_msg(json.dumps({ + "plate_1": "panda_gripper_link", + "tip_rack": "panda_deck_link", + })) + sim.resource_pose_callback(msg) + assert sim._resource_poses["plate_1"] == "panda_gripper_link" + assert sim._resource_poses["tip_rack"] == "panda_deck_link" + + def test_incremental_update(self): + """增量更新:新消息合并到已有状态""" + sim = _make_sim({"panda": "uuid-panda"}) + sim.resource_pose_callback(_make_string_msg(json.dumps({"plate_1": "panda_deck_link"}))) + sim.resource_pose_callback(_make_string_msg(json.dumps({"plate_2": "panda_gripper_link"}))) + assert len(sim._resource_poses) == 2 + assert sim._resource_poses["plate_1"] == "panda_deck_link" + assert sim._resource_poses["plate_2"] == "panda_gripper_link" + + def test_resource_reattach(self): + """资源从 deck 移动到 gripper(抓取操作)""" + sim = _make_sim({"panda": "uuid-panda"}) + sim.resource_pose_callback(_make_string_msg(json.dumps({"plate_1": "panda_deck_link"}))) + assert sim._resource_poses["plate_1"] == "panda_deck_link" + sim.resource_pose_callback(_make_string_msg(json.dumps({"plate_1": "panda_gripper_link"}))) + assert sim._resource_poses["plate_1"] == "panda_gripper_link" + + def test_resource_release_back_to_world(self): + """释放资源回到 world""" + sim = _make_sim({"panda": "uuid-panda"}) + sim.resource_pose_callback(_make_string_msg(json.dumps({"plate_1": "panda_gripper_link"}))) + sim.resource_pose_callback(_make_string_msg(json.dumps({"plate_1": "world"}))) + assert sim._resource_poses["plate_1"] == "world" + + def test_empty_dict_heartbeat_no_dirty(self): + """空 dict(心跳包)不标记 dirty""" + sim = _make_sim({"panda": "uuid-panda"}) + sim.resource_pose_callback(_make_string_msg(json.dumps({"plate_1": "panda_link"}))) + sim._resource_poses_dirty = False # 重置 + sim.resource_pose_callback(_make_string_msg(json.dumps({}))) + assert sim._resource_poses_dirty is False # 空 dict 不应标记 dirty + + def test_same_value_no_dirty(self): + """重复发送相同值不应标记 dirty""" + sim = _make_sim({"panda": "uuid-panda"}) + sim.resource_pose_callback(_make_string_msg(json.dumps({"plate_1": "panda_link"}))) + sim._resource_poses_dirty = False + sim.resource_pose_callback(_make_string_msg(json.dumps({"plate_1": "panda_link"}))) + assert sim._resource_poses_dirty is False + + def test_invalid_json_ignored(self): + """非法 JSON 消息不影响状态""" + sim = _make_sim({"panda": "uuid-panda"}) + sim.resource_pose_callback(_make_string_msg(json.dumps({"plate_1": "panda_link"}))) + sim.resource_pose_callback(_make_string_msg("not valid json {{{")) + assert sim._resource_poses["plate_1"] == "panda_link" + + def test_non_dict_json_ignored(self): + """JSON 但不是 dict(如 list)应被忽略""" + sim = _make_sim({"panda": "uuid-panda"}) + sim.resource_pose_callback(_make_string_msg(json.dumps(["not", "a", "dict"]))) + assert sim._resource_poses == {} + + def test_python_repr_ignored(self): + """Python repr 格式(单引号)应被忽略""" + sim = _make_sim({"panda": "uuid-panda"}) + sim.resource_pose_callback(_make_string_msg("{'plate_1': 'panda_link'}")) + assert sim._resource_poses == {} + + def test_multi_device_resource_attach(self): + """多设备场景:不同设备的 link 挂载不同资源""" + sim = _make_sim({"arm1": "uuid-arm1", "arm2": "uuid-arm2"}) + sim.resource_pose_callback(_make_string_msg(json.dumps({ + "plate_A": "arm1_gripper_link", + "plate_B": "arm2_gripper_link", + }))) + assert sim._resource_poses["plate_A"] == "arm1_gripper_link" + assert sim._resource_poses["plate_B"] == "arm2_gripper_link" + + +# ==================== E4: 联合流程 — 关节 + 资源一并通过 bridge ==================== + + +class TestJointWithResourcePoses: + """测试关节状态回调时,resource_poses 被正确按设备分组并包含在结果中""" + + def test_single_device_joint_with_resource(self): + """单设备:关节更新时携带已挂载的资源""" + sim = _make_sim({"panda": "uuid-panda"}) + sim.resource_pose_callback(_make_string_msg(json.dumps({ + "plate_1": "panda_gripper_link", + }))) + msg = _make_joint_state_msg(["panda_j1", "panda_j2"], [0.5, 1.0]) + result = sim.joint_state_callback(msg) + assert result["panda"]["resource_poses"] == {"plate_1": "panda_gripper_link"} + + def test_single_device_no_resource(self): + """单设备:无资源挂载时 resource_poses 为空 dict""" + sim = _make_sim({"panda": "uuid-panda"}) + msg = _make_joint_state_msg(["panda_j1"], [0.5]) + result = sim.joint_state_callback(msg) + assert result["panda"]["resource_poses"] == {} + + def test_multi_device_resource_routing(self): + """多设备:资源按 link 前缀路由到正确设备""" + sim = _make_sim({"arm1": "uuid-arm1", "arm2": "uuid-arm2"}) + sim.resource_pose_callback(_make_string_msg(json.dumps({ + "plate_A": "arm1_gripper_link", + "plate_B": "arm2_gripper_link", + "tube_1": "arm1_tool_link", + }))) + msg = _make_joint_state_msg( + ["arm1_j1", "arm2_j1"], + [0.1, 0.2], + ) + result = sim.joint_state_callback(msg) + assert result["arm1"]["resource_poses"] == { + "plate_A": "arm1_gripper_link", + "tube_1": "arm1_tool_link", + } + assert result["arm2"]["resource_poses"] == {"plate_B": "arm2_gripper_link"} + + def test_resource_on_world_frame_not_routed(self): + """资源挂在 world frame(已释放)— 多设备时无法匹配任何设备前缀""" + sim = _make_sim({"arm1": "uuid-arm1", "arm2": "uuid-arm2"}) + sim.resource_pose_callback(_make_string_msg(json.dumps({ + "plate_A": "world", + }))) + msg = _make_joint_state_msg(["arm1_j1"], [0.1]) + result = sim.joint_state_callback(msg) + assert result["arm1"]["resource_poses"] == {} + + def test_resource_world_frame_single_device_fallback(self): + """单设备时 world frame 的资源走 fallback""" + sim = _make_sim({"panda": "uuid-panda"}) + sim.resource_pose_callback(_make_string_msg(json.dumps({ + "plate_A": "world", + }))) + msg = _make_joint_state_msg(["panda_j1"], [0.1]) + result = sim.joint_state_callback(msg) + assert result["panda"]["resource_poses"] == {"plate_A": "world"} + + def test_grab_and_move_sequence(self): + """完整夹取序列: 资源在 deck → gripper 抓取 → arm 移动 → 放下""" + sim = _make_sim({"panda": "uuid-panda"}) + + # 初始: plate 在 deck + sim.resource_pose_callback(_make_string_msg(json.dumps({ + "plate_1": "panda_deck_third_link", + }))) + + msg = _make_joint_state_msg( + ["panda_j1", "panda_j2", "panda_j3"], + [0.0, -0.5, 1.0], + ) + result = sim.joint_state_callback(msg) + assert result["panda"]["resource_poses"]["plate_1"] == "panda_deck_third_link" + + # 抓取: plate 从 deck → gripper + sim.resource_pose_callback(_make_string_msg(json.dumps({ + "plate_1": "panda_gripper_link", + }))) + + msg = _make_joint_state_msg( + ["panda_j1", "panda_j2", "panda_j3"], + [1.57, 0.0, -0.5], + ) + result = sim.joint_state_callback(msg) + assert result["panda"]["resource_poses"]["plate_1"] == "panda_gripper_link" + assert result["panda"]["joint_states"]["panda_j1"] == pytest.approx(1.57) + + # 放下: plate 从 gripper → 目标 deck + sim.resource_pose_callback(_make_string_msg(json.dumps({ + "plate_1": "panda_deck_first_link", + }))) + + msg = _make_joint_state_msg( + ["panda_j1", "panda_j2", "panda_j3"], + [0.0, 0.0, 0.0], + ) + result = sim.joint_state_callback(msg) + assert result["panda"]["resource_poses"]["plate_1"] == "panda_deck_first_link" + + def test_simultaneous_grab_multiple_resources(self): + """同时持有多个资源(如双夹爪)""" + sim = _make_sim({"panda": "uuid-panda"}) + sim.resource_pose_callback(_make_string_msg(json.dumps({ + "plate_1": "panda_left_gripper", + "plate_2": "panda_right_gripper", + "tip_rack": "panda_deck_link", + }))) + msg = _make_joint_state_msg(["panda_j1"], [0.5]) + result = sim.joint_state_callback(msg) + assert len(result["panda"]["resource_poses"]) == 3 + + def test_resource_with_ambiguous_link_prefix(self): + """link 前缀歧义: arm_left_gripper 应匹配 arm_left 而非 arm""" + sim = _make_sim({"arm": "uuid-arm", "arm_left": "uuid-arm-left"}) + sim.resource_pose_callback(_make_string_msg(json.dumps({ + "plate_A": "arm_gripper_link", + "plate_B": "arm_left_gripper_link", + }))) + msg = _make_joint_state_msg( + ["arm_j1", "arm_left_j1"], + [0.1, 0.2], + ) + result = sim.joint_state_callback(msg) + assert result["arm"]["resource_poses"] == {"plate_A": "arm_gripper_link"} + assert result["arm_left"]["resource_poses"] == {"plate_B": "arm_left_gripper_link"} + + +# ==================== E5: Bridge 调用验证 ==================== + + +class TestBridgeCalls: + """验证完整桥接流: callback → bridge.publish_joint_state 调用""" + + def test_bridge_called_per_device(self): + """每个设备调用一次 publish_joint_state""" + device_uuid_map = {"arm1": "uuid-111", "arm2": "uuid-222"} + sim = _make_sim(device_uuid_map) + bridge = MagicMock() + bridge.publish_joint_state = MagicMock() + + msg = _make_joint_state_msg( + ["arm1_j1", "arm2_j1"], + [1.0, 2.0], + ) + result = sim.joint_state_callback(msg) + + for device_id, data in result.items(): + bridge.publish_joint_state( + data["node_uuid"], data["joint_states"], data["resource_poses"] + ) + + assert bridge.publish_joint_state.call_count == 2 + call_uuids = {c[0][0] for c in bridge.publish_joint_state.call_args_list} + assert call_uuids == {"uuid-111", "uuid-222"} + + def test_bridge_called_with_resource_poses(self): + """bridge 调用时携带 resource_poses""" + sim = _make_sim({"panda": "uuid-panda"}) + sim.resource_pose_callback(_make_string_msg(json.dumps({ + "plate_1": "panda_gripper_link", + }))) + + bridge = MagicMock() + msg = _make_joint_state_msg(["panda_j1"], [0.5]) + result = sim.joint_state_callback(msg) + + for device_id, data in result.items(): + bridge.publish_joint_state( + data["node_uuid"], data["joint_states"], data["resource_poses"] + ) + + bridge.publish_joint_state.assert_called_once_with( + "uuid-panda", + {"panda_j1": 0.5}, + {"plate_1": "panda_gripper_link"}, + ) + + def test_bridge_no_call_for_empty_joints(self): + """无关节数据时不调用 bridge""" + sim = _make_sim({"panda": "uuid-panda"}) + bridge = MagicMock() + + msg = _make_joint_state_msg([], []) + result = sim.joint_state_callback(msg) + + for device_id, data in result.items(): + bridge.publish_joint_state( + data["node_uuid"], data["joint_states"], data["resource_poses"] + ) + + bridge.publish_joint_state.assert_not_called() + + def test_bridge_resource_poses_empty_when_no_resources(self): + """无资源挂载时,resource_poses 参数为空 dict""" + sim = _make_sim({"panda": "uuid-panda"}) + bridge = MagicMock() + + msg = _make_joint_state_msg(["panda_j1"], [0.5]) + result = sim.joint_state_callback(msg) + + for device_id, data in result.items(): + bridge.publish_joint_state( + data["node_uuid"], data["joint_states"], data["resource_poses"] + ) + + bridge.publish_joint_state.assert_called_once_with( + "uuid-panda", + {"panda_j1": 0.5}, + {}, + ) + + def test_multi_bridge_all_called(self): + """多个 bridge 都应被调用""" + sim = _make_sim({"arm": "uuid-arm"}) + bridges = [MagicMock(), MagicMock()] + + msg = _make_joint_state_msg(["arm_j1"], [0.5]) + result = sim.joint_state_callback(msg) + + for device_id, data in result.items(): + for bridge in bridges: + bridge.publish_joint_state( + data["node_uuid"], data["joint_states"], data["resource_poses"] + ) + + for bridge in bridges: + bridge.publish_joint_state.assert_called_once() + + +# ==================== E6: 同类型设备多个实例 — 重复关节名场景 ==================== + + +class TestDuplicateDeviceTypes: + """ + 多个同类型设备(如 2 个 OT-2 移液器),关节名格式为 {device_id}_{joint_name}。 + 设备 ID 不同(如 ot2_left, ot2_right),但底层关节名相同(如 pipette_j1)。 + """ + + def test_same_type_different_id(self): + """同类型设备不同 ID""" + sim = _make_sim({ + "ot2_left": "uuid-ot2-left", + "ot2_right": "uuid-ot2-right", + }) + msg = _make_joint_state_msg( + ["ot2_left_pipette_j1", "ot2_left_pipette_j2", + "ot2_right_pipette_j1", "ot2_right_pipette_j2"], + [0.1, 0.2, 0.3, 0.4], + ) + result = sim.joint_state_callback(msg) + assert result["ot2_left"]["joint_states"]["ot2_left_pipette_j1"] == 0.1 + assert result["ot2_left"]["joint_states"]["ot2_left_pipette_j2"] == 0.2 + assert result["ot2_right"]["joint_states"]["ot2_right_pipette_j1"] == 0.3 + assert result["ot2_right"]["joint_states"]["ot2_right_pipette_j2"] == 0.4 + + def test_same_type_with_resources_routed_correctly(self): + """同类型设备各自抓取资源,按 link 前缀正确路由""" + sim = _make_sim({ + "ot2_left": "uuid-ot2-left", + "ot2_right": "uuid-ot2-right", + }) + sim.resource_pose_callback(_make_string_msg(json.dumps({ + "plate_A": "ot2_left_gripper", + "plate_B": "ot2_right_gripper", + }))) + msg = _make_joint_state_msg( + ["ot2_left_j1", "ot2_right_j1"], + [0.5, 0.6], + ) + result = sim.joint_state_callback(msg) + assert result["ot2_left"]["resource_poses"] == {"plate_A": "ot2_left_gripper"} + assert result["ot2_right"]["resource_poses"] == {"plate_B": "ot2_right_gripper"} + + def test_numbered_devices_no_confusion(self): + """编号设备: robot1 不应匹配 robot10 的关节""" + sim = _make_sim({ + "robot1": "uuid-r1", + "robot10": "uuid-r10", + }) + msg = _make_joint_state_msg( + ["robot1_j1", "robot10_j1"], + [1.0, 10.0], + ) + result = sim.joint_state_callback(msg) + assert result["robot1"]["joint_states"]["robot1_j1"] == 1.0 + assert result["robot10"]["joint_states"]["robot10_j1"] == 10.0 + + def test_three_same_type_devices(self): + """三个同类型设备""" + sim = _make_sim({ + "pump_a": "uuid-pa", + "pump_b": "uuid-pb", + "pump_c": "uuid-pc", + }) + msg = _make_joint_state_msg( + ["pump_a_flow", "pump_b_flow", "pump_c_flow", + "pump_a_pressure", "pump_b_pressure"], + [1.0, 2.0, 3.0, 0.1, 0.2], + ) + result = sim.joint_state_callback(msg) + assert len(result["pump_a"]["joint_states"]) == 2 + assert len(result["pump_b"]["joint_states"]) == 2 + assert len(result["pump_c"]["joint_states"]) == 1 + + +# ==================== E7: 吞吐优化测试 ==================== + + +class TestThroughputOptimizations: + """测试死区过滤、抑频、增量 resource_poses 等优化行为""" + + # --- 死区过滤 (Dead Band) --- + + def test_dead_band_filters_tiny_change(self): + """关节变化小于死区阈值 → 被过滤""" + sim = JointBridgeSimulator({"arm": "uuid-arm"}, dead_band=0.01, min_interval=0.0) + msg1 = _make_joint_state_msg(["arm_j1"], [1.0]) + result1 = sim.joint_state_callback(msg1, now=0.0) + assert "arm" in result1 + + # 微小变化 (0.001 < 0.01 死区) + msg2 = _make_joint_state_msg(["arm_j1"], [1.001]) + result2 = sim.joint_state_callback(msg2, now=1.0) + assert result2 == {} + + def test_dead_band_passes_significant_change(self): + """关节变化大于死区阈值 → 通过""" + sim = JointBridgeSimulator({"arm": "uuid-arm"}, dead_band=0.01, min_interval=0.0) + msg1 = _make_joint_state_msg(["arm_j1"], [1.0]) + sim.joint_state_callback(msg1, now=0.0) + + msg2 = _make_joint_state_msg(["arm_j1"], [1.05]) + result2 = sim.joint_state_callback(msg2, now=1.0) + assert "arm" in result2 + assert result2["arm"]["joint_states"]["arm_j1"] == pytest.approx(1.05) + + def test_dead_band_first_message_always_passes(self): + """首次消息总是通过(无历史值)""" + sim = JointBridgeSimulator({"arm": "uuid-arm"}, dead_band=1000.0, min_interval=0.0) + msg = _make_joint_state_msg(["arm_j1"], [0.001]) + result = sim.joint_state_callback(msg, now=0.0) + assert "arm" in result + + def test_dead_band_any_joint_change_triggers(self): + """多关节中只要有一个超过死区就全部发送""" + sim = JointBridgeSimulator({"arm": "uuid-arm"}, dead_band=0.01, min_interval=0.0) + msg1 = _make_joint_state_msg(["arm_j1", "arm_j2"], [1.0, 2.0]) + sim.joint_state_callback(msg1, now=0.0) + + # j1 微变化,j2 大变化 + msg2 = _make_joint_state_msg(["arm_j1", "arm_j2"], [1.001, 2.5]) + result2 = sim.joint_state_callback(msg2, now=1.0) + assert "arm" in result2 + # 两个关节的值都应包含在结果中 + assert result2["arm"]["joint_states"]["arm_j1"] == pytest.approx(1.001) + assert result2["arm"]["joint_states"]["arm_j2"] == pytest.approx(2.5) + + # --- 抑频 (Throttle) --- + + def test_throttle_filters_rapid_messages(self): + """发送间隔内的消息被过滤""" + sim = JointBridgeSimulator({"arm": "uuid-arm"}, dead_band=0.0, min_interval=0.1) + msg1 = _make_joint_state_msg(["arm_j1"], [1.0]) + result1 = sim.joint_state_callback(msg1, now=0.0) + assert "arm" in result1 + + # 0.05s < 0.1s 间隔 + msg2 = _make_joint_state_msg(["arm_j1"], [2.0]) + result2 = sim.joint_state_callback(msg2, now=0.05) + assert result2 == {} + + def test_throttle_passes_after_interval(self): + """超过发送间隔后消息通过""" + sim = JointBridgeSimulator({"arm": "uuid-arm"}, dead_band=0.0, min_interval=0.1) + msg1 = _make_joint_state_msg(["arm_j1"], [1.0]) + sim.joint_state_callback(msg1, now=0.0) + + msg2 = _make_joint_state_msg(["arm_j1"], [2.0]) + result2 = sim.joint_state_callback(msg2, now=0.15) + assert "arm" in result2 + + def test_throttle_bypassed_by_resource_change(self): + """resource_pose 变化时忽略抑频限制""" + sim = JointBridgeSimulator({"arm": "uuid-arm"}, dead_band=0.0, min_interval=1.0) + msg1 = _make_joint_state_msg(["arm_j1"], [1.0]) + sim.joint_state_callback(msg1, now=0.0) + + # 资源变化 → 强制发送 + sim.resource_pose_callback(_make_string_msg(json.dumps({"plate": "arm_gripper"}))) + msg2 = _make_joint_state_msg(["arm_j1"], [1.0]) + result2 = sim.joint_state_callback(msg2, now=0.01) # 远小于 1.0 间隔 + assert "arm" in result2 + assert result2["arm"]["resource_poses"] == {"plate": "arm_gripper"} + + # --- 增量 resource_poses --- + + def test_resource_poses_only_sent_when_dirty(self): + """resource_poses 仅在 dirty 时附带,否则为空""" + sim = _make_sim({"panda": "uuid-panda"}) + sim.resource_pose_callback(_make_string_msg(json.dumps({"plate": "panda_gripper"}))) + + # 第一次发送:dirty → 携带 resource_poses + msg1 = _make_joint_state_msg(["panda_j1"], [0.5]) + result1 = sim.joint_state_callback(msg1) + assert result1["panda"]["resource_poses"] == {"plate": "panda_gripper"} + + # dirty 已清除 + assert sim._resource_poses_dirty is False + + # 第二次发送:not dirty → resource_poses 为空 + msg2 = _make_joint_state_msg(["panda_j1"], [1.0]) + result2 = sim.joint_state_callback(msg2) + assert result2["panda"]["resource_poses"] == {} + + def test_resource_change_resets_dirty_after_send(self): + """dirty 在发送后被重置,再次 resource_pose 变化后重新标记""" + sim = _make_sim({"panda": "uuid-panda"}) + sim.resource_pose_callback(_make_string_msg(json.dumps({"plate": "panda_deck"}))) + + msg = _make_joint_state_msg(["panda_j1"], [0.5]) + sim.joint_state_callback(msg) + assert sim._resource_poses_dirty is False + + # 再次资源变化 + sim.resource_pose_callback(_make_string_msg(json.dumps({"plate": "panda_gripper"}))) + assert sim._resource_poses_dirty is True + + msg2 = _make_joint_state_msg(["panda_j1"], [1.0]) + result2 = sim.joint_state_callback(msg2) + assert result2["panda"]["resource_poses"] == {"plate": "panda_gripper"} + + # --- 组合场景 --- + + def test_dead_band_bypassed_by_resource_dirty(self): + """关节无变化但 resource_pose 有变化 → 仍然发送""" + sim = JointBridgeSimulator({"arm": "uuid-arm"}, dead_band=0.01, min_interval=0.0) + msg1 = _make_joint_state_msg(["arm_j1"], [1.0]) + sim.joint_state_callback(msg1, now=0.0) + + sim.resource_pose_callback(_make_string_msg(json.dumps({"plate": "arm_gripper"}))) + # 关节值完全不变 + msg2 = _make_joint_state_msg(["arm_j1"], [1.0]) + result2 = sim.joint_state_callback(msg2, now=1.0) + assert "arm" in result2 + assert result2["arm"]["resource_poses"] == {"plate": "arm_gripper"} + + def test_high_frequency_stream_only_significant_pass(self): + """模拟高频流: 只有显著变化的消息通过""" + sim = JointBridgeSimulator({"arm": "uuid-arm"}, dead_band=0.01, min_interval=0.0) + t = 0.0 + passed_count = 0 + + # 100 条消息,每条微小递增 0.001 + for i in range(100): + t += 0.1 + val = 1.0 + i * 0.001 + msg = _make_joint_state_msg(["arm_j1"], [val]) + result = sim.joint_state_callback(msg, now=t) + if result: + passed_count += 1 + + # 首次总通过 + 每 10 条左右(累计 0.01 变化)通过一次 + assert passed_count < 20 # 远少于 100 + assert passed_count >= 5 # 但不应为 0 + + def test_throttle_and_dead_band_combined(self): + """同时受抑频和死区影响""" + sim = JointBridgeSimulator({"arm": "uuid-arm"}, dead_band=0.01, min_interval=0.5) + + # 首条通过 + msg1 = _make_joint_state_msg(["arm_j1"], [1.0]) + assert sim.joint_state_callback(msg1, now=0.0) != {} + + # 时间不够 + 变化不够 → 过滤 + msg2 = _make_joint_state_msg(["arm_j1"], [1.001]) + assert sim.joint_state_callback(msg2, now=0.1) == {} + + # 时间够但变化不够 → 过滤 + msg3 = _make_joint_state_msg(["arm_j1"], [1.002]) + assert sim.joint_state_callback(msg3, now=1.0) == {} + + # 时间够且变化够 → 通过 + msg4 = _make_joint_state_msg(["arm_j1"], [1.05]) + assert sim.joint_state_callback(msg4, now=1.5) != {} diff --git a/unilabos/app/communication.py b/unilabos/app/communication.py index 700065dc5..5a598705a 100644 --- a/unilabos/app/communication.py +++ b/unilabos/app/communication.py @@ -50,6 +50,17 @@ def publish_device_status(self, device_status: dict, device_id: str, property_na """ pass + def publish_joint_state(self, node_uuid: str, joint_states: dict, resource_poses: dict = None) -> None: + """ + 发布高频关节状态数据(push_joint_state action,不写 DB) + + Args: + node_uuid: 设备节点的云端 UUID + joint_states: 关节名 → 角度/位置 的映射 + resource_poses: 物料附着映射(可选) + """ + pass + @abstractmethod def publish_job_status( self, feedback_data: dict, job_id: str, status: str, return_info: Optional[dict] = None diff --git a/unilabos/app/model_upload.py b/unilabos/app/model_upload.py new file mode 100644 index 000000000..ef5fbb445 --- /dev/null +++ b/unilabos/app/model_upload.py @@ -0,0 +1,210 @@ +"""模型文件上传/下载管理。 + +提供 Edge 端本地模型文件与 OSS 之间的双向同步: +- upload_device_model: 本地模型 → OSS(Edge 首次接入时) +- download_model_from_oss: OSS → 本地(新 Edge 加入已有 Lab 时) +""" + +from __future__ import annotations + +import os +from pathlib import Path +from typing import TYPE_CHECKING, Optional + +import requests + +from unilabos.utils.log import logger + +if TYPE_CHECKING: + from unilabos.app.web.client import HTTPClient + +# 设备 mesh 根目录 +_MESH_BASE_DIR = Path(__file__).parent.parent / "device_mesh" + +# 支持的模型文件后缀 +_MODEL_EXTENSIONS = frozenset({ + ".xacro", ".urdf", ".stl", ".dae", ".obj", + ".gltf", ".glb", ".fbx", ".yaml", ".yml", +}) + +# 需要 XOR 加密/解密的 mesh 文件后缀(反爬保护 — 方案 C) +_MESH_ENCRYPT_EXTENSIONS = frozenset({ + ".stl", ".dae", ".obj", ".fbx", ".gltf", ".glb", +}) + +# XOR 密钥 — 从环境变量读取,与前端 mesh-decrypt.ts 一致 +_XOR_KEY = os.environ.get("UNILAB_MESH_XOR_KEY", "unilab3d-model-protection-key-v1").encode() + + +def _xor_transform(data: bytes, key: bytes = _XOR_KEY) -> bytes: + """XOR 加密/解密(对称操作)。""" + key_len = len(key) + return bytes(b ^ key[i % key_len] for i, b in enumerate(data)) + + +def upload_device_model( + http_client: "HTTPClient", + template_uuid: str, + mesh_name: str, + model_type: str, + version: str = "1.0.0", +) -> Optional[str]: + """上传本地模型文件到 OSS,返回入口文件的 OSS URL。 + + Args: + http_client: HTTPClient 实例 + template_uuid: 设备模板 UUID + mesh_name: mesh 目录名(如 "arm_slider") + model_type: "device" 或 "resource" + version: 模型版本 + + Returns: + 入口文件 OSS URL,上传失败返回 None + """ + if model_type == "device": + model_dir = _MESH_BASE_DIR / "devices" / mesh_name + else: + model_dir = _MESH_BASE_DIR / "resources" / mesh_name + + if not model_dir.exists(): + logger.warning(f"[模型上传] 本地目录不存在: {model_dir}") + return None + + # 收集所有需要上传的文件 + files = [] + for f in model_dir.rglob("*"): + if f.is_file() and f.suffix.lower() in _MODEL_EXTENSIONS: + files.append({ + "name": str(f.relative_to(model_dir)), + "size_kb": f.stat().st_size // 1024, + }) + + if not files: + logger.warning(f"[模型上传] 目录中无可上传的模型文件: {model_dir}") + return None + + try: + # 1. 获取预签名上传 URL + upload_urls_resp = http_client.get_model_upload_urls( + template_uuid=template_uuid, + files=[{"name": f["name"], "version": version} for f in files], + ) + if not upload_urls_resp: + return None + + url_items = upload_urls_resp.get("files", []) + + # 2. 逐个上传文件 + for file_info, url_info in zip(files, url_items): + local_path = model_dir / file_info["name"] + upload_url = url_info.get("upload_url", "") + if not upload_url: + continue + _put_upload(local_path, upload_url) + + # 3. 确认发布 + entry_file = "macro_device.xacro" if model_type == "device" else "modal.xacro" + # 检查入口文件是否存在,使用实际存在的文件名 + for f in files: + if f["name"].endswith(".xacro"): + entry_file = f["name"] + break + + publish_resp = http_client.publish_model( + template_uuid=template_uuid, + version=version, + entry_file=entry_file, + ) + return publish_resp.get("path") if publish_resp else None + + except Exception as e: + logger.error(f"[模型上传] 上传失败 ({mesh_name}): {e}") + return None + + +def download_model_from_oss( + model_config: dict, + mesh_base_dir: Optional[Path] = None, +) -> bool: + """检查本地模型文件是否存在,不存在则从 OSS 下载。 + + Args: + model_config: 节点的 model 配置字典 + mesh_base_dir: mesh 根目录,默认使用 device_mesh/ + + Returns: + True 表示本地文件就绪,False 表示下载失败或无需下载 + """ + if mesh_base_dir is None: + mesh_base_dir = _MESH_BASE_DIR + + mesh_name = model_config.get("mesh", "") + model_type = model_config.get("type", "") + oss_path = model_config.get("path", "") + + if not mesh_name or not oss_path or not oss_path.startswith("https://"): + return False + + # 确定本地目标目录 + if model_type == "device": + local_dir = mesh_base_dir / "devices" / mesh_name + elif model_type == "resource": + resource_name = mesh_name.split("/")[0] + local_dir = mesh_base_dir / "resources" / resource_name + else: + return False + + # 已有本地文件 → 跳过 + if local_dir.exists() and any(local_dir.iterdir()): + return True + + # 从 OSS 下载 + local_dir.mkdir(parents=True, exist_ok=True) + try: + # 下载入口文件(OSS URL 通常直接可访问) + entry_name = oss_path.rsplit("/", 1)[-1] + _download_file(oss_path, local_dir / entry_name) + + # 如果有 children_mesh,也下载 + children_mesh = model_config.get("children_mesh") + if isinstance(children_mesh, dict) and children_mesh.get("path"): + cm_path = children_mesh["path"] + if cm_path.startswith("https://"): + cm_name = cm_path.rsplit("/", 1)[-1] + meshes_dir = local_dir / "meshes" + meshes_dir.mkdir(parents=True, exist_ok=True) + _download_file(cm_path, meshes_dir / cm_name) + + logger.info(f"[模型下载] 成功下载模型到本地: {mesh_name} → {local_dir}") + return True + + except Exception as e: + logger.warning(f"[模型下载] 下载失败 ({mesh_name}): {e}") + return False + + +def _put_upload(local_path: Path, upload_url: str) -> None: + """通过预签名 URL 上传文件到 OSS。对 mesh 文件自动 XOR 加密。""" + with open(local_path, "rb") as f: + data = f.read() + # 对 mesh 文件 XOR 加密后上传(反爬保护 — 方案 C) + if local_path.suffix.lower() in _MESH_ENCRYPT_EXTENSIONS: + data = _xor_transform(data) + logger.debug(f"[模型上传] XOR 加密: {local_path.name}") + resp = requests.put(upload_url, data=data, timeout=120) + resp.raise_for_status() + logger.debug(f"[模型上传] 已上传: {local_path.name}") + + +def _download_file(url: str, local_path: Path) -> None: + """下载单个文件到本地路径。对 mesh 文件自动 XOR 解密。""" + local_path.parent.mkdir(parents=True, exist_ok=True) + resp = requests.get(url, timeout=60) + resp.raise_for_status() + data = resp.content + # 从 OSS 下载的 mesh 文件是加密的,需要 XOR 解密后再存本地 + if local_path.suffix.lower() in _MESH_ENCRYPT_EXTENSIONS: + data = _xor_transform(data) + logger.debug(f"[模型下载] XOR 解密: {local_path.name}") + local_path.write_bytes(data) + logger.debug(f"[模型下载] 已下载: {local_path}") diff --git a/unilabos/app/register.py b/unilabos/app/register.py index 5940364ed..30cdd1094 100644 --- a/unilabos/app/register.py +++ b/unilabos/app/register.py @@ -5,6 +5,48 @@ from unilabos.utils.tools import normalize_json as _normalize_device +def normalize_model_for_upload(model_dict: dict) -> dict: + """将 Registry YAML 的 model 字段映射为后端 DeviceModel 结构化格式。 + + 保留所有原始字段,额外做以下标准化: + 1. 自动推断 format(如果 YAML 未指定) + 2. 将 children_mesh 扁平字段映射为结构化 children_mesh 对象 + """ + if not model_dict: + return model_dict + + result = {**model_dict} + + # 自动推断 format + if "format" not in result and result.get("path"): + path = result["path"] + if path.endswith(".xacro"): + result["format"] = "xacro" + elif path.endswith(".urdf"): + result["format"] = "urdf" + elif path.endswith(".stl"): + result["format"] = "stl" + elif path.endswith((".gltf", ".glb")): + result["format"] = "gltf" + + # 将 children_mesh 扁平字段 → 结构化 children_mesh 对象 + if "children_mesh" in result and isinstance(result["children_mesh"], str): + cm_path = result.pop("children_mesh") + cm_tf = result.pop("children_mesh_tf", None) + cm_oss = result.pop("children_mesh_path", None) + result["children_mesh"] = { + "path": cm_oss or cm_path, + "format": "stl" if cm_path.endswith(".stl") else "gltf", + "default_visible": True, + } + if cm_tf and len(cm_tf) >= 3: + result["children_mesh"]["local_offset"] = cm_tf[:3] + if cm_tf and len(cm_tf) >= 6: + result["children_mesh"]["local_rotation"] = cm_tf[3:6] + + return result + + def register_devices_and_resources(lab_registry, gather_only=False) -> Optional[Tuple[Dict[str, Any], Dict[str, Any]]]: """ 注册设备和资源到服务器(仅支持HTTP) @@ -16,11 +58,18 @@ def register_devices_and_resources(lab_registry, gather_only=False) -> Optional[ devices_to_register = {} for device_info in lab_registry.obtain_registry_device_info(): - devices_to_register[device_info["id"]] = _normalize_device(device_info) + normalized = _normalize_device(device_info) + # 标准化 model 字段 + if normalized.get("model"): + normalized["model"] = normalize_model_for_upload(normalized["model"]) + devices_to_register[device_info["id"]] = normalized logger.trace(f"[UniLab Register] 收集设备: {device_info['id']}") resources_to_register = {} for resource_info in lab_registry.obtain_registry_resource_info(): + # 标准化 model 字段 + if resource_info.get("model"): + resource_info["model"] = normalize_model_for_upload(resource_info["model"]) resources_to_register[resource_info["id"]] = resource_info logger.trace(f"[UniLab Register] 收集资源: {resource_info['id']}") diff --git a/unilabos/app/web/client.py b/unilabos/app/web/client.py index 1dd056aeb..301fd2c12 100644 --- a/unilabos/app/web/client.py +++ b/unilabos/app/web/client.py @@ -470,6 +470,63 @@ def workflow_publish(self, workflow_uuid: str, description: str = "") -> Dict[st logger.error(f"发布工作流失败: {response.status_code}, {response.text}") return {"code": response.status_code, "message": response.text} + # ──────────────────── 模型资产管理 ──────────────────── + + def get_model_upload_urls( + self, template_uuid: str, files: list[dict], + ) -> dict | None: + """获取模型文件预签名上传 URL。 + + Args: + template_uuid: 设备模板 UUID + files: 文件列表 [{"name": "...", "version": "1.0.0"}] + + Returns: + {"files": [{"name": "...", "upload_url": "...", "path": "..."}]} + """ + try: + response = requests.post( + f"{self.remote_addr}/lab/square/template/{template_uuid}/model/upload-urls", + json={"files": files}, + headers={"Authorization": f"Lab {self.auth}"}, + timeout=30, + ) + if response.status_code == 200: + data = response.json().get("data") + return data + logger.error(f"获取模型上传 URL 失败: {response.status_code}, {response.text}") + except Exception as e: + logger.error(f"获取模型上传 URL 异常: {e}") + return None + + def publish_model( + self, template_uuid: str, version: str, entry_file: str, + ) -> dict | None: + """确认模型上传完成,发布新版本。 + + Args: + template_uuid: 设备模板 UUID + version: 模型版本 + entry_file: 入口文件名 + + Returns: + {"path": "...", "oss_dir": "...", "version": "..."} + """ + try: + response = requests.post( + f"{self.remote_addr}/lab/square/template/{template_uuid}/model/publish", + json={"version": version, "entry_file": entry_file}, + headers={"Authorization": f"Lab {self.auth}"}, + timeout=30, + ) + if response.status_code == 200: + data = response.json().get("data") + return data + logger.error(f"发布模型失败: {response.status_code}, {response.text}") + except Exception as e: + logger.error(f"发布模型异常: {e}") + return None + # 创建默认客户端实例 http_client = HTTPClient() diff --git a/unilabos/app/ws_client.py b/unilabos/app/ws_client.py index 851ae3203..06ddcfafd 100644 --- a/unilabos/app/ws_client.py +++ b/unilabos/app/ws_client.py @@ -83,7 +83,7 @@ def update_timestamp(self): """更新最后更新时间""" self.last_update_time = time.time() - def set_ready_timeout(self, timeout_seconds: int = 10): + def set_ready_timeout(self, timeout_seconds: int = 30): """设置READY状态超时时间""" self.ready_timeout = time.time() + timeout_seconds @@ -133,7 +133,7 @@ def add_queue_request(self, job_info: JobInfo) -> bool: if job_info.always_free: job_info.status = JobStatus.READY job_info.update_timestamp() - job_info.set_ready_timeout(10) + job_info.set_ready_timeout(30) job_log = format_job_log(job_info.job_id, job_info.task_id, job_info.device_id, job_info.action_name) logger.trace(f"[DeviceActionManager] Job {job_log} always_free, start immediately") return True @@ -162,7 +162,7 @@ def add_queue_request(self, job_info: JobInfo) -> bool: # 将其状态设为READY并占位,防止后续job也被判断为free job_info.status = JobStatus.READY job_info.update_timestamp() - job_info.set_ready_timeout(10) # 设置10秒超时 + job_info.set_ready_timeout(30) # 设置30秒超时 self.active_jobs[device_key] = job_info job_log = format_job_log(job_info.job_id, job_info.task_id, job_info.device_id, job_info.action_name) logger.trace(f"[DeviceActionManager] Job {job_log} can start immediately for {device_key}") @@ -245,7 +245,7 @@ def end_job(self, job_id: str) -> Optional[JobInfo]: # 将下一个job设置为READY状态并放入active_jobs next_job.status = JobStatus.READY next_job.update_timestamp() - next_job.set_ready_timeout(10) # 设置10秒超时 + next_job.set_ready_timeout(30) # 设置30秒超时 self.active_jobs[device_key] = next_job next_job_log = format_job_log( next_job.job_id, next_job.task_id, next_job.device_id, next_job.action_name @@ -312,7 +312,7 @@ def cancel_job(self, job_id: str) -> bool: # 将下一个job设置为READY状态并放入active_jobs next_job.status = JobStatus.READY next_job.update_timestamp() - next_job.set_ready_timeout(10) + next_job.set_ready_timeout(30) self.active_jobs[device_key] = next_job next_job_log = format_job_log( next_job.job_id, next_job.task_id, next_job.device_id, next_job.action_name @@ -1464,6 +1464,21 @@ def publish_device_status(self, device_status: dict, device_id: str, property_na self.message_processor.send_message(message) # logger.trace(f"[WebSocketClient] Device status published: {device_id}.{property_name}") + def publish_joint_state(self, node_uuid: str, joint_states: dict, resource_poses: dict = None) -> None: + """发布高频关节状态(push_joint_state,不写 DB)""" + if self.is_disabled or not self.is_connected(): + return + + message = { + "action": "push_joint_state", + "data": { + "node_uuid": node_uuid, + "joint_states": joint_states or {}, + "resource_poses": resource_poses or {}, + }, + } + self.message_processor.send_message(message) + def publish_job_status( self, feedback_data: dict, item: QueueItem, status: str, return_info: Optional[dict] = None ) -> None: diff --git a/unilabos/device_mesh/devices/access2_backend/macro_device.xacro b/unilabos/device_mesh/devices/access2_backend/macro_device.xacro new file mode 100644 index 000000000..a18c64f49 --- /dev/null +++ b/unilabos/device_mesh/devices/access2_backend/macro_device.xacro @@ -0,0 +1,125 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/unilabos/device_mesh/devices/access2_backend/meshes/access2_backend_box.stl b/unilabos/device_mesh/devices/access2_backend/meshes/access2_backend_box.stl new file mode 100644 index 000000000..89348df72 Binary files /dev/null and b/unilabos/device_mesh/devices/access2_backend/meshes/access2_backend_box.stl differ diff --git a/unilabos/device_mesh/devices/access2_backend/meta.json b/unilabos/device_mesh/devices/access2_backend/meta.json new file mode 100644 index 000000000..ace477791 --- /dev/null +++ b/unilabos/device_mesh/devices/access2_backend/meta.json @@ -0,0 +1,77 @@ +{ + "fileName": "access2_backend", + "related": [ + "access2_backend", + "vspin_backend", + "agilent_microplate_centrifuge", + "agilent_centrifuge_loader" + ], + "model_strategy": "fallback_box_from_manual_dimensions", + "sources": [ + { + "title": "Agilent Microplate Centrifuge data sheet", + "url": "https://www.agilent.com/cs/library/datasheets/Public/5990-3484EN_LO.pdf", + "kind": "datasheet", + "notes": "Provides exact dimensions for the centrifuge without loader and confirms front-door access." + }, + { + "title": "Agilent Microplate Centrifuge with Loader User Guide", + "url": "https://www.agilent.com/cs/library/usermanuals/public/G5405-90002C_Centrifuge_wLoaderUG_EN.pdf", + "kind": "user_guide", + "notes": "Provides exact overall dimensions for the centrifuge with loader plus front and top figures used to place access frames." + }, + { + "title": "Wotol listing: Agilent VSpin with Access2", + "url": "https://www.wotol.com/product/agilent-vspin-with-access2-automated-microplate-centrifuge-with-microplate-loade/2783053", + "kind": "secondary_reference", + "notes": "Used only to link the Access2 name in the driver to the VSpin / Agilent centrifuge-loader product naming." + } + ], + "dimensions_m": { + "overall_with_loader": { + "width": 0.327, + "depth": 0.711, + "height": 0.251 + }, + "centrifuge_only": { + "width": 0.328, + "depth": 0.457, + "height": 0.206 + } + }, + "access_points": [ + { + "name": "front_door_access_link", + "xyz_m": [0.0, -0.1155, 0.1255], + "rpy_rad": [0.0, 0.0, 0.0], + "collision_strategy": "opening_cutout", + "description": "Approximate center of the front door opening on the centrifuge body, approached through the front opening corridor." + }, + { + "name": "loader_stage_access_link", + "xyz_m": [0.0, -0.2555, 0.095], + "rpy_rad": [0.0, 0.0, 0.0], + "collision_strategy": "fixed_extended_tray", + "description": "Approximate robotic handoff point above the fixed extended loader stage." + }, + { + "name": "hover_access_link", + "xyz_m": [0.0, -0.1755, 0.12], + "rpy_rad": [0.0, 0.0, 0.0], + "collision_strategy": "opening_cutout", + "description": "Approximate hover point just outside the centrifuge door, aligned with the open front-door approach corridor." + }, + { + "name": "rear_service_access_link", + "xyz_m": [0.0, 0.3855, 0.12048], + "rpy_rad": [0.0, 0.0, 3.141592653589793], + "collision_strategy": "opening_cutout", + "description": "Rear-side service reference for power, serial, and compressed-air connections on the exposed rear service face, positioned just outside the simplified body shell." + } + ], + "notes": [ + "No exact downloadable CAD/STL/GLB for the Agilent Microplate Centrifuge with Access2 loader was located during web search.", + "The STL is an intentionally simple meter-based box generated from the documented overall dimensions of the combined centrifuge-plus-loader unit.", + "Access frame positions are inferred from Agilent front and top-view figures and should be treated as integration-friendly approximations, not factory metrology." + ] +} diff --git a/unilabos/device_mesh/devices/agilent_biotek_406_fx/macro_device.xacro b/unilabos/device_mesh/devices/agilent_biotek_406_fx/macro_device.xacro new file mode 100644 index 000000000..309446dd7 --- /dev/null +++ b/unilabos/device_mesh/devices/agilent_biotek_406_fx/macro_device.xacro @@ -0,0 +1,150 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/unilabos/device_mesh/devices/agilent_biotek_406_fx/meshes/base_frame.stl b/unilabos/device_mesh/devices/agilent_biotek_406_fx/meshes/base_frame.stl new file mode 100644 index 000000000..8e1821f46 Binary files /dev/null and b/unilabos/device_mesh/devices/agilent_biotek_406_fx/meshes/base_frame.stl differ diff --git a/unilabos/device_mesh/devices/agilent_biotek_406_fx/meshes/control_console.stl b/unilabos/device_mesh/devices/agilent_biotek_406_fx/meshes/control_console.stl new file mode 100644 index 000000000..674d54395 Binary files /dev/null and b/unilabos/device_mesh/devices/agilent_biotek_406_fx/meshes/control_console.stl differ diff --git a/unilabos/device_mesh/devices/agilent_biotek_406_fx/meshes/manifold_guard.stl b/unilabos/device_mesh/devices/agilent_biotek_406_fx/meshes/manifold_guard.stl new file mode 100644 index 000000000..535c05c38 Binary files /dev/null and b/unilabos/device_mesh/devices/agilent_biotek_406_fx/meshes/manifold_guard.stl differ diff --git a/unilabos/device_mesh/devices/agilent_biotek_406_fx/meshes/plate_carrier.stl b/unilabos/device_mesh/devices/agilent_biotek_406_fx/meshes/plate_carrier.stl new file mode 100644 index 000000000..520d8b805 Binary files /dev/null and b/unilabos/device_mesh/devices/agilent_biotek_406_fx/meshes/plate_carrier.stl differ diff --git a/unilabos/device_mesh/devices/agilent_biotek_406_fx/meshes/syringe_tower.stl b/unilabos/device_mesh/devices/agilent_biotek_406_fx/meshes/syringe_tower.stl new file mode 100644 index 000000000..667b35ba4 Binary files /dev/null and b/unilabos/device_mesh/devices/agilent_biotek_406_fx/meshes/syringe_tower.stl differ diff --git a/unilabos/device_mesh/devices/agilent_biotek_406_fx/meshes/touch_panel.stl b/unilabos/device_mesh/devices/agilent_biotek_406_fx/meshes/touch_panel.stl new file mode 100644 index 000000000..46abadb6c Binary files /dev/null and b/unilabos/device_mesh/devices/agilent_biotek_406_fx/meshes/touch_panel.stl differ diff --git a/unilabos/device_mesh/devices/agilent_biotek_406_fx/meshes/wash_module.stl b/unilabos/device_mesh/devices/agilent_biotek_406_fx/meshes/wash_module.stl new file mode 100644 index 000000000..49a108e3a Binary files /dev/null and b/unilabos/device_mesh/devices/agilent_biotek_406_fx/meshes/wash_module.stl differ diff --git a/unilabos/device_mesh/devices/agilent_biotek_406_fx/meta.json b/unilabos/device_mesh/devices/agilent_biotek_406_fx/meta.json new file mode 100644 index 000000000..c4b58d763 --- /dev/null +++ b/unilabos/device_mesh/devices/agilent_biotek_406_fx/meta.json @@ -0,0 +1,8 @@ +{ + "fileName": "agilent_biotek_406_fx", + "related": [ + "agilent_biotek_406_fx", + "biotek_406_fx", + "el406" + ] +} diff --git a/unilabos/device_mesh/devices/applied_biosystems_seqstudio_genetic_analyzer/macro_device.xacro b/unilabos/device_mesh/devices/applied_biosystems_seqstudio_genetic_analyzer/macro_device.xacro new file mode 100644 index 000000000..047cfb6b1 --- /dev/null +++ b/unilabos/device_mesh/devices/applied_biosystems_seqstudio_genetic_analyzer/macro_device.xacro @@ -0,0 +1,119 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/unilabos/device_mesh/devices/applied_biosystems_seqstudio_genetic_analyzer/meshes/seqstudio_ar_source.glb b/unilabos/device_mesh/devices/applied_biosystems_seqstudio_genetic_analyzer/meshes/seqstudio_ar_source.glb new file mode 100644 index 000000000..b10a85200 Binary files /dev/null and b/unilabos/device_mesh/devices/applied_biosystems_seqstudio_genetic_analyzer/meshes/seqstudio_ar_source.glb differ diff --git a/unilabos/device_mesh/devices/applied_biosystems_seqstudio_genetic_analyzer/meshes/seqstudio_visual.stl b/unilabos/device_mesh/devices/applied_biosystems_seqstudio_genetic_analyzer/meshes/seqstudio_visual.stl new file mode 100644 index 000000000..f677f08c1 Binary files /dev/null and b/unilabos/device_mesh/devices/applied_biosystems_seqstudio_genetic_analyzer/meshes/seqstudio_visual.stl differ diff --git a/unilabos/device_mesh/devices/applied_biosystems_seqstudio_genetic_analyzer/meta.json b/unilabos/device_mesh/devices/applied_biosystems_seqstudio_genetic_analyzer/meta.json new file mode 100644 index 000000000..1cdc09f86 --- /dev/null +++ b/unilabos/device_mesh/devices/applied_biosystems_seqstudio_genetic_analyzer/meta.json @@ -0,0 +1,86 @@ +{ + "fileName": "applied_biosystems_seqstudio_genetic_analyzer", + "related": [ + "applied_biosystems_seqstudio_genetic_analyzer", + "seqstudio_genetic_analyzer", + "applied_biosystems_seqstudio" + ], + "model_strategy": "official_glb_converted_to_stl", + "sources": [ + { + "title": "SeqStudio Genetic Analyzer product page", + "url": "https://www.thermofisher.com/us/en/home/life-science/sequencing/sanger-sequencing/genetic-analyzers/models/seqstudio.html", + "kind": "product_page", + "notes": "Confirms the instrument identity, the all-in-one cartridge workflow, and links to the 3D tour and brochure." + }, + { + "title": "SeqStudio Genetic Analyzer 3D Tour", + "url": "https://www.thermofisher.com/us/en/home/virtual/seqstudio-3d-tour.html", + "kind": "interactive_tour", + "notes": "Official Thermo Fisher 3D tour page used to confirm the public 3D asset and named hotspots for plate, cartridge, compartment, and screen locations." + }, + { + "title": "SeqStudio 3D tour manifest", + "url": "https://www.thermofisher.com/content/dam/LifeTech/virtual/seqstudio-genetic-analyzer/seqstudio-ga-manifest-28.json", + "kind": "3d_manifest", + "notes": "Provides the official GLB part list and hotspot coordinates used to estimate access points." + }, + { + "title": "SeqStudio AR GLB source asset", + "url": "https://www.thermofisher.com/content/dam/LifeTech/virtual/seqstudio-genetic-analyzer/models/seqStudioGeneticAnalyzer_vX_ar.glb", + "kind": "3d_model", + "notes": "Static vendor GLB used as the source mesh for the converted STL visual." + }, + { + "title": "SeqStudio specification sheet", + "url": "https://assets.thermofisher.com/TFS-Assets/GSD/Specification-Sheets/SeqStudio-Specification-Sheet.pdf.pdf", + "kind": "specification_sheet", + "notes": "Provides official dimensions of 49.5 x 64.8 x 44.2 cm and confirms 96-well plate / 8-strip tube compatibility." + } + ], + "dimensions_m": { + "width": 0.495, + "depth": 0.648, + "height": 0.442, + "weight_kg": 53.6 + }, + "mesh_assets": { + "visual_stl": "meshes/seqstudio_visual.stl", + "source_glb": "meshes/seqstudio_ar_source.glb" + }, + "access_points": [ + { + "name": "sample_plate_access_link", + "xyz_m": [-0.0568, -0.1888, 0.1794], + "rpy_rad": [0.0, 0.0, 0.0], + "collision_strategy": "opening_cutout", + "description": "Approximate center of the internal sample plate position inferred from the official 3D tour Plate hotspot." + }, + { + "name": "cartridge_access_link", + "xyz_m": [-0.1321, -0.1493, 0.2896], + "rpy_rad": [0.0, 0.0, 0.0], + "collision_strategy": "opening_cutout", + "description": "Approximate center of the all-in-one cartridge click-in location inferred from the official 3D tour Cartridge hotspot." + }, + { + "name": "front_loading_access_link", + "xyz_m": [-0.1755, -0.3182, 0.0947], + "rpy_rad": [0.0, 0.0, 0.0], + "collision_strategy": "opening_cutout", + "description": "Approximate reference point near the front compartment opening, based on the official 3D tour Internal Compartment hotspot." + }, + { + "name": "screen_access_link", + "xyz_m": [0.0439, -0.2423, 0.3033], + "rpy_rad": [0.0, 0.0, 0.0], + "collision_strategy": "opening_cutout", + "description": "Approximate touchscreen / on-board computer reference point based on the official 3D tour On-board Computer hotspot." + } + ], + "notes": [ + "The visible mesh is derived from Thermo Fisher's public AR GLB, not from a vendor-supplied CAD/STL package.", + "The converted STL was rescaled to match the official specification-sheet envelope before being added to the device mesh library.", + "Access frame poses are integration-friendly estimates from the official 3D tour hotspot coordinates and should not be treated as service-manual metrology." + ] +} diff --git a/unilabos/device_mesh/devices/arm_slider/joint_limit.yaml b/unilabos/device_mesh/devices/arm_slider/joint_limit.yaml index b14126207..1854d32a6 100644 --- a/unilabos/device_mesh/devices/arm_slider/joint_limit.yaml +++ b/unilabos/device_mesh/devices/arm_slider/joint_limit.yaml @@ -3,8 +3,8 @@ joint_limits: arm_base_joint: effort: 50 velocity: 1.0 - lower: 0 - upper: 1.5 + lower: -0.2 + upper: 4.8 arm_link_1_joint: effort: 50 diff --git a/unilabos/device_mesh/devices/arm_slider/macro_device.xacro b/unilabos/device_mesh/devices/arm_slider/macro_device.xacro index 871229d80..b78dc7702 100644 --- a/unilabos/device_mesh/devices/arm_slider/macro_device.xacro +++ b/unilabos/device_mesh/devices/arm_slider/macro_device.xacro @@ -40,7 +40,7 @@ - + @@ -49,16 +49,16 @@ - + - + - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/unilabos/device_mesh/devices/bd_facsmelody/meshes/body_shell.stl b/unilabos/device_mesh/devices/bd_facsmelody/meshes/body_shell.stl new file mode 100644 index 000000000..ecc46d741 Binary files /dev/null and b/unilabos/device_mesh/devices/bd_facsmelody/meshes/body_shell.stl differ diff --git a/unilabos/device_mesh/devices/bd_facsmelody/meshes/center_panel.stl b/unilabos/device_mesh/devices/bd_facsmelody/meshes/center_panel.stl new file mode 100644 index 000000000..dc21c1968 Binary files /dev/null and b/unilabos/device_mesh/devices/bd_facsmelody/meshes/center_panel.stl differ diff --git a/unilabos/device_mesh/devices/bd_facsmelody/meshes/left_chamber_door.stl b/unilabos/device_mesh/devices/bd_facsmelody/meshes/left_chamber_door.stl new file mode 100644 index 000000000..81dfa65b6 Binary files /dev/null and b/unilabos/device_mesh/devices/bd_facsmelody/meshes/left_chamber_door.stl differ diff --git a/unilabos/device_mesh/devices/bd_facsmelody/meshes/lower_window_panel.stl b/unilabos/device_mesh/devices/bd_facsmelody/meshes/lower_window_panel.stl new file mode 100644 index 000000000..815a659a6 Binary files /dev/null and b/unilabos/device_mesh/devices/bd_facsmelody/meshes/lower_window_panel.stl differ diff --git a/unilabos/device_mesh/devices/bd_facsmelody/meshes/right_front_panel.stl b/unilabos/device_mesh/devices/bd_facsmelody/meshes/right_front_panel.stl new file mode 100644 index 000000000..6a075a08d Binary files /dev/null and b/unilabos/device_mesh/devices/bd_facsmelody/meshes/right_front_panel.stl differ diff --git a/unilabos/device_mesh/devices/bd_facsmelody/meta.json b/unilabos/device_mesh/devices/bd_facsmelody/meta.json new file mode 100644 index 000000000..05b2c2581 --- /dev/null +++ b/unilabos/device_mesh/devices/bd_facsmelody/meta.json @@ -0,0 +1,92 @@ +{ + "fileName": "bd_facsmelody", + "related": [ + "bd_facsmelody", + "bd_facsmelody_br_4way", + "bd_facsmelody_cell_sorter" + ], + "model_strategy": "fallback_segmented_box_from_official_dimensions_and_imagery", + "sources": [ + { + "title": "BD FACSMelody Technical Specifications", + "url": "https://www.bdbiosciences.com/content/dam/bdb/marketing-documents/BD-FACSMelody-TS.pdf", + "kind": "technical_spec", + "notes": "Provides official sorter dimensions and distinguishes the main sorter from the separate electronics box." + }, + { + "title": "BD FACSMelody Cell Sorter User's Guide", + "url": "https://www.bdbiosciences.com/content/dam/bdb/marketing-documents/FACSMelody-ug-ruo.pdf", + "kind": "user_guide", + "notes": "Describes sample loading, sort collection chamber access, automated stage loading, and flow-cell maintenance doors." + }, + { + "title": "BD FACSMelody product page", + "url": "https://www.bdbiosciences.com/en-us/products/instruments/flow-cytometers/research-cell-sorters/bd-facsmelody", + "kind": "product_page", + "notes": "Primary official product page used to verify current branding and access to brochure resources." + }, + { + "title": "BD FACSMelody official product image", + "url": "https://www.bdbiosciences.com/content/dam/bdb/products/instruments/flow-cytometers/research-cell-sorters/facsmelody/Melody-Feature-Slide-1.png", + "kind": "official_image", + "notes": "Open-door front view used to estimate sample-loading and sort-output access regions." + }, + { + "title": "BD FACSMelody overview image", + "url": "https://www.bdbiosciences.com/content/dam/bdb/products/instruments/flow-cytometers/research-cell-sorters/facsmelody/Melody-Overview.png", + "kind": "official_image", + "notes": "Shows plate-stage access and confirms the lower front-right flow-cell service door location." + } + ], + "dimensions_m": { + "cell_sorter": { + "width": 0.495, + "depth": 0.559, + "height": 0.483, + "weight_kg": 40.75 + }, + "electronics_box": { + "width": 0.508, + "depth": 0.559, + "height": 0.483, + "weight_kg": 36.25 + } + }, + "access_points": [ + { + "name": "sample_loading_access_link", + "xyz_m": [-0.155, -0.3745, 0.225], + "rpy_rad": [0.0, 0.0, 0.0], + "collision_strategy": "opening_cutout", + "description": "Estimated front-left sample loading point for the sample chamber and 5 mL tube insertion." + }, + { + "name": "sort_output_access_link", + "xyz_m": [-0.045, -0.4145, 0.145], + "rpy_rad": [0.0, 0.0, 0.0], + "collision_strategy": "opening_cutout", + "description": "Estimated front collection handoff point for tube racks or optional automated-stage plate loading." + }, + { + "name": "flow_cell_access_link", + "xyz_m": [0.155, -0.3495, 0.125], + "rpy_rad": [0.0, 0.0, 0.0], + "collision_strategy": "opening_cutout", + "description": "Estimated lower front-right service point for nozzle and flow-cell maintenance." + }, + { + "name": "rear_service_access_link", + "xyz_m": [0.0, 0.2545, 0.245], + "rpy_rad": [0.0, 0.0, 3.141592653589793], + "collision_strategy": "opening_cutout", + "description": "Rear-side service reference for utilities and cabling." + } + ], + "notes": [ + "No public CAD, STL, GLB, or USDZ model for the BD FACSMelody was located from official BD pages or search results during this task.", + "A 2024 BD brochure advertises an AR 3D model view, but the public asset URL was not extractable from the brochure or product page HTML.", + "The mesh is therefore a segmented fallback proxy built from official dimensions and front-view imagery so the sorter stays recognizable in layout scenes while keeping collision geometry conservative.", + "Collision geometry intentionally leaves a front loading corridor and a rear service cutout open so access links are not embedded in a solid body volume.", + "Access point positions are integration-friendly estimates and should be treated as approximate until a service drawing or direct measurement is available." + ] +} diff --git a/unilabos/device_mesh/devices/bio_shake/macro_device.xacro b/unilabos/device_mesh/devices/bio_shake/macro_device.xacro new file mode 100644 index 000000000..24b90452f --- /dev/null +++ b/unilabos/device_mesh/devices/bio_shake/macro_device.xacro @@ -0,0 +1,102 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/unilabos/device_mesh/devices/bio_shake/meshes/bio_shake_body.stl b/unilabos/device_mesh/devices/bio_shake/meshes/bio_shake_body.stl new file mode 100644 index 000000000..61d9c65ae Binary files /dev/null and b/unilabos/device_mesh/devices/bio_shake/meshes/bio_shake_body.stl differ diff --git a/unilabos/device_mesh/devices/bio_shake/meta.json b/unilabos/device_mesh/devices/bio_shake/meta.json new file mode 100644 index 000000000..251dc115a --- /dev/null +++ b/unilabos/device_mesh/devices/bio_shake/meta.json @@ -0,0 +1,87 @@ +{ + "fileName": "bio_shake", + "related": [ + "bio_shake", + "bioshake", + "inheco_thermoshake_ac" + ], + "model_strategy": "fallback_box", + "sources": [ + "https://www.inheco.com/thermoshake-ac.html", + "https://www.inheco.com/data/pdf/manual-thermoshake-ac-1019-0731-02.pdf", + "https://www.inheco.com/data/images/uploads/slider/1005-1254_d_thermoshake-ac-2.jpg", + "https://www.inheco.com/data/images/uploads/slider/1005-1255_d_thermoshake-ac-3.jpg" + ], + "dimensions_m": { + "width": 0.104, + "depth": 0.147, + "height": 0.1159 + }, + "collision_model": { + "strategy": "opening_cutout", + "body_m": { + "width": 0.104, + "depth": 0.147, + "height": 0.1159 + }, + "opening_m": { + "width": 0.09, + "depth": 0.132, + "recess_depth": 0.008, + "floor_z": 0.1079 + }, + "notes": "Simplified collision is split into a lower body block plus shallow top rim blocks so the centered SBS pickup zone remains open from above while the access frames stay attached to base_link." + }, + "access_points": [ + { + "name": "socketTypeGenericSbsFootprint", + "description": "Primary SBS microplate pickup/drop-off frame at the centered top contact zone.", + "face": "top", + "center_m": { + "x": 0.0, + "y": 0.0, + "z": 0.1099 + }, + "size_m": { + "x": 0.08548, + "y": 0.12776, + "z": 0.012 + }, + "collision_strategy": "opening_cutout", + "collision_opening_m": { + "x": 0.09, + "y": 0.132, + "z": 0.008 + }, + "collision_notes": "The access frame remains on base_link and sits inside the shallow top opening, above the cutout floor and outside the simplified collision rim." + }, + { + "name": "plate_slot_access", + "description": "Top SBS microplate pickup/drop-off position on the shaker table in the documented zero position for robotic gripping.", + "face": "top", + "center_m": { + "x": 0.0, + "y": 0.0, + "z": 0.1099 + }, + "size_m": { + "x": 0.08548, + "y": 0.12776, + "z": 0.012 + }, + "collision_strategy": "opening_cutout", + "collision_opening_m": { + "x": 0.09, + "y": 0.132, + "z": 0.008 + }, + "collision_notes": "This access point uses the same centered opening-cutout recess as socketTypeGenericSbsFootprint so vertical plate approach remains clear of the simplified body collision." + } + ], + "notes": [ + "No openly accessible vendor CAD/STL/GLB for the BioShake or an INHECO Thermoshake AC was found during web search, so the mesh uses a generated fallback box based on official dimensions.", + "The bio_shake backend appears generic/QInstruments-derived; per task guidance this package uses an INHECO-style single-plate thermoshaker as the closest representative physical model.", + "The access frame is centered on the top contact surface height from the official spec; clamp pins, vents, and side panel details are not modeled in the STL.", + "Collision was refactored from a single solid box to a lower block plus top perimeter rim blocks so the recessed plate contact area is genuinely reachable from above." + ] +} diff --git a/unilabos/device_mesh/devices/bio_tek_plate_reader_backend/macro_device.xacro b/unilabos/device_mesh/devices/bio_tek_plate_reader_backend/macro_device.xacro new file mode 100644 index 000000000..62bcb23bc --- /dev/null +++ b/unilabos/device_mesh/devices/bio_tek_plate_reader_backend/macro_device.xacro @@ -0,0 +1,157 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/unilabos/device_mesh/devices/bio_tek_plate_reader_backend/meshes/synergy_h1_body.stl b/unilabos/device_mesh/devices/bio_tek_plate_reader_backend/meshes/synergy_h1_body.stl new file mode 100644 index 000000000..bb045c40d Binary files /dev/null and b/unilabos/device_mesh/devices/bio_tek_plate_reader_backend/meshes/synergy_h1_body.stl differ diff --git a/unilabos/device_mesh/devices/bio_tek_plate_reader_backend/meshes/synergy_h1_service_door.stl b/unilabos/device_mesh/devices/bio_tek_plate_reader_backend/meshes/synergy_h1_service_door.stl new file mode 100644 index 000000000..594c11d6c Binary files /dev/null and b/unilabos/device_mesh/devices/bio_tek_plate_reader_backend/meshes/synergy_h1_service_door.stl differ diff --git a/unilabos/device_mesh/devices/bio_tek_plate_reader_backend/meshes/synergy_h1_tray.stl b/unilabos/device_mesh/devices/bio_tek_plate_reader_backend/meshes/synergy_h1_tray.stl new file mode 100644 index 000000000..8b5f59098 Binary files /dev/null and b/unilabos/device_mesh/devices/bio_tek_plate_reader_backend/meshes/synergy_h1_tray.stl differ diff --git a/unilabos/device_mesh/devices/bio_tek_plate_reader_backend/meshes/synergy_h1_tray_door.stl b/unilabos/device_mesh/devices/bio_tek_plate_reader_backend/meshes/synergy_h1_tray_door.stl new file mode 100644 index 000000000..ebe79d6c2 Binary files /dev/null and b/unilabos/device_mesh/devices/bio_tek_plate_reader_backend/meshes/synergy_h1_tray_door.stl differ diff --git a/unilabos/device_mesh/devices/bio_tek_plate_reader_backend/meta.json b/unilabos/device_mesh/devices/bio_tek_plate_reader_backend/meta.json new file mode 100644 index 000000000..b4d39922c --- /dev/null +++ b/unilabos/device_mesh/devices/bio_tek_plate_reader_backend/meta.json @@ -0,0 +1,26 @@ +{ + "fileName": "bio_tek_plate_reader_backend", + "related": [ + "bio_tek_plate_reader_backend", + "agilent_biotek_synergy_h1", + "synergy_h1_backend" + ], + "access_points": [ + { + "name": "drawer_access_point", + "face": "front", + "xyz_m": [0.03, 0.282, 0.07], + "rpy_rad": [0.0, 0.0, 0.0], + "collision_strategy": "fixed_extended_tray", + "description": "Tray-backed SBS handoff frame on the fixed extended tray, kept outside the body shell for robot plate transfers." + }, + { + "name": "service_access_point", + "face": "front", + "xyz_m": [0.055, 0.272, 0.16], + "rpy_rad": [0.0, 0.0, 0.0], + "collision_strategy": "opening_cutout", + "description": "Front service access frame aligned with the upper service door area and approached from outside the front shell." + } + ] +} diff --git a/unilabos/device_mesh/devices/centrifuge/macro_device.xacro b/unilabos/device_mesh/devices/centrifuge/macro_device.xacro new file mode 100644 index 000000000..399ed1fa8 --- /dev/null +++ b/unilabos/device_mesh/devices/centrifuge/macro_device.xacro @@ -0,0 +1,88 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/unilabos/device_mesh/devices/centrifuge/meshes/body.stl b/unilabos/device_mesh/devices/centrifuge/meshes/body.stl new file mode 100644 index 000000000..4f766e096 Binary files /dev/null and b/unilabos/device_mesh/devices/centrifuge/meshes/body.stl differ diff --git a/unilabos/device_mesh/devices/centrifuge/meshes/door_panel.stl b/unilabos/device_mesh/devices/centrifuge/meshes/door_panel.stl new file mode 100644 index 000000000..23a87ccb8 Binary files /dev/null and b/unilabos/device_mesh/devices/centrifuge/meshes/door_panel.stl differ diff --git a/unilabos/device_mesh/devices/centrifuge/meshes/plinth.stl b/unilabos/device_mesh/devices/centrifuge/meshes/plinth.stl new file mode 100644 index 000000000..b90327e80 Binary files /dev/null and b/unilabos/device_mesh/devices/centrifuge/meshes/plinth.stl differ diff --git a/unilabos/device_mesh/devices/centrifuge/meshes/top_cap.stl b/unilabos/device_mesh/devices/centrifuge/meshes/top_cap.stl new file mode 100644 index 000000000..40a48ae97 Binary files /dev/null and b/unilabos/device_mesh/devices/centrifuge/meshes/top_cap.stl differ diff --git a/unilabos/device_mesh/devices/centrifuge/meta.json b/unilabos/device_mesh/devices/centrifuge/meta.json new file mode 100644 index 000000000..a4d0b8cd5 --- /dev/null +++ b/unilabos/device_mesh/devices/centrifuge/meta.json @@ -0,0 +1,7 @@ +{ + "fileName": "centrifuge", + "related": [ + "centrifuge", + "v_spin_backend" + ] +} diff --git a/unilabos/device_mesh/devices/clari_ostar_backend/macro_device.xacro b/unilabos/device_mesh/devices/clari_ostar_backend/macro_device.xacro new file mode 100644 index 000000000..57c6a7c9e --- /dev/null +++ b/unilabos/device_mesh/devices/clari_ostar_backend/macro_device.xacro @@ -0,0 +1,76 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/unilabos/device_mesh/devices/clari_ostar_backend/meshes/body_shell.stl b/unilabos/device_mesh/devices/clari_ostar_backend/meshes/body_shell.stl new file mode 100644 index 000000000..d53cb438d Binary files /dev/null and b/unilabos/device_mesh/devices/clari_ostar_backend/meshes/body_shell.stl differ diff --git a/unilabos/device_mesh/devices/clari_ostar_backend/meshes/optic_hood.stl b/unilabos/device_mesh/devices/clari_ostar_backend/meshes/optic_hood.stl new file mode 100644 index 000000000..e0ac00bdf Binary files /dev/null and b/unilabos/device_mesh/devices/clari_ostar_backend/meshes/optic_hood.stl differ diff --git a/unilabos/device_mesh/devices/clari_ostar_backend/meshes/plate_drawer.stl b/unilabos/device_mesh/devices/clari_ostar_backend/meshes/plate_drawer.stl new file mode 100644 index 000000000..97aaf0a4d Binary files /dev/null and b/unilabos/device_mesh/devices/clari_ostar_backend/meshes/plate_drawer.stl differ diff --git a/unilabos/device_mesh/devices/clari_ostar_backend/meta.json b/unilabos/device_mesh/devices/clari_ostar_backend/meta.json new file mode 100644 index 000000000..08a17e296 --- /dev/null +++ b/unilabos/device_mesh/devices/clari_ostar_backend/meta.json @@ -0,0 +1,8 @@ +{ + "fileName": "clari_ostar_backend", + "related": [ + "clari_ostar_backend", + "clariostar_plus", + "bmg_clariostar_plus" + ] +} diff --git a/unilabos/device_mesh/devices/cytiva_akta_pure/macro_device.xacro b/unilabos/device_mesh/devices/cytiva_akta_pure/macro_device.xacro new file mode 100644 index 000000000..a3cdf545c --- /dev/null +++ b/unilabos/device_mesh/devices/cytiva_akta_pure/macro_device.xacro @@ -0,0 +1,91 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/unilabos/device_mesh/devices/cytiva_akta_pure/meshes/cabinet_body.stl b/unilabos/device_mesh/devices/cytiva_akta_pure/meshes/cabinet_body.stl new file mode 100644 index 000000000..a114fe317 Binary files /dev/null and b/unilabos/device_mesh/devices/cytiva_akta_pure/meshes/cabinet_body.stl differ diff --git a/unilabos/device_mesh/devices/cytiva_akta_pure/meshes/control_panel.stl b/unilabos/device_mesh/devices/cytiva_akta_pure/meshes/control_panel.stl new file mode 100644 index 000000000..ab4eea2d6 Binary files /dev/null and b/unilabos/device_mesh/devices/cytiva_akta_pure/meshes/control_panel.stl differ diff --git a/unilabos/device_mesh/devices/cytiva_akta_pure/meshes/front_fluidics_panel.stl b/unilabos/device_mesh/devices/cytiva_akta_pure/meshes/front_fluidics_panel.stl new file mode 100644 index 000000000..7d982a7a4 Binary files /dev/null and b/unilabos/device_mesh/devices/cytiva_akta_pure/meshes/front_fluidics_panel.stl differ diff --git a/unilabos/device_mesh/devices/cytiva_akta_pure/meshes/top_rail_deck.stl b/unilabos/device_mesh/devices/cytiva_akta_pure/meshes/top_rail_deck.stl new file mode 100644 index 000000000..7f77bf1fb Binary files /dev/null and b/unilabos/device_mesh/devices/cytiva_akta_pure/meshes/top_rail_deck.stl differ diff --git a/unilabos/device_mesh/devices/cytiva_akta_pure/meta.json b/unilabos/device_mesh/devices/cytiva_akta_pure/meta.json new file mode 100644 index 000000000..3beebdb53 --- /dev/null +++ b/unilabos/device_mesh/devices/cytiva_akta_pure/meta.json @@ -0,0 +1,58 @@ +{ + "fileName": "cytiva_akta_pure", + "related": [ + "cytiva_akta_pure", + "akta_pure", + "akta_pure_25", + "akta_pure_150" + ], + "model_strategy": "fallback_composite_stl_from_official_dimensions_and_manual_imagery", + "sources": [ + { + "title": "Cytiva ÄKTA pure 25 Product Documentation 29020658", + "url": "https://d3.cytivalifesciences.com/prod/IFU/29020658.pdf", + "kind": "official_product_documentation", + "notes": "Provides the documented instrument envelope dimensions of 535 x 470 x 630 mm and product configuration context." + }, + { + "title": "ÄKTA pure User Manual mirror", + "url": "https://dnas.dukekunshan.edu.cn/wp-content/uploads/2024/06/AKTA_pure_user_manual.pdf", + "kind": "user_manual", + "notes": "Used to place the operator-facing front side and rear service side based on references to the top rails, front-side valve/tubing clips, and rear waste-tubing clips." + }, + { + "title": "Wikimedia Commons: Äkta Pure", + "url": "https://commons.wikimedia.org/wiki/File:%C3%84kta_Pure.jpg", + "kind": "reference_image", + "notes": "Used only as a visual reference to approximate the raised top deck, front fluidics zone, and right-front control panel silhouette." + } + ], + "dimensions_m": { + "width": 0.535, + "depth": 0.470, + "height": 0.630 + }, + "access_points": [ + { + "name": "front_access_link", + "face": "front", + "collision_strategy": "opening_cutout", + "xyz_m": [0.0, 0.3, 0.36], + "rpy_rad": [0.0, 0.0, 0.0], + "description": "Approximate operator/robot access frame for the front fluidics face where the documented rails, valves, sample pump, and control panel are presented." + }, + { + "name": "rear_service_access_link", + "face": "rear", + "collision_strategy": "opening_cutout", + "xyz_m": [0.0, -0.29, 0.285], + "rpy_rad": [0.0, 0.0, 3.141592653589793], + "description": "Approximate rear service frame for waste tubing and general service/cable access." + } + ], + "notes": [ + "A directly usable official Cytiva CAD/STL/STEP asset for ÄKTA pure was not located during web search, so this package uses a measured fallback composite mesh instead of claiming exact factory geometry.", + "The front side is defined as positive Y in the xacro because the manual describes the rails, column holder, inlet/outlet valves, sample pump, and front tubing clips on the operator-facing side.", + "The rear service frame is estimated from manual references to rear waste-tubing clips and general cabling/service routing; exact connector locations remain uncertain." + ] +} diff --git a/unilabos/device_mesh/devices/cytiva_biacore_8k_plus/macro_device.xacro b/unilabos/device_mesh/devices/cytiva_biacore_8k_plus/macro_device.xacro new file mode 100644 index 000000000..03c8ebed0 --- /dev/null +++ b/unilabos/device_mesh/devices/cytiva_biacore_8k_plus/macro_device.xacro @@ -0,0 +1,134 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/unilabos/device_mesh/devices/cytiva_biacore_8k_plus/meshes/drip_tray.stl b/unilabos/device_mesh/devices/cytiva_biacore_8k_plus/meshes/drip_tray.stl new file mode 100644 index 000000000..6e68d8e82 Binary files /dev/null and b/unilabos/device_mesh/devices/cytiva_biacore_8k_plus/meshes/drip_tray.stl differ diff --git a/unilabos/device_mesh/devices/cytiva_biacore_8k_plus/meshes/hotel_column.stl b/unilabos/device_mesh/devices/cytiva_biacore_8k_plus/meshes/hotel_column.stl new file mode 100644 index 000000000..2baeb06b5 Binary files /dev/null and b/unilabos/device_mesh/devices/cytiva_biacore_8k_plus/meshes/hotel_column.stl differ diff --git a/unilabos/device_mesh/devices/cytiva_biacore_8k_plus/meshes/hotel_window.stl b/unilabos/device_mesh/devices/cytiva_biacore_8k_plus/meshes/hotel_window.stl new file mode 100644 index 000000000..c98204e5e Binary files /dev/null and b/unilabos/device_mesh/devices/cytiva_biacore_8k_plus/meshes/hotel_window.stl differ diff --git a/unilabos/device_mesh/devices/cytiva_biacore_8k_plus/meshes/main_chassis.stl b/unilabos/device_mesh/devices/cytiva_biacore_8k_plus/meshes/main_chassis.stl new file mode 100644 index 000000000..851b5b2ad Binary files /dev/null and b/unilabos/device_mesh/devices/cytiva_biacore_8k_plus/meshes/main_chassis.stl differ diff --git a/unilabos/device_mesh/devices/cytiva_biacore_8k_plus/meshes/sample_compartment.stl b/unilabos/device_mesh/devices/cytiva_biacore_8k_plus/meshes/sample_compartment.stl new file mode 100644 index 000000000..41974a8b4 Binary files /dev/null and b/unilabos/device_mesh/devices/cytiva_biacore_8k_plus/meshes/sample_compartment.stl differ diff --git a/unilabos/device_mesh/devices/cytiva_biacore_8k_plus/meshes/sample_window.stl b/unilabos/device_mesh/devices/cytiva_biacore_8k_plus/meshes/sample_window.stl new file mode 100644 index 000000000..2b431fa8c Binary files /dev/null and b/unilabos/device_mesh/devices/cytiva_biacore_8k_plus/meshes/sample_window.stl differ diff --git a/unilabos/device_mesh/devices/cytiva_biacore_8k_plus/meshes/sensor_chip_port.stl b/unilabos/device_mesh/devices/cytiva_biacore_8k_plus/meshes/sensor_chip_port.stl new file mode 100644 index 000000000..a3ce97beb Binary files /dev/null and b/unilabos/device_mesh/devices/cytiva_biacore_8k_plus/meshes/sensor_chip_port.stl differ diff --git a/unilabos/device_mesh/devices/cytiva_biacore_8k_plus/meshes/upper_housing.stl b/unilabos/device_mesh/devices/cytiva_biacore_8k_plus/meshes/upper_housing.stl new file mode 100644 index 000000000..3251de3a2 Binary files /dev/null and b/unilabos/device_mesh/devices/cytiva_biacore_8k_plus/meshes/upper_housing.stl differ diff --git a/unilabos/device_mesh/devices/cytiva_biacore_8k_plus/meta.json b/unilabos/device_mesh/devices/cytiva_biacore_8k_plus/meta.json new file mode 100644 index 000000000..b15b7fa15 --- /dev/null +++ b/unilabos/device_mesh/devices/cytiva_biacore_8k_plus/meta.json @@ -0,0 +1,67 @@ +{ + "fileName": "cytiva_biacore_8k_plus", + "related": [ + "cytiva_biacore_8k_plus" + ], + "representation": { + "type": "fallback-composite-stl", + "dimensionsMm": { + "width": 900, + "depth": 614, + "height": 865 + }, + "sourceUrls": [ + "https://d3.cytivalifesciences.com/prod/IFU/29338851.pdf", + "https://cdn.cytivalifesciences.com/api/public/content/digi-18215-original", + "https://www.cytivalifesciences.com/en/sg/solutions/protein-research/products-and-technologies/spr-systems" + ], + "notes": [ + "No reusable vendor CAD/STL surfaced in web search results.", + "Outer envelope follows Cytiva installation/operating-document dimensions; front geometry is estimated from official Biacore 8K+ manual illustrations." + ] + }, + "accessFrames": [ + { + "name": "sample-hotel-door", + "linkSuffix": "sample_hotel_access_link", + "description": "Estimated front access point centered on the Biacore 8K+ sample hotel door/window used for tray loading.", + "collision_strategy": "opening_cutout", + "poseMeters": { + "x": -0.34, + "y": 0.362, + "z": 0.38, + "roll": 0.0, + "pitch": 0.0, + "yaw": 0.0 + } + }, + { + "name": "sample-compartment-window", + "linkSuffix": "sample_compartment_access_link", + "description": "Estimated front access point aligned to the sample compartment/loading carriage zone behind the sample compartment window.", + "collision_strategy": "opening_cutout", + "poseMeters": { + "x": 0.04, + "y": 0.362, + "z": 0.555, + "roll": 0.0, + "pitch": 0.0, + "yaw": 0.0 + } + }, + { + "name": "sensor-chip-port", + "linkSuffix": "sensor_chip_access_link", + "description": "Estimated front access point for the automated sensor chip port on the upper housing.", + "collision_strategy": "opening_cutout", + "poseMeters": { + "x": 0.12, + "y": 0.392, + "z": 0.675, + "roll": 0.0, + "pitch": 0.0, + "yaw": 0.0 + } + } + ] +} diff --git a/unilabos/device_mesh/devices/cytomat_backend/macro_device.xacro b/unilabos/device_mesh/devices/cytomat_backend/macro_device.xacro new file mode 100644 index 000000000..9225a357f --- /dev/null +++ b/unilabos/device_mesh/devices/cytomat_backend/macro_device.xacro @@ -0,0 +1,146 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/unilabos/device_mesh/devices/cytomat_backend/meshes/cabinet_body.stl b/unilabos/device_mesh/devices/cytomat_backend/meshes/cabinet_body.stl new file mode 100644 index 000000000..097dca0ff Binary files /dev/null and b/unilabos/device_mesh/devices/cytomat_backend/meshes/cabinet_body.stl differ diff --git a/unilabos/device_mesh/devices/cytomat_backend/meshes/control_panel.stl b/unilabos/device_mesh/devices/cytomat_backend/meshes/control_panel.stl new file mode 100644 index 000000000..035d46d85 Binary files /dev/null and b/unilabos/device_mesh/devices/cytomat_backend/meshes/control_panel.stl differ diff --git a/unilabos/device_mesh/devices/cytomat_backend/meshes/loading_tray.stl b/unilabos/device_mesh/devices/cytomat_backend/meshes/loading_tray.stl new file mode 100644 index 000000000..ec79a079c Binary files /dev/null and b/unilabos/device_mesh/devices/cytomat_backend/meshes/loading_tray.stl differ diff --git a/unilabos/device_mesh/devices/cytomat_backend/meshes/transfer_opening_module.stl b/unilabos/device_mesh/devices/cytomat_backend/meshes/transfer_opening_module.stl new file mode 100644 index 000000000..3e636b1fa Binary files /dev/null and b/unilabos/device_mesh/devices/cytomat_backend/meshes/transfer_opening_module.stl differ diff --git a/unilabos/device_mesh/devices/cytomat_backend/meta.json b/unilabos/device_mesh/devices/cytomat_backend/meta.json new file mode 100644 index 000000000..4cdd8ebbb --- /dev/null +++ b/unilabos/device_mesh/devices/cytomat_backend/meta.json @@ -0,0 +1,53 @@ +{ + "fileName": "cytomat_backend", + "related": [ + "cytomat_backend", + "thermo_cytomat_2_c_lin", + "thermo_cytomat_2_c450_lin", + "thermo_cytomat_2_c6000", + "thermo_cytomat_2_c6002" + ], + "representation": { + "type": "fallback-composite-stl", + "dimensionsMm": { + "width": 572, + "depth": 511, + "height": 905, + "plateCenterZ": 460 + }, + "sourceUrls": [ + "https://assets.thermofisher.com/TFS-Assets/CMD/Specification-Sheets/PS-50118298-Cytomat-2-C4xx-LIN-UB-Dimensions-PS50118298-EN.pdf", + "https://documents.thermofisher.com/TFS-Assets/CMD/brochures/br-90468-cytomat-2-c-lin-br90468-en.pdf" + ] + }, + "accessFrames": [ + { + "name": "transfer-opening", + "linkSuffix": "transfer_opening_access_link", + "description": "Front automatic gate / transfer opening aligned to the nominal SBS plate center.", + "collision_strategy": "opening_cutout", + "poseMeters": { + "x": 0.0, + "y": 0.3105, + "z": 0.46, + "roll": 0.0, + "pitch": 0.0, + "yaw": 0.0 + } + }, + { + "name": "loading-tray", + "linkSuffix": "loading_tray_access_link", + "description": "Front loading tray handoff point aligned to the nominal SBS plate center.", + "collision_strategy": "fixed_extended_tray", + "poseMeters": { + "x": 0.0, + "y": 0.3905, + "z": 0.46, + "roll": 0.0, + "pitch": 0.0, + "yaw": 0.0 + } + } + ] +} diff --git a/unilabos/device_mesh/devices/eppendorf_centrifuge_5910_ri/macro_device.xacro b/unilabos/device_mesh/devices/eppendorf_centrifuge_5910_ri/macro_device.xacro new file mode 100644 index 000000000..fe8802fe1 --- /dev/null +++ b/unilabos/device_mesh/devices/eppendorf_centrifuge_5910_ri/macro_device.xacro @@ -0,0 +1,147 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/unilabos/device_mesh/devices/eppendorf_centrifuge_5910_ri/meshes/body_core.stl b/unilabos/device_mesh/devices/eppendorf_centrifuge_5910_ri/meshes/body_core.stl new file mode 100644 index 000000000..3e2e47dad Binary files /dev/null and b/unilabos/device_mesh/devices/eppendorf_centrifuge_5910_ri/meshes/body_core.stl differ diff --git a/unilabos/device_mesh/devices/eppendorf_centrifuge_5910_ri/meshes/display_bezel.stl b/unilabos/device_mesh/devices/eppendorf_centrifuge_5910_ri/meshes/display_bezel.stl new file mode 100644 index 000000000..1e6a1ddd7 Binary files /dev/null and b/unilabos/device_mesh/devices/eppendorf_centrifuge_5910_ri/meshes/display_bezel.stl differ diff --git a/unilabos/device_mesh/devices/eppendorf_centrifuge_5910_ri/meshes/front_panel.stl b/unilabos/device_mesh/devices/eppendorf_centrifuge_5910_ri/meshes/front_panel.stl new file mode 100644 index 000000000..40db4588e Binary files /dev/null and b/unilabos/device_mesh/devices/eppendorf_centrifuge_5910_ri/meshes/front_panel.stl differ diff --git a/unilabos/device_mesh/devices/eppendorf_centrifuge_5910_ri/meshes/lid_shell.stl b/unilabos/device_mesh/devices/eppendorf_centrifuge_5910_ri/meshes/lid_shell.stl new file mode 100644 index 000000000..bebf6ba1b Binary files /dev/null and b/unilabos/device_mesh/devices/eppendorf_centrifuge_5910_ri/meshes/lid_shell.stl differ diff --git a/unilabos/device_mesh/devices/eppendorf_centrifuge_5910_ri/meshes/plinth.stl b/unilabos/device_mesh/devices/eppendorf_centrifuge_5910_ri/meshes/plinth.stl new file mode 100644 index 000000000..f8de0291c Binary files /dev/null and b/unilabos/device_mesh/devices/eppendorf_centrifuge_5910_ri/meshes/plinth.stl differ diff --git a/unilabos/device_mesh/devices/eppendorf_centrifuge_5910_ri/meta.json b/unilabos/device_mesh/devices/eppendorf_centrifuge_5910_ri/meta.json new file mode 100644 index 000000000..dfcde8a34 --- /dev/null +++ b/unilabos/device_mesh/devices/eppendorf_centrifuge_5910_ri/meta.json @@ -0,0 +1,77 @@ +{ + "fileName": "eppendorf_centrifuge_5910_ri", + "related": [ + "eppendorf_centrifuge_5910_ri" + ], + "model_strategy": "fallback_box_assembly", + "sources": [ + "https://www.eppendorf.com/us-en/Products/Centrifugation/Multipurpose-Centrifuges/Centrifuge-5910Ri-p-PF-963296", + "https://www.eppendorf.com/product-media/doc/en/996680/Centrifugation_Brochure_Centrifuge-5910-Ri_Accelerate-Your-Results.pdf", + "https://www.eppendorf.com/product-media/img/global/966241/Eppendorf_Centrifugation_Centrifuge-5910-Ri_side-view-open_product.jpg?imwidth=540", + "https://www.eppendorf.com/product-media/img/global/966312/Eppendorf_Centrifugation_Centrifuge-5910-Ri_side-view-closed_product.jpg?imwidth=540" + ], + "dimensions_m": { + "width": 0.72, + "depth": 0.68, + "height": 0.37, + "footprint_depth_without_front_panel": 0.62, + "height_with_open_lid": 0.85 + }, + "access_points": [ + { + "name": "lid_handle_access", + "description": "Estimated front-center latch and lift position for the top lid, based on the official closed-unit product photo and the documented 6 cm front-panel protrusion.", + "face": "front", + "collision_strategy": "opening_cutout", + "center_m": { + "x": 0.0, + "y": -0.37, + "z": 0.355 + }, + "size_m": { + "x": 0.18, + "y": 0.06, + "z": 0.05 + } + }, + { + "name": "loading_access", + "description": "Estimated top-loading clearance above the rotor chamber center, using the official open-lid height of 85 cm and the open-lid product image.", + "face": "top", + "collision_strategy": "opening_cutout", + "center_m": { + "x": 0.0, + "y": 0.03, + "z": 0.586 + }, + "size_m": { + "x": 0.36, + "y": 0.30, + "z": 0.18 + } + }, + { + "name": "rear_service_access", + "description": "Estimated rear service zone for cable, power, and network access, positioned just outside the simplified rear shell so it is reachable without intersecting the body collision.", + "face": "rear", + "collision_strategy": "opening_cutout", + "center_m": { + "x": 0.0, + "y": 0.38, + "z": 0.204 + }, + "size_m": { + "x": 0.18, + "y": 0.06, + "z": 0.10 + } + } + ], + "notes": [ + "No usable public CAD, STL, OBJ, GLB, or similar 3D model for the Eppendorf Centrifuge 5910 Ri was found during web search, so this package uses generated fallback geometry.", + "The mesh assembly uses the official dimensions of 72 x 68 x 37 cm, the official footprint without front panel of 72 x 62 cm, and official closed/open product photos to approximate the recessed body, front control fascia, and top lid proportions.", + "The front fascia depth is inferred as 6 cm from the difference between the overall depth and the footprint depth without the front panel.", + "The loading-access and lid-handle frames are estimated from official imagery and should be treated as integration aids rather than measured mechanical datums.", + "Collision geometry is intentionally modeled as a lower body plus upper shell with a top/front opening corridor, and the rear service access is placed just behind the rear shell to avoid embedding service approaches inside the simplified collision volume." + ] +} diff --git a/unilabos/device_mesh/devices/hamilton_vantage/macro_device.xacro b/unilabos/device_mesh/devices/hamilton_vantage/macro_device.xacro new file mode 100644 index 000000000..9bffdba16 --- /dev/null +++ b/unilabos/device_mesh/devices/hamilton_vantage/macro_device.xacro @@ -0,0 +1,259 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/unilabos/device_mesh/devices/hamilton_vantage/meshes/unit_box.stl b/unilabos/device_mesh/devices/hamilton_vantage/meshes/unit_box.stl new file mode 100644 index 000000000..639faa7ca Binary files /dev/null and b/unilabos/device_mesh/devices/hamilton_vantage/meshes/unit_box.stl differ diff --git a/unilabos/device_mesh/devices/hamilton_vantage/meshes/vantage_1_3.stl b/unilabos/device_mesh/devices/hamilton_vantage/meshes/vantage_1_3.stl new file mode 100644 index 000000000..8d710dffd Binary files /dev/null and b/unilabos/device_mesh/devices/hamilton_vantage/meshes/vantage_1_3.stl differ diff --git a/unilabos/device_mesh/devices/hamilton_vantage/meshes/vantage_2_0.stl b/unilabos/device_mesh/devices/hamilton_vantage/meshes/vantage_2_0.stl new file mode 100644 index 000000000..0b3d1090c Binary files /dev/null and b/unilabos/device_mesh/devices/hamilton_vantage/meshes/vantage_2_0.stl differ diff --git a/unilabos/device_mesh/devices/hamilton_vantage/meta.json b/unilabos/device_mesh/devices/hamilton_vantage/meta.json new file mode 100644 index 000000000..a612635ce --- /dev/null +++ b/unilabos/device_mesh/devices/hamilton_vantage/meta.json @@ -0,0 +1,260 @@ +{ + "fileName": "hamilton_vantage", + "related": [ + "vantage_backend", + "hamilton_vantage", + "microlab_vantage", + "hamilton_vantage_1_3", + "hamilton_vantage_2_0" + ], + "model_strategy": "fallback_segmented_box_from_official_dimensions_and_official_product_renders", + "sources": [ + { + "title": "Hamilton Microlab VANTAGE product page", + "url": "https://www.hamiltoncompany.com/microlab-vantage", + "kind": "official_product_page", + "notes": "Provides the official 1.3 m and 2.0 m system dimensions, deck capacities, sample-loading wording, and confirms 360-degree device integration around the platform." + }, + { + "title": "Hamilton Microlab VANTAGE site requirements PDF", + "url": "https://robotics.hamiltoncompany.com/hubfs/ROB_US%20Microlab%20VANTAGE%20Site%20Requirements%20%28HAR0287%29/VANTAGE-Site-Requirements.pdf?hsLang=en-us", + "kind": "official_site_requirements_pdf", + "notes": "Provides shipping-crate footprints and configuration weights for the 1.3 m and 2.0 m pipettor, logistics cabinet, rear integration cabinet, and track gripper." + }, + { + "title": "Hamilton official Microlab VANTAGE 1.3 front-closed render", + "url": "https://assets-robotics.hamiltoncompany.com/1_Platforms/Microlab-VANTAGE/250530_VANTAGE-13AB_front-closed.png?v=1748872499", + "kind": "official_render", + "notes": "Primary visual reference for the stacked upper pipetting enclosure, lower logistics cabinet glazing, dual front handle bars, and central pipetting head silhouette." + }, + { + "title": "Hamilton official Microlab VANTAGE 2.0 front-closed render", + "url": "https://assets-robotics.hamiltoncompany.com/1_Platforms/Microlab-VANTAGE/221213_VANTAGE-20AB_front-closed_statuslight-green-40.png?v=1747897714", + "kind": "official_render", + "notes": "Used to verify that the 2.0 m system preserves the same stacked front silhouette while extending the deck width and status-light span." + }, + { + "title": "Hamilton official Optional Track Gripper exploded render", + "url": "https://assets-robotics.hamiltoncompany.com/1_Platforms/Microlab-VANTAGE/4-Optional-Track-Gripper.png?v=1749542466", + "kind": "official_render", + "notes": "Used to estimate the open-top deck depth, the lower logistics-cabinet volume, and the right-side handoff / under-deck access region." + } + ], + "dimensions_m": { + "vantage_1_3": { + "width": 1.33, + "depth": 0.98, + "height": 1.82, + "weight_kg": 273, + "deck_tracks": 54, + "ansi_slas_positions": 35 + }, + "vantage_2_0": { + "width": 2.01, + "depth": 0.98, + "height": 1.82, + "weight_kg": 451, + "deck_tracks": 80, + "ansi_slas_positions": 60 + }, + "site_requirements": { + "pipettor_1_3_shipping_crate": { + "length": 1.201, + "width": 1.473, + "height": 1.316 + }, + "pipettor_2_0_shipping_crate": { + "length": 1.201, + "width": 2.146, + "height": 1.316 + } + } + }, + "collision_model": { + "strategy": "segmented_hybrid_open_deck_with_fixed_extended_loading_tray", + "blocked_parts": [ + "lower_rear_body_shell", + "upper_left_body_shell", + "upper_right_body_shell", + "upper_rear_body_shell", + "fixed_extended_loading_tray" + ], + "notes": "The simplified collision intentionally removes the single full-body envelope. The upper deck remains reachable from above and from the front, while the lower loading tray is modeled as a separate fixed, extended collision outside the body shell." + }, + "mesh_assets": { + "visual_stl_parts": [ + "meshes/unit_box.stl" + ], + "legacy_fallback_meshes": [ + "meshes/vantage_1_3.stl", + "meshes/vantage_2_0.stl" + ] + }, + "access_points": [ + { + "name": "deck_access_link", + "description": "Approximate center of the primary pipetting deck / work surface inside the upper enclosure.", + "collision_strategy": "exposed_work_surface", + "collision_notes": "This access frame is intentionally positioned over the collision-free main work surface so vertical approach and front approach onto the deck remain valid.", + "variants": { + "vantage_1_3": { + "face": "internal_top_deck", + "center_m": [0.0, 0.0, 0.98], + "size_m": [1.09, 0.60, 0.001], + "rpy_rad": [0.0, 0.0, 0.0] + }, + "vantage_2_0": { + "face": "internal_top_deck", + "center_m": [0.0, 0.0, 0.98], + "size_m": [1.65, 0.60, 0.001], + "rpy_rad": [0.0, 0.0, 0.0] + } + } + }, + { + "name": "front_loading_access_link", + "description": "Approximate robot-facing access point immediately outside the upper front enclosure where manual front service or front-facing loading interactions would occur.", + "collision_strategy": "opening_cutout", + "collision_notes": "This access face sits in front of the open front cutout of the upper enclosure rather than inside the segmented shell collision.", + "variants": { + "vantage_1_3": { + "face": "front_upper", + "center_m": [0.0, -0.245, 0.98], + "size_m": [1.09, 0.001, 0.24], + "rpy_rad": [0.0, 0.0, 0.0] + }, + "vantage_2_0": { + "face": "front_upper", + "center_m": [0.0, -0.245, 0.98], + "size_m": [1.65, 0.001, 0.24], + "rpy_rad": [0.0, 0.0, 0.0] + } + } + }, + { + "name": "loading_tray_access_link", + "description": "Estimated right-front handoff point for the lower Entry/Exit / autoload region visible in official front and exploded renders.", + "collision_strategy": "fixed_extended_tray", + "collision_notes": "This access frame is tray-backed by the fixed, extended lower loading tray and is intentionally kept outside the body-shell collision volumes.", + "variants": { + "vantage_1_3": { + "face": "front_lower_right", + "center_m": [0.4522, -0.245, 0.265], + "size_m": [0.16, 0.001, 0.12], + "rpy_rad": [0.0, 0.0, 0.0] + }, + "vantage_2_0": { + "face": "front_lower_right", + "center_m": [0.6834, -0.245, 0.265], + "size_m": [0.16, 0.001, 0.12], + "rpy_rad": [0.0, 0.0, 0.0] + } + } + }, + { + "name": "front_service_access_link", + "description": "Approximate center of the upper front service face aligned with the dual handle bars and service window.", + "collision_strategy": "opening_cutout", + "collision_notes": "This service face is aligned to the segmented front opening and remains forward of the upper shell collision.", + "variants": { + "vantage_1_3": { + "face": "front_upper_service", + "center_m": [0.0, -0.245, 1.125], + "size_m": [0.50, 0.001, 0.12], + "rpy_rad": [0.0, 0.0, 0.0] + }, + "vantage_2_0": { + "face": "front_upper_service", + "center_m": [0.0, -0.245, 1.125], + "size_m": [0.76, 0.001, 0.12], + "rpy_rad": [0.0, 0.0, 0.0] + } + } + }, + { + "name": "logistics_cabinet_access_link", + "description": "Approximate center of the lower logistics-cabinet front glazing used for under-deck consumable access.", + "collision_strategy": "opening_cutout", + "collision_notes": "This lower-front access face is kept in the front glazing cutout zone so it is not embedded in the lower body shell collision.", + "variants": { + "vantage_1_3": { + "face": "front_lower", + "center_m": [0.0, -0.245, 0.345], + "size_m": [1.12, 0.001, 0.30], + "rpy_rad": [0.0, 0.0, 0.0] + }, + "vantage_2_0": { + "face": "front_lower", + "center_m": [0.0, -0.245, 0.345], + "size_m": [1.69, 0.001, 0.30], + "rpy_rad": [0.0, 0.0, 0.0] + } + } + }, + { + "name": "rear_service_access_link", + "description": "Approximate rear utility and maintenance region for cables, services, and rear-side integrations.", + "collision_strategy": "opening_cutout", + "collision_notes": "This rear service frame is placed behind the shortened rear shell collision so back-side utility access remains reachable.", + "variants": { + "vantage_1_3": { + "face": "rear", + "center_m": [0.0, 0.39, 0.92], + "size_m": [0.40, 0.001, 0.25], + "rpy_rad": [0.0, 0.0, 3.141592653589793] + }, + "vantage_2_0": { + "face": "rear", + "center_m": [0.0, 0.39, 0.92], + "size_m": [0.40, 0.001, 0.25], + "rpy_rad": [0.0, 0.0, 3.141592653589793] + } + } + }, + { + "name": "socketTypeGenericSbsFootprint_1_60_1", + "description": "Representative SBS work reference on the exposed upper pipetting deck.", + "collision_strategy": "exposed_work_surface", + "collision_notes": "This SBS work reference stays on the collision-free upper deck opening and is not embedded in the segmented shell.", + "variants": { + "vantage_1_3": { + "face": "internal_top_deck", + "center_m": [0.0, 0.0, 0.98], + "size_m": [0.12776, 0.08548, 0.015], + "rpy_rad": [0.0, 0.0, 0.0] + }, + "vantage_2_0": { + "face": "internal_top_deck", + "center_m": [0.0, 0.0, 0.98], + "size_m": [0.12776, 0.08548, 0.015], + "rpy_rad": [0.0, 0.0, 0.0] + } + } + }, + { + "name": "socketTypeGenericSbsFootprint_2_60_1", + "description": "Representative SBS handoff reference on the fixed, extended lower loading tray.", + "collision_strategy": "fixed_extended_tray", + "collision_notes": "This SBS handoff reference is anchored to the tray-backed loading region rather than the body shell, keeping the loading zone reachable.", + "variants": { + "vantage_1_3": { + "face": "front_lower_right_tray", + "center_m": [0.4522, -0.245, 0.265], + "size_m": [0.12776, 0.08548, 0.015], + "rpy_rad": [0.0, 0.0, 0.0] + }, + "vantage_2_0": { + "face": "front_lower_right_tray", + "center_m": [0.6834, -0.245, 0.265], + "size_m": [0.12776, 0.08548, 0.015], + "rpy_rad": [0.0, 0.0, 0.0] + } + } + } + ], + "notes": [ + "No public Hamilton CAD, STL, STEP, OBJ, GLB, or similar directly reusable 3D model was located during web search on official public pages, so this package remains a fallback representation rather than an OEM mesh import.", + "The mesh is now a segmented official-image-guided proxy instead of a single envelope box: it distinguishes the upper pipetting enclosure, lower logistics cabinet, glazing, front handle bars, deck surface, and approximate internal handoff structures.", + "Access points are integration-oriented estimates derived from official front renders, product-page wording about sample loading and 360-degree device integration, and the exploded track-gripper render; they should not be treated as factory metrology." + ] +} diff --git a/unilabos/device_mesh/devices/hettich_rotanta_460_robotic/macro_device.xacro b/unilabos/device_mesh/devices/hettich_rotanta_460_robotic/macro_device.xacro new file mode 100644 index 000000000..f946c99c0 --- /dev/null +++ b/unilabos/device_mesh/devices/hettich_rotanta_460_robotic/macro_device.xacro @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/unilabos/device_mesh/devices/hettich_rotanta_460_robotic/meshes/body_core.stl b/unilabos/device_mesh/devices/hettich_rotanta_460_robotic/meshes/body_core.stl new file mode 100644 index 000000000..9087ecaf3 Binary files /dev/null and b/unilabos/device_mesh/devices/hettich_rotanta_460_robotic/meshes/body_core.stl differ diff --git a/unilabos/device_mesh/devices/hettich_rotanta_460_robotic/meshes/front_control_panel.stl b/unilabos/device_mesh/devices/hettich_rotanta_460_robotic/meshes/front_control_panel.stl new file mode 100644 index 000000000..c1ffb8d7d Binary files /dev/null and b/unilabos/device_mesh/devices/hettich_rotanta_460_robotic/meshes/front_control_panel.stl differ diff --git a/unilabos/device_mesh/devices/hettich_rotanta_460_robotic/meshes/front_hatch_frame.stl b/unilabos/device_mesh/devices/hettich_rotanta_460_robotic/meshes/front_hatch_frame.stl new file mode 100644 index 000000000..0bbdc2125 Binary files /dev/null and b/unilabos/device_mesh/devices/hettich_rotanta_460_robotic/meshes/front_hatch_frame.stl differ diff --git a/unilabos/device_mesh/devices/hettich_rotanta_460_robotic/meshes/lid_shell.stl b/unilabos/device_mesh/devices/hettich_rotanta_460_robotic/meshes/lid_shell.stl new file mode 100644 index 000000000..e341183f8 Binary files /dev/null and b/unilabos/device_mesh/devices/hettich_rotanta_460_robotic/meshes/lid_shell.stl differ diff --git a/unilabos/device_mesh/devices/hettich_rotanta_460_robotic/meshes/plinth.stl b/unilabos/device_mesh/devices/hettich_rotanta_460_robotic/meshes/plinth.stl new file mode 100644 index 000000000..004e078c4 Binary files /dev/null and b/unilabos/device_mesh/devices/hettich_rotanta_460_robotic/meshes/plinth.stl differ diff --git a/unilabos/device_mesh/devices/hettich_rotanta_460_robotic/meshes/rear_hatch_frame.stl b/unilabos/device_mesh/devices/hettich_rotanta_460_robotic/meshes/rear_hatch_frame.stl new file mode 100644 index 000000000..d81674f71 Binary files /dev/null and b/unilabos/device_mesh/devices/hettich_rotanta_460_robotic/meshes/rear_hatch_frame.stl differ diff --git a/unilabos/device_mesh/devices/hettich_rotanta_460_robotic/meta.json b/unilabos/device_mesh/devices/hettich_rotanta_460_robotic/meta.json new file mode 100644 index 000000000..dd1aca1dc --- /dev/null +++ b/unilabos/device_mesh/devices/hettich_rotanta_460_robotic/meta.json @@ -0,0 +1,44 @@ +{ + "fileName": "hettich_rotanta_460_robotic", + "related": [ + "hettich_rotanta_460_robotic" + ], + "access_points": [ + { + "name": "front_lid_hatch_access_link", + "face": "top_front", + "collision_strategy": "opening_cutout", + "xyz_m": [0.0, -0.1885, 0.729], + "rpy_rad": [0.0, 0.0, 0.0], + "description": "Front robotic lid-hatch loading point positioned over the front portion of the top opening." + }, + { + "name": "rear_lid_hatch_access_link", + "face": "top_rear", + "collision_strategy": "opening_cutout", + "xyz_m": [0.0, 0.1885, 0.729], + "rpy_rad": [0.0, 0.0, 3.141592653589793], + "description": "Rear robotic lid-hatch loading point positioned over the rear portion of the same top opening." + }, + { + "name": "top_loading_access_link", + "face": "top_center", + "collision_strategy": "opening_cutout", + "xyz_m": [0.0, 0.0, 0.729], + "rpy_rad": [0.0, 0.0, 0.0], + "description": "Centered loading approach above the large top cutout left open through the upper collision shell." + }, + { + "name": "rear_service_access_link", + "face": "rear", + "collision_strategy": "opening_cutout", + "xyz_m": [0.0, 0.3705, 0.3078], + "rpy_rad": [0.0, 0.0, 3.141592653589793], + "description": "Rear utilities and service approach placed just behind the rear face so cabling access is not embedded inside the body collision." + } + ], + "notes": [ + "Collision was refactored from one solid device box into a lower body block plus an upper perimeter rim, leaving a genuine top opening corridor for the front, rear, and centered loading access frames.", + "The rear service access frame was moved to sit slightly behind the rear shell so service approach no longer intersects the simplified body collision." + ] +} diff --git a/unilabos/device_mesh/devices/incubator/macro_device.xacro b/unilabos/device_mesh/devices/incubator/macro_device.xacro new file mode 100644 index 000000000..af26965bf --- /dev/null +++ b/unilabos/device_mesh/devices/incubator/macro_device.xacro @@ -0,0 +1,187 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/unilabos/device_mesh/devices/incubator/meshes/base_plinth.stl b/unilabos/device_mesh/devices/incubator/meshes/base_plinth.stl new file mode 100644 index 000000000..5deda67b2 Binary files /dev/null and b/unilabos/device_mesh/devices/incubator/meshes/base_plinth.stl differ diff --git a/unilabos/device_mesh/devices/incubator/meshes/cabinet_body.stl b/unilabos/device_mesh/devices/incubator/meshes/cabinet_body.stl new file mode 100644 index 000000000..278690f78 Binary files /dev/null and b/unilabos/device_mesh/devices/incubator/meshes/cabinet_body.stl differ diff --git a/unilabos/device_mesh/devices/incubator/meshes/control_display.stl b/unilabos/device_mesh/devices/incubator/meshes/control_display.stl new file mode 100644 index 000000000..bb1bcbea1 Binary files /dev/null and b/unilabos/device_mesh/devices/incubator/meshes/control_display.stl differ diff --git a/unilabos/device_mesh/devices/incubator/meshes/door_handle.stl b/unilabos/device_mesh/devices/incubator/meshes/door_handle.stl new file mode 100644 index 000000000..bfc14e954 Binary files /dev/null and b/unilabos/device_mesh/devices/incubator/meshes/door_handle.stl differ diff --git a/unilabos/device_mesh/devices/incubator/meshes/loading_tray.stl b/unilabos/device_mesh/devices/incubator/meshes/loading_tray.stl new file mode 100644 index 000000000..6a0273584 Binary files /dev/null and b/unilabos/device_mesh/devices/incubator/meshes/loading_tray.stl differ diff --git a/unilabos/device_mesh/devices/incubator/meshes/lower_service_panel.stl b/unilabos/device_mesh/devices/incubator/meshes/lower_service_panel.stl new file mode 100644 index 000000000..c7c43d3fb Binary files /dev/null and b/unilabos/device_mesh/devices/incubator/meshes/lower_service_panel.stl differ diff --git a/unilabos/device_mesh/devices/incubator/meshes/robotic_gate_bezel.stl b/unilabos/device_mesh/devices/incubator/meshes/robotic_gate_bezel.stl new file mode 100644 index 000000000..d61015f9d Binary files /dev/null and b/unilabos/device_mesh/devices/incubator/meshes/robotic_gate_bezel.stl differ diff --git a/unilabos/device_mesh/devices/incubator/meshes/upper_door_panel.stl b/unilabos/device_mesh/devices/incubator/meshes/upper_door_panel.stl new file mode 100644 index 000000000..6bfca04bd Binary files /dev/null and b/unilabos/device_mesh/devices/incubator/meshes/upper_door_panel.stl differ diff --git a/unilabos/device_mesh/devices/incubator/meta.json b/unilabos/device_mesh/devices/incubator/meta.json new file mode 100644 index 000000000..aa8134da7 --- /dev/null +++ b/unilabos/device_mesh/devices/incubator/meta.json @@ -0,0 +1,81 @@ +{ + "fileName": "incubator", + "related": [ + "incubator", + "_phage_display_incubator", + "generic_automated_microplate_incubator" + ], + "representation": { + "type": "fallback-composite-stl", + "representativeModel": { + "vendor": "LiCONiC", + "model": "StoreX STX220-SA", + "formFactor": "stand-alone automated microplate incubator with a full front user door, upper robotic gate, and nominal 1000 mm transfer/loading tray height" + }, + "dimensionsMm": { + "width": 744, + "depth": 716, + "height": 1203, + "transferHeight": 1000 + }, + "sourceUrls": [ + "https://legacysite.liconic.com/old/products/automated-incubators/stx/cad-files/stx220-sa_step.zip", + "https://legacysite.liconic.com/old/products/automated-incubators/stx/stx220sa_dimensions.php", + "https://legacysite.liconic.com/old/products/automated-incubators/stx/cad-files/stx220-sa.pdf", + "https://legacysite.liconic.com/old/products/automated-incubators/stx/stx220sa_description.php", + "https://legacysite.liconic.com/old/support/flyer/flyer_new-stx220.pdf", + "https://legacysite.liconic.com/old/products/automated-incubators/stx/stx220sa_drawings.php" + ], + "notes": [ + "A vendor STEP download was found for the representative STX220-SA, but no practical STEP-to-STL conversion toolchain is available in this workspace, so the delivered mesh is a composite STL fallback.", + "The fallback follows the official STX220-SA outer envelope and uses vendor imagery plus flush-gate/transfer-height documentation to estimate the upper robotic gate and protruding loading tray." + ] + }, + "accessFrames": [ + { + "name": "front-user-door", + "linkSuffix": "front_user_access_link", + "description": "Estimated manual front access point centered on the full-sized user door for plate loading and service access.", + "collision_strategy": "opening_cutout", + "collision_notes": "The simplified cabinet collision leaves a front body cutout through the main door region so approach to this body-mounted access frame is not blocked by a solid front wall.", + "poseMeters": { + "x": 0.0, + "y": 0.398, + "z": 0.78, + "roll": 0.0, + "pitch": 0.0, + "yaw": 0.0 + } + }, + { + "name": "loading-tray", + "linkSuffix": "loading_tray_access_link", + "description": "Estimated robotic tray handoff point aligned with the representative upper flush gate and 1000 mm transfer height.", + "collision_strategy": "fixed_extended_tray", + "collision_notes": "This access frame is mounted on a dedicated loading tray link, with the tray collision modeled in a fixed fully extended pose outside the body shell.", + "poseMeters": { + "x": 0.0, + "y": 0.513, + "z": 1.0, + "roll": 0.0, + "pitch": 0.0, + "yaw": 0.0 + } + }, + { + "name": "lower-control-panel", + "linkSuffix": "control_panel_access_link", + "description": "Estimated operator access point for the lower-right display and control cluster visible in the representative STX220-SA front image.", + "collision_strategy": "opening_cutout", + "collision_notes": "The front cutout used for body access keeps this main-body service/control approach reachable without routing through the simplified cabinet shell.", + "poseMeters": { + "x": 0.275, + "y": 0.393, + "z": 0.2, + "roll": 0.0, + "pitch": 0.0, + "yaw": 0.0 + } + } + ] +} diff --git a/unilabos/device_mesh/devices/incubator_shaker_stack/macro_device.xacro b/unilabos/device_mesh/devices/incubator_shaker_stack/macro_device.xacro new file mode 100644 index 000000000..2014f9517 --- /dev/null +++ b/unilabos/device_mesh/devices/incubator_shaker_stack/macro_device.xacro @@ -0,0 +1,323 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/unilabos/device_mesh/devices/incubator_shaker_stack/meshes/front_access_face.stl b/unilabos/device_mesh/devices/incubator_shaker_stack/meshes/front_access_face.stl new file mode 100644 index 000000000..d7e81a288 Binary files /dev/null and b/unilabos/device_mesh/devices/incubator_shaker_stack/meshes/front_access_face.stl differ diff --git a/unilabos/device_mesh/devices/incubator_shaker_stack/meshes/loading_tray.stl b/unilabos/device_mesh/devices/incubator_shaker_stack/meshes/loading_tray.stl new file mode 100644 index 000000000..f64633b00 Binary files /dev/null and b/unilabos/device_mesh/devices/incubator_shaker_stack/meshes/loading_tray.stl differ diff --git a/unilabos/device_mesh/devices/incubator_shaker_stack/meshes/unit_body.stl b/unilabos/device_mesh/devices/incubator_shaker_stack/meshes/unit_body.stl new file mode 100644 index 000000000..f66991f6c Binary files /dev/null and b/unilabos/device_mesh/devices/incubator_shaker_stack/meshes/unit_body.stl differ diff --git a/unilabos/device_mesh/devices/incubator_shaker_stack/meta.json b/unilabos/device_mesh/devices/incubator_shaker_stack/meta.json new file mode 100644 index 000000000..f31ad3f18 --- /dev/null +++ b/unilabos/device_mesh/devices/incubator_shaker_stack/meta.json @@ -0,0 +1,118 @@ +{ + "fileName": "incubator_shaker_stack", + "related": [ + "incubator_shaker_stack", + "inheco_incubator_shaker_stack", + "inheco_single_plate_incubator_shaker" + ], + "model_strategy": "fallback_composite_stl_from_inheco_dimensions_and_official_product_imagery", + "representative_form_factor": { + "vendor": "INHECO", + "series": "Single Plate Incubator Shaker", + "chosen_model": "3-unit stack of Single Plate Incubator Shaker MP modules", + "product_part_number": "7300013", + "reason": "The official INHECO brochure documents the MP shaker variant as stackable up to three units and shows the per-unit drawer/door behavior used by this driver family." + }, + "sources": [ + { + "title": "INHECO Single Plate Incubator Shaker product page", + "url": "https://www.inheco.com/incubator-shaker.html", + "kind": "product_page", + "notes": "Provides the external envelope for the shaker MP module (149 x 268.5 x 88.5 mm) and the open-drawer depth figure of 209 mm." + }, + { + "title": "INHECO Single Plate Incubators brochure", + "url": "https://www.inheco.com/data/pdf/bro-incubator-10-2022-1026-0919-32.pdf", + "kind": "brochure", + "notes": "Confirms the stackable tower concept, the 7300013 shaker MP identity, the 149 x 268 x 87 mm rounded dimensions, and the 90-degree door rotation after drawer opening." + }, + { + "title": "INHECO Single Plate Incubator Shaker feature overview", + "url": "https://www.inheco.com/index.php?id=259", + "kind": "feature_page", + "notes": "Secondary visual reference for the stacked thermoshaker silhouette and operator-facing drawer arrangement." + } + ], + "dimensions_m": { + "representative_stack": { + "width": 0.149, + "depth": 0.2685, + "height": 0.2655 + }, + "single_unit": { + "width": 0.149, + "depth": 0.2685, + "height": 0.0885 + }, + "open_drawer_depth": 0.209 + }, + "mesh_assets": { + "unit_body": "meshes/unit_body.stl", + "front_access_face": "meshes/front_access_face.stl", + "loading_tray": "meshes/loading_tray.stl" + }, + "access_points": [ + { + "name": "lower_unit_front_access_link", + "face": "front", + "xyz_m": [0.0, 0.15025, 0.041], + "rpy_rad": [0.0, 0.0, 0.0], + "collision_strategy": "opening_cutout", + "description": "Estimated center of the lower unit front access face / door area." + }, + { + "name": "middle_unit_front_access_link", + "face": "front", + "xyz_m": [0.0, 0.15025, 0.1295], + "rpy_rad": [0.0, 0.0, 0.0], + "collision_strategy": "opening_cutout", + "description": "Estimated center of the middle unit front access face / door area." + }, + { + "name": "upper_unit_front_access_link", + "face": "front", + "xyz_m": [0.0, 0.15025, 0.218], + "rpy_rad": [0.0, 0.0, 0.0], + "collision_strategy": "opening_cutout", + "description": "Estimated center of the upper unit front access face / door area." + }, + { + "name": "lower_unit_loading_tray_access_link", + "face": "front", + "xyz_m": [0.0, 0.16425, 0.0512], + "rpy_rad": [0.0, 0.0, 0.0], + "collision_strategy": "fixed_extended_tray", + "description": "Representative plate handoff point above the lower unit loading tray." + }, + { + "name": "middle_unit_loading_tray_access_link", + "face": "front", + "xyz_m": [0.0, 0.16425, 0.1397], + "rpy_rad": [0.0, 0.0, 0.0], + "collision_strategy": "fixed_extended_tray", + "description": "Representative plate handoff point above the middle unit loading tray." + }, + { + "name": "upper_unit_loading_tray_access_link", + "face": "front", + "xyz_m": [0.0, 0.16425, 0.2282], + "rpy_rad": [0.0, 0.0, 0.0], + "collision_strategy": "fixed_extended_tray", + "description": "Representative plate handoff point above the upper unit loading tray." + }, + { + "name": "rear_service_access_link", + "face": "rear", + "xyz_m": [0.0, -0.18425, 0.13275], + "rpy_rad": [0.0, 0.0, 3.141592653589793], + "collision_strategy": "opening_cutout", + "description": "Approximate rear-side service reference for power and communications cabling." + } + ], + "notes": [ + "No public vendor CAD, STL, STEP, GLB, or USDZ model for the INHECO Single Plate Incubator Shaker stack surfaced in web search, so this package uses a representative fallback mesh instead of implying factory-exact geometry.", + "The fallback chooses a three-unit stack of the shaker MP module because it is the most explicitly documented stack form and best matches the driver's stacked thermoshaker/incubator intent.", + "Per-unit tray heights are anchored to the driver's `incubator_shaker_mp` loading-tray z reference of 51.2 mm; front/tray x positions are centered because the official sources do not publish a left-right tray-center offset.", + "The tray and front-face access frames are integration-friendly estimates derived from official dimensions and brochure imagery and should be refined if service drawings or direct measurements become available." + ] +} diff --git a/unilabos/device_mesh/devices/li_ha/macro_device.xacro b/unilabos/device_mesh/devices/li_ha/macro_device.xacro new file mode 100644 index 000000000..f4090f5b6 --- /dev/null +++ b/unilabos/device_mesh/devices/li_ha/macro_device.xacro @@ -0,0 +1,215 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/unilabos/device_mesh/devices/li_ha/meshes/base_plinth.stl b/unilabos/device_mesh/devices/li_ha/meshes/base_plinth.stl new file mode 100644 index 000000000..2e3d16797 Binary files /dev/null and b/unilabos/device_mesh/devices/li_ha/meshes/base_plinth.stl differ diff --git a/unilabos/device_mesh/devices/li_ha/meshes/console_pedestal.stl b/unilabos/device_mesh/devices/li_ha/meshes/console_pedestal.stl new file mode 100644 index 000000000..4c2cfa1df Binary files /dev/null and b/unilabos/device_mesh/devices/li_ha/meshes/console_pedestal.stl differ diff --git a/unilabos/device_mesh/devices/li_ha/meshes/console_screen.stl b/unilabos/device_mesh/devices/li_ha/meshes/console_screen.stl new file mode 100644 index 000000000..7f9f5b02b Binary files /dev/null and b/unilabos/device_mesh/devices/li_ha/meshes/console_screen.stl differ diff --git a/unilabos/device_mesh/devices/li_ha/meshes/front_guard.stl b/unilabos/device_mesh/devices/li_ha/meshes/front_guard.stl new file mode 100644 index 000000000..3688d75be Binary files /dev/null and b/unilabos/device_mesh/devices/li_ha/meshes/front_guard.stl differ diff --git a/unilabos/device_mesh/devices/li_ha/meshes/pipetting_head.stl b/unilabos/device_mesh/devices/li_ha/meshes/pipetting_head.stl new file mode 100644 index 000000000..98981c739 Binary files /dev/null and b/unilabos/device_mesh/devices/li_ha/meshes/pipetting_head.stl differ diff --git a/unilabos/device_mesh/devices/li_ha/meshes/rear_cabinet.stl b/unilabos/device_mesh/devices/li_ha/meshes/rear_cabinet.stl new file mode 100644 index 000000000..3ed51a7bc Binary files /dev/null and b/unilabos/device_mesh/devices/li_ha/meshes/rear_cabinet.stl differ diff --git a/unilabos/device_mesh/devices/li_ha/meshes/side_column.stl b/unilabos/device_mesh/devices/li_ha/meshes/side_column.stl new file mode 100644 index 000000000..008f4da55 Binary files /dev/null and b/unilabos/device_mesh/devices/li_ha/meshes/side_column.stl differ diff --git a/unilabos/device_mesh/devices/li_ha/meshes/worktable_deck.stl b/unilabos/device_mesh/devices/li_ha/meshes/worktable_deck.stl new file mode 100644 index 000000000..de7b4b8cb Binary files /dev/null and b/unilabos/device_mesh/devices/li_ha/meshes/worktable_deck.stl differ diff --git a/unilabos/device_mesh/devices/li_ha/meshes/x_rail_beam.stl b/unilabos/device_mesh/devices/li_ha/meshes/x_rail_beam.stl new file mode 100644 index 000000000..0c11c7f4a Binary files /dev/null and b/unilabos/device_mesh/devices/li_ha/meshes/x_rail_beam.stl differ diff --git a/unilabos/device_mesh/devices/li_ha/meta.json b/unilabos/device_mesh/devices/li_ha/meta.json new file mode 100644 index 000000000..5b8d4acbd --- /dev/null +++ b/unilabos/device_mesh/devices/li_ha/meta.json @@ -0,0 +1,144 @@ +{ + "fileName": "li_ha", + "related": [ + "li_ha", + "_phage_display_li_ha", + "tecan_liha", + "freedom_evo_150" + ], + "model_strategy": "fallback_segmented_box", + "representative_form_factor": { + "chosen_model": "Tecan Freedom EVO 150 liquid handling workstation", + "reason": "The driver is based on a Tecan EVO LiHa backend, and web search did not surface a clearly reusable public STL/STEP/GLB with repo-safe licensing. The fallback therefore uses the Freedom EVO 150 as a documented real-world reference for the cabinet, open deck, and front safety-panel opening." + }, + "sources": [ + { + "title": "Tecan Freedom EVO brochure", + "url": "https://www.tecan.com/hubfs/HubDB/Te-DocDB/pdf/BR_Freedom_EVO_392956_V7.pdf", + "kind": "brochure", + "notes": "Provides the clearest official front and angled product imagery used to estimate the open worktable, side columns, X-axis beam, and right-front control console proportions." + }, + { + "title": "Tecan Freedom EVO operating manual (overall dimensions)", + "url": "https://www.tecan.com/hubfs/Knowledgebase/Manuals/Freedom_EVO/392886_EVO%20OpM_it_V10_1.pdf?hsLang=en", + "kind": "operating_manual", + "notes": "Web search snippet exposes the overall Freedom EVO 150 envelope used here: 1450 mm width, 980 mm depth, and 1120 mm height." + }, + { + "title": "Tecan Freedom EVO operating manual (front safety-panel opening)", + "url": "https://www.tecan.com/hubfs/Knowledgebase/Manuals/Freedom_EVO/392886_EVO%20OpM_nl_V10_1.pdf", + "kind": "operating_manual", + "notes": "Web search snippet exposes the Freedom EVO 150 front safety-panel opening as 1130 x 170 mm, which is used for the front-loading access face." + } + ], + "dimensions_m": { + "width": 1.45, + "depth": 0.98, + "height": 1.12, + "worktable_height": 0.78, + "front_safety_panel_opening_width": 1.13, + "front_safety_panel_opening_height": 0.17 + }, + "collision_model": { + "strategy": "segmented_structural_blocks_with_open_deck", + "blocked_parts": [ + "rear_cabinet", + "left_side_column", + "right_side_column", + "x_rail_beam", + "front_guard_lower_bar", + "console_pedestal" + ], + "notes": "The simplified collision intentionally leaves the central worktable and the front safety-panel opening clear so the pipetting deck and representative SBS handoff positions remain reachable from above and from the front." + }, + "mesh_assets": { + "visual_stl_parts": [ + "meshes/base_plinth.stl", + "meshes/rear_cabinet.stl", + "meshes/side_column.stl", + "meshes/x_rail_beam.stl", + "meshes/worktable_deck.stl", + "meshes/front_guard.stl", + "meshes/pipetting_head.stl", + "meshes/console_pedestal.stl", + "meshes/console_screen.stl" + ] + }, + "access_points": [ + { + "name": "pipetting_deck_access_link", + "description": "Approximate center of the open pipetting deck / worktable surface below the LiHa gantry.", + "face": "top_open_deck", + "collision_strategy": "exposed_work_surface", + "center_m": [0.0, -0.02, 0.821], + "size_m": [1.13, 0.54, 0.001], + "rpy_rad": [0.0, 0.0, 0.0], + "collision_notes": "This frame sits over the intentionally open worktable span between the side columns, in front of the rear cabinet, and below the real gantry beam." + }, + { + "name": "socketTypeGenericSbsFootprint_1_60_1", + "description": "Left-side representative SBS work position on the exposed pipetting deck.", + "face": "top_open_deck", + "collision_strategy": "exposed_work_surface", + "center_m": [-0.34, -0.02, 0.824], + "size_m": [0.12776, 0.08548, 0.015], + "rpy_rad": [0.0, 0.0, 0.0], + "collision_notes": "This SBS handoff position stays outside the segmented cabinet, side-column, and front-guard collision volumes so vertical approach onto the open deck remains valid." + }, + { + "name": "socketTypeGenericSbsFootprint_2_60_1", + "description": "Center representative SBS work position on the exposed pipetting deck.", + "face": "top_open_deck", + "collision_strategy": "exposed_work_surface", + "center_m": [0.0, -0.02, 0.824], + "size_m": [0.12776, 0.08548, 0.015], + "rpy_rad": [0.0, 0.0, 0.0], + "collision_notes": "This centered SBS handoff position shares the same open-deck clearance envelope as the pipetting_deck_access_link." + }, + { + "name": "socketTypeGenericSbsFootprint_3_60_1", + "description": "Right-side representative SBS work position on the exposed pipetting deck.", + "face": "top_open_deck", + "collision_strategy": "exposed_work_surface", + "center_m": [0.34, -0.02, 0.824], + "size_m": [0.12776, 0.08548, 0.015], + "rpy_rad": [0.0, 0.0, 0.0], + "collision_notes": "This SBS handoff position remains forward of the rear cabinet and above the collision-free worktable opening." + }, + { + "name": "front_loading_access_link", + "description": "Approximate center of the front safety-panel opening used for manual deck access and front loading.", + "face": "front", + "collision_strategy": "opening_cutout", + "center_m": [0.0, -0.535, 0.865], + "size_m": [1.13, 0.001, 0.17], + "rpy_rad": [0.0, 0.0, 0.0], + "collision_notes": "This access face represents the front safety-panel opening and sits in front of the lower front guard collision bar." + }, + { + "name": "control_console_access_link", + "description": "Approximate center of the right-front console / display interaction area.", + "face": "front_right", + "collision_strategy": "opening_cutout", + "center_m": [0.58, -0.395, 0.905], + "size_m": [0.10, 0.001, 0.14], + "rpy_rad": [0.0, 0.0, 0.0], + "collision_notes": "The access frame is intentionally above and slightly forward of the console pedestal collision so the operator interaction zone is not embedded in structure." + }, + { + "name": "rear_service_access_link", + "description": "Approximate rear service region for utilities and maintenance access.", + "face": "rear", + "collision_strategy": "opening_cutout", + "center_m": [0.2, 0.53, 0.62], + "size_m": [0.25, 0.001, 0.2], + "rpy_rad": [0.0, 0.0, 3.141592653589793], + "collision_notes": "The rear service frame is placed just outside the rear cabinet collision envelope for cable, power, and pneumatic access." + } + ], + "notes": [ + "No clearly reusable public vendor 3D model for the Freedom EVO / generic Tecan LiHa workstation was found during web search, so this is a segmented fallback mesh rather than an exact OEM model.", + "The shape is intentionally open at the front and top so the pipetting deck remains visible and the access frames can represent manual loading semantics.", + "Console placement, deck depth, and rear service center are estimated from official brochure imagery and should be treated as integration references, not factory metrology." + ] +} diff --git a/unilabos/device_mesh/devices/molecular_devices_backend/macro_device.xacro b/unilabos/device_mesh/devices/molecular_devices_backend/macro_device.xacro new file mode 100644 index 000000000..408b804b6 --- /dev/null +++ b/unilabos/device_mesh/devices/molecular_devices_backend/macro_device.xacro @@ -0,0 +1,182 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/unilabos/device_mesh/devices/molecular_devices_backend/meshes/id5_base_plinth.stl b/unilabos/device_mesh/devices/molecular_devices_backend/meshes/id5_base_plinth.stl new file mode 100644 index 000000000..d28c900e4 Binary files /dev/null and b/unilabos/device_mesh/devices/molecular_devices_backend/meshes/id5_base_plinth.stl differ diff --git a/unilabos/device_mesh/devices/molecular_devices_backend/meshes/id5_drawer_face.stl b/unilabos/device_mesh/devices/molecular_devices_backend/meshes/id5_drawer_face.stl new file mode 100644 index 000000000..52e959662 Binary files /dev/null and b/unilabos/device_mesh/devices/molecular_devices_backend/meshes/id5_drawer_face.stl differ diff --git a/unilabos/device_mesh/devices/molecular_devices_backend/meshes/id5_left_console.stl b/unilabos/device_mesh/devices/molecular_devices_backend/meshes/id5_left_console.stl new file mode 100644 index 000000000..9c049c03f Binary files /dev/null and b/unilabos/device_mesh/devices/molecular_devices_backend/meshes/id5_left_console.stl differ diff --git a/unilabos/device_mesh/devices/molecular_devices_backend/meshes/id5_plate_tray.stl b/unilabos/device_mesh/devices/molecular_devices_backend/meshes/id5_plate_tray.stl new file mode 100644 index 000000000..deafdb4d0 Binary files /dev/null and b/unilabos/device_mesh/devices/molecular_devices_backend/meshes/id5_plate_tray.stl differ diff --git a/unilabos/device_mesh/devices/molecular_devices_backend/meshes/id5_right_housing.stl b/unilabos/device_mesh/devices/molecular_devices_backend/meshes/id5_right_housing.stl new file mode 100644 index 000000000..2ab8dec22 Binary files /dev/null and b/unilabos/device_mesh/devices/molecular_devices_backend/meshes/id5_right_housing.stl differ diff --git a/unilabos/device_mesh/devices/molecular_devices_backend/meshes/id5_screen_panel.stl b/unilabos/device_mesh/devices/molecular_devices_backend/meshes/id5_screen_panel.stl new file mode 100644 index 000000000..6bb1c8235 Binary files /dev/null and b/unilabos/device_mesh/devices/molecular_devices_backend/meshes/id5_screen_panel.stl differ diff --git a/unilabos/device_mesh/devices/molecular_devices_backend/meta.json b/unilabos/device_mesh/devices/molecular_devices_backend/meta.json new file mode 100644 index 000000000..b575b3fdc --- /dev/null +++ b/unilabos/device_mesh/devices/molecular_devices_backend/meta.json @@ -0,0 +1,100 @@ +{ + "fileName": "molecular_devices_backend", + "related": [ + "molecular_devices_backend", + "molecular_devices_plate_reader", + "spectramax_id5", + "spectramax_id5e" + ], + "representation": { + "type": "fallback-composite-stl", + "representativeModel": "Molecular Devices SpectraMax iD5 Multi-Mode Microplate Reader", + "dimensionsMm": { + "width": 532, + "depth": 598, + "height": 401, + "weight": 40 + }, + "sourceUrls": [ + "https://www.moleculardevices.com/products/microplate-readers/multi-mode-readers/spectramax-id3s-id5e-readers", + "https://www.moleculardevices.com/sites/default/files/en/assets/brochures/br/spectramax-id5-multi-mode-microplate-reader.pdf", + "https://www.moleculardevices.com/sites/default/files/en/assets/user-guide/br/spectramax-id5-user-guide-5059784h.pdf", + "https://www.moleculardevices.com/products/microplate-readers/multi-mode-readers/media_1ae5c1fe6d7c220c0304c6523fdb7d78884804fa8.jpg" + ], + "notes": [ + "No openly reusable Molecular Devices CAD, GLB, STL, or STEP model for a compatible SpectraMax plate reader was found during web search, so this package uses generated fallback meshes.", + "The chosen representative form factor is the SpectraMax iD5 enclosure family because the generic backend exposes multimode/TRF/FP capabilities that align more closely with the iD5 than with simpler Molecular Devices readers.", + "Visual proportions for the touchscreen console, right optics housing, and lower plate drawer were estimated from current SpectraMax iD5e product imagery while the outer envelope follows the published SpectraMax iD5 dimensions." + ] + }, + "accessFrames": [ + { + "name": "drawer-loading-front", + "linkSuffix": "drawer_loading_access_link", + "description": "Estimated front-of-drawer loading position for an SBS plate just outside the lower plate carrier opening.", + "collision_strategy": "fixed_extended_tray", + "face": "front", + "centerMeters": { + "x": 0.112, + "y": 0.357, + "z": 0.084 + }, + "sizeMeters": { + "x": 0.136, + "y": 0.040, + "z": 0.012 + } + }, + { + "name": "drawer-closed-carrier", + "linkSuffix": "drawer_internal_access_link", + "description": "Estimated closed carrier position for the internal microplate stage.", + "collision_strategy": "fixed_extended_tray", + "face": "interior", + "centerMeters": { + "x": 0.112, + "y": 0.214, + "z": 0.084 + }, + "sizeMeters": { + "x": 0.12776, + "y": 0.08548, + "z": 0.012 + } + }, + { + "name": "touchscreen-console", + "linkSuffix": "touchscreen_access_link", + "description": "Approximate operator interaction plane centered on the slanted touchscreen.", + "collision_strategy": "opening_cutout", + "face": "front-left", + "centerMeters": { + "x": -0.144, + "y": 0.020, + "z": 0.272 + }, + "sizeMeters": { + "x": 0.172, + "y": 0.008, + "z": 0.118 + } + }, + { + "name": "rear-service-zone", + "linkSuffix": "rear_service_access_link", + "description": "Estimated rear service clearance reference for utilities and preventive maintenance.", + "collision_strategy": "opening_cutout", + "face": "rear", + "centerMeters": { + "x": 0.0, + "y": -0.255, + "z": 0.235 + }, + "sizeMeters": { + "x": 0.276, + "y": 0.010, + "z": 0.180 + } + } + ] +} diff --git a/unilabos/device_mesh/devices/molecular_devices_qpix_420/macro_device.xacro b/unilabos/device_mesh/devices/molecular_devices_qpix_420/macro_device.xacro new file mode 100644 index 000000000..caf66490a --- /dev/null +++ b/unilabos/device_mesh/devices/molecular_devices_qpix_420/macro_device.xacro @@ -0,0 +1,276 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/unilabos/device_mesh/devices/molecular_devices_qpix_420/meshes/control_console.stl b/unilabos/device_mesh/devices/molecular_devices_qpix_420/meshes/control_console.stl new file mode 100644 index 000000000..4b9eebae0 Binary files /dev/null and b/unilabos/device_mesh/devices/molecular_devices_qpix_420/meshes/control_console.stl differ diff --git a/unilabos/device_mesh/devices/molecular_devices_qpix_420/meshes/deck_table.stl b/unilabos/device_mesh/devices/molecular_devices_qpix_420/meshes/deck_table.stl new file mode 100644 index 000000000..5c74cb9ad Binary files /dev/null and b/unilabos/device_mesh/devices/molecular_devices_qpix_420/meshes/deck_table.stl differ diff --git a/unilabos/device_mesh/devices/molecular_devices_qpix_420/meshes/front_door.stl b/unilabos/device_mesh/devices/molecular_devices_qpix_420/meshes/front_door.stl new file mode 100644 index 000000000..e724fcdca Binary files /dev/null and b/unilabos/device_mesh/devices/molecular_devices_qpix_420/meshes/front_door.stl differ diff --git a/unilabos/device_mesh/devices/molecular_devices_qpix_420/meshes/gantry_beam.stl b/unilabos/device_mesh/devices/molecular_devices_qpix_420/meshes/gantry_beam.stl new file mode 100644 index 000000000..b38410e70 Binary files /dev/null and b/unilabos/device_mesh/devices/molecular_devices_qpix_420/meshes/gantry_beam.stl differ diff --git a/unilabos/device_mesh/devices/molecular_devices_qpix_420/meshes/lane_tray.stl b/unilabos/device_mesh/devices/molecular_devices_qpix_420/meshes/lane_tray.stl new file mode 100644 index 000000000..c500414da Binary files /dev/null and b/unilabos/device_mesh/devices/molecular_devices_qpix_420/meshes/lane_tray.stl differ diff --git a/unilabos/device_mesh/devices/molecular_devices_qpix_420/meshes/left_side_cover.stl b/unilabos/device_mesh/devices/molecular_devices_qpix_420/meshes/left_side_cover.stl new file mode 100644 index 000000000..d438de675 Binary files /dev/null and b/unilabos/device_mesh/devices/molecular_devices_qpix_420/meshes/left_side_cover.stl differ diff --git a/unilabos/device_mesh/devices/molecular_devices_qpix_420/meshes/lower_chassis.stl b/unilabos/device_mesh/devices/molecular_devices_qpix_420/meshes/lower_chassis.stl new file mode 100644 index 000000000..0b8828148 Binary files /dev/null and b/unilabos/device_mesh/devices/molecular_devices_qpix_420/meshes/lower_chassis.stl differ diff --git a/unilabos/device_mesh/devices/molecular_devices_qpix_420/meshes/petri_stage.stl b/unilabos/device_mesh/devices/molecular_devices_qpix_420/meshes/petri_stage.stl new file mode 100644 index 000000000..3666fa9ed Binary files /dev/null and b/unilabos/device_mesh/devices/molecular_devices_qpix_420/meshes/petri_stage.stl differ diff --git a/unilabos/device_mesh/devices/molecular_devices_qpix_420/meshes/picker_head.stl b/unilabos/device_mesh/devices/molecular_devices_qpix_420/meshes/picker_head.stl new file mode 100644 index 000000000..302221f47 Binary files /dev/null and b/unilabos/device_mesh/devices/molecular_devices_qpix_420/meshes/picker_head.stl differ diff --git a/unilabos/device_mesh/devices/molecular_devices_qpix_420/meshes/upper_canopy.stl b/unilabos/device_mesh/devices/molecular_devices_qpix_420/meshes/upper_canopy.stl new file mode 100644 index 000000000..642586ecf Binary files /dev/null and b/unilabos/device_mesh/devices/molecular_devices_qpix_420/meshes/upper_canopy.stl differ diff --git a/unilabos/device_mesh/devices/molecular_devices_qpix_420/meta.json b/unilabos/device_mesh/devices/molecular_devices_qpix_420/meta.json new file mode 100644 index 000000000..d366d2bac --- /dev/null +++ b/unilabos/device_mesh/devices/molecular_devices_qpix_420/meta.json @@ -0,0 +1,112 @@ +{ + "fileName": "molecular_devices_qpix_420", + "related": [ + "molecular_devices_qpix_420", + "qpix_420", + "qpix_400_series" + ], + "representation": { + "type": "fallback-composite-stl", + "dimensionsMm": { + "width": 1440, + "depth": 790, + "height": 780 + }, + "sourceUrls": [ + "https://www.moleculardevices.com/products/specifications/qpix-400-series-microbial-colony-pickers.json", + "https://www.moleculardevices.com/sites/default/files/en/assets/user-guide/bpd/qpix-family-preinstall-guide-5083796b.pdf", + "https://www.moleculardevices.com/products/clone-screening/microbial-screening/qpix-400-series-microbial-colony-pickers", + "https://view.ceros.com/molecular-devices/qpix-lh/p/1?heightOverride=960" + ], + "notes": [ + "No public reusable Molecular Devices QPix 420 CAD, STEP, or STL asset was found during web search.", + "The mesh package is a composite box-model fallback using the official QPix 420 envelope plus front/side imagery from Molecular Devices product materials." + ] + }, + "accessFrames": [ + { + "name": "front-door-opening", + "linkSuffix": "front_door_access_link", + "collision_strategy": "opening_cutout", + "description": "Estimated main front opening centered on the acrylic safety door used to access the picking chamber.", + "poseMeters": { + "x": -0.05, + "y": -0.44, + "z": 0.57, + "roll": 0.0, + "pitch": 0.0, + "yaw": 0.0 + } + }, + { + "name": "deck-loading-zone", + "linkSuffix": "deck_access_link", + "collision_strategy": "exposed_work_surface", + "description": "Estimated left-front deck access point aligned to the petri-dish loading stage under the front door.", + "poseMeters": { + "x": -0.12, + "y": -0.28, + "z": 0.31, + "roll": 0.0, + "pitch": 0.0, + "yaw": 0.0 + } + }, + { + "name": "multi-plate-loading-zone", + "linkSuffix": "loading_access_link", + "collision_strategy": "exposed_work_surface", + "description": "Estimated right-front loading point aligned to the multi-position plate lane visible in Molecular Devices imagery.", + "poseMeters": { + "x": 0.2, + "y": -0.31, + "z": 0.31, + "roll": 0.0, + "pitch": 0.0, + "yaw": 0.0 + } + }, + { + "name": "imaging-and-picking-head", + "linkSuffix": "imaging_access_link", + "collision_strategy": "opening_cutout", + "description": "Estimated access point directly in front of the imaging/picking head carriage based on official open-door product images.", + "poseMeters": { + "x": 0.16, + "y": -0.14, + "z": 0.55, + "roll": 0.0, + "pitch": 0.0, + "yaw": 0.0 + } + }, + { + "name": "right-control-pod", + "linkSuffix": "control_access_link", + "collision_strategy": "opening_cutout", + "description": "Estimated operator access point for the right-side touchscreen and emergency stop area.", + "poseMeters": { + "x": 0.61, + "y": -0.23, + "z": 0.55, + "roll": 0.0, + "pitch": 0.0, + "yaw": 0.0 + } + }, + { + "name": "rear-service-zone", + "linkSuffix": "rear_service_access_link", + "collision_strategy": "opening_cutout", + "description": "Estimated rear service access for utilities and preventive-maintenance access noted in the pre-install guide.", + "poseMeters": { + "x": 0.0, + "y": 0.44, + "z": 0.38, + "roll": 0.0, + "pitch": 0.0, + "yaw": 3.141592653589793 + } + } + ] +} diff --git a/unilabos/device_mesh/devices/peeler/macro_device.xacro b/unilabos/device_mesh/devices/peeler/macro_device.xacro new file mode 100644 index 000000000..4f5a0bb98 --- /dev/null +++ b/unilabos/device_mesh/devices/peeler/macro_device.xacro @@ -0,0 +1,175 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/unilabos/device_mesh/devices/peeler/meshes/base_plinth.stl b/unilabos/device_mesh/devices/peeler/meshes/base_plinth.stl new file mode 100644 index 000000000..1604e31df Binary files /dev/null and b/unilabos/device_mesh/devices/peeler/meshes/base_plinth.stl differ diff --git a/unilabos/device_mesh/devices/peeler/meshes/chamber_back_panel.stl b/unilabos/device_mesh/devices/peeler/meshes/chamber_back_panel.stl new file mode 100644 index 000000000..6fab8f3ef Binary files /dev/null and b/unilabos/device_mesh/devices/peeler/meshes/chamber_back_panel.stl differ diff --git a/unilabos/device_mesh/devices/peeler/meshes/chamber_left_wall.stl b/unilabos/device_mesh/devices/peeler/meshes/chamber_left_wall.stl new file mode 100644 index 000000000..098358cf0 Binary files /dev/null and b/unilabos/device_mesh/devices/peeler/meshes/chamber_left_wall.stl differ diff --git a/unilabos/device_mesh/devices/peeler/meshes/chamber_right_wall.stl b/unilabos/device_mesh/devices/peeler/meshes/chamber_right_wall.stl new file mode 100644 index 000000000..d3966928f Binary files /dev/null and b/unilabos/device_mesh/devices/peeler/meshes/chamber_right_wall.stl differ diff --git a/unilabos/device_mesh/devices/peeler/meshes/chamber_roof.stl b/unilabos/device_mesh/devices/peeler/meshes/chamber_roof.stl new file mode 100644 index 000000000..cde7a8c5a Binary files /dev/null and b/unilabos/device_mesh/devices/peeler/meshes/chamber_roof.stl differ diff --git a/unilabos/device_mesh/devices/peeler/meshes/control_pod.stl b/unilabos/device_mesh/devices/peeler/meshes/control_pod.stl new file mode 100644 index 000000000..6009c57fc Binary files /dev/null and b/unilabos/device_mesh/devices/peeler/meshes/control_pod.stl differ diff --git a/unilabos/device_mesh/devices/peeler/meshes/feed_bridge.stl b/unilabos/device_mesh/devices/peeler/meshes/feed_bridge.stl new file mode 100644 index 000000000..5a8ab023f Binary files /dev/null and b/unilabos/device_mesh/devices/peeler/meshes/feed_bridge.stl differ diff --git a/unilabos/device_mesh/devices/peeler/meshes/tray_pedestal.stl b/unilabos/device_mesh/devices/peeler/meshes/tray_pedestal.stl new file mode 100644 index 000000000..a0a0dce6e Binary files /dev/null and b/unilabos/device_mesh/devices/peeler/meshes/tray_pedestal.stl differ diff --git a/unilabos/device_mesh/devices/peeler/meshes/tray_stage.stl b/unilabos/device_mesh/devices/peeler/meshes/tray_stage.stl new file mode 100644 index 000000000..51d74664c Binary files /dev/null and b/unilabos/device_mesh/devices/peeler/meshes/tray_stage.stl differ diff --git a/unilabos/device_mesh/devices/peeler/meta.json b/unilabos/device_mesh/devices/peeler/meta.json new file mode 100644 index 000000000..57e928310 --- /dev/null +++ b/unilabos/device_mesh/devices/peeler/meta.json @@ -0,0 +1,112 @@ +{ + "fileName": "peeler", + "related": [ + "peeler", + "plate_desealer", + "plate_seal_remover", + "azenta_xpeel", + "brooks_xpeel" + ], + "representative_form_factor": { + "vendor": "Azenta Life Sciences / Brooks Life Sciences", + "model": "Automated Plate Seal Remover (XPeel)", + "variant": "left-side output tray representation based on official product imagery", + "reason": "The Uni-Lab-OS driver is generic, and no public CAD/STL/STEP asset was located during web search. XPeel provides a close, automation-oriented plate desealer form factor with a visible front loading slot and exposed SBS tray." + }, + "model_strategy": "fallback_box_assembly_from_official_imagery_and_product_dimensions", + "sources": [ + { + "title": "Azenta Automated Plate Seal Remover product page", + "url": "https://www.azenta.com/products/automated-plate-seal-remover-formerly-xpeel", + "kind": "product_page", + "notes": "Provides throughput, motion-parameter notes, and the official user-manual link." + }, + { + "title": "Azenta Automated Plate Seal Remover User Manual", + "url": "https://web.azenta.com/hubfs/azenta-files/resources/manuals-guides/440140-Xpeel-Manual.pdf", + "kind": "user_manual", + "notes": "Used with the product page as the primary dimensional reference for the representative envelope when wiring the fallback mesh." + }, + { + "title": "Brooks Anatomy of XPeel brochure", + "url": "https://web.azenta.com/hubfs/azenta-files/resources/brochures-flyers/B2070-20-Anatomy-of-XPeel.pdf", + "kind": "brochure", + "notes": "Shows the exposed tray, front slot, and peel-head area used to position access faces." + }, + { + "title": "Azenta product image teaser_seal-remover-1", + "url": "https://www.azenta.com/wp-content/uploads/2024/08/teaser_seal-remover-1.jpg", + "kind": "image", + "notes": "Confirms the broad cabinet proportions and the left-side tray presentation used in this representative mesh." + }, + { + "title": "Azenta product image car1_seal-remover-1", + "url": "https://www.azenta.com/wp-content/uploads/2024/08/car1_seal-remover-1.jpg", + "kind": "image", + "notes": "Provides a clearer front three-quarter view of the loading tray and chamber opening." + } + ], + "dimensions_m": { + "width": 0.349, + "depth": 0.278, + "height": 0.349 + }, + "access_points": [ + { + "name": "socketTypeGenericSbsFootprint", + "description": "Representative SBS microplate handoff position on the fixed, fully extended output tray, using the left-output orientation shown in official imagery.", + "face": "front_left", + "collision_strategy": "fixed_extended_tray", + "center_m": { + "x": -0.117, + "y": 0.111, + "z": 0.082 + }, + "size_m": { + "x": 0.12776, + "y": 0.08548, + "z": 0.02 + } + }, + { + "name": "front_loading_slot_access", + "description": "Estimated center of the robot-facing front loading slot where the plate transitions into the peeling chamber.", + "face": "front", + "collision_strategy": "opening_cutout", + "center_m": { + "x": -0.102, + "y": 0.069, + "z": 0.131 + }, + "size_m": { + "x": 0.07, + "y": 0.10, + "z": 0.06 + } + }, + { + "name": "operator_panel_access", + "description": "Approximate center of the front operator panel for maintenance and manual interaction clearance.", + "face": "front", + "collision_strategy": "opening_cutout", + "center_m": { + "x": 0.015, + "y": 0.159, + "z": 0.052 + }, + "size_m": { + "x": 0.14, + "y": 0.06, + "z": 0.05 + } + } + ], + "notes": [ + "No usable public CAD, STL, OBJ, GLB, STEP, or similar 3D asset for the XPeel was found during web search, so this package uses a generated fallback STL assembly.", + "The mesh intentionally keeps the upper chamber open at the front and adds a dedicated tray pedestal plus feed bridge so the desealer reads as a front-loading instrument rather than a generic cuboid.", + "For collision-safe access planning, the tray is modeled as fixed in its fully extended pose and the main cabinet collision leaves the front-left tray corridor open.", + "Because the upstream Uni-Lab-OS driver is generic, the left-side tray and slot placement should be treated as a representative automation-friendly pose, not mechanical metrology for every possible plate desealer.", + "The operator-panel access frame is intentionally placed just outside the front face so it remains reachable without intersecting the simplified cabinet collision.", + "The 349 x 278 x 349 mm envelope is modeled as a representative overall footprint from Azenta product materials; if a site uses another desealer variant or the alternate right-output orientation, the access frames may need refinement." + ] +} diff --git a/unilabos/device_mesh/devices/plate_reader/macro_device.xacro b/unilabos/device_mesh/devices/plate_reader/macro_device.xacro new file mode 100644 index 000000000..3e6cab322 --- /dev/null +++ b/unilabos/device_mesh/devices/plate_reader/macro_device.xacro @@ -0,0 +1,172 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/unilabos/device_mesh/devices/plate_reader/meshes/body_shell.stl b/unilabos/device_mesh/devices/plate_reader/meshes/body_shell.stl new file mode 100644 index 000000000..0c9d250fb Binary files /dev/null and b/unilabos/device_mesh/devices/plate_reader/meshes/body_shell.stl differ diff --git a/unilabos/device_mesh/devices/plate_reader/meshes/drawer_face.stl b/unilabos/device_mesh/devices/plate_reader/meshes/drawer_face.stl new file mode 100644 index 000000000..b06ecfac7 Binary files /dev/null and b/unilabos/device_mesh/devices/plate_reader/meshes/drawer_face.stl differ diff --git a/unilabos/device_mesh/devices/plate_reader/meshes/optic_hood.stl b/unilabos/device_mesh/devices/plate_reader/meshes/optic_hood.stl new file mode 100644 index 000000000..540a521b5 Binary files /dev/null and b/unilabos/device_mesh/devices/plate_reader/meshes/optic_hood.stl differ diff --git a/unilabos/device_mesh/devices/plate_reader/meshes/service_panel.stl b/unilabos/device_mesh/devices/plate_reader/meshes/service_panel.stl new file mode 100644 index 000000000..633495e0f Binary files /dev/null and b/unilabos/device_mesh/devices/plate_reader/meshes/service_panel.stl differ diff --git a/unilabos/device_mesh/devices/plate_reader/meshes/tray.stl b/unilabos/device_mesh/devices/plate_reader/meshes/tray.stl new file mode 100644 index 000000000..6e86ed0f8 Binary files /dev/null and b/unilabos/device_mesh/devices/plate_reader/meshes/tray.stl differ diff --git a/unilabos/device_mesh/devices/plate_reader/meta.json b/unilabos/device_mesh/devices/plate_reader/meta.json new file mode 100644 index 000000000..396928e75 --- /dev/null +++ b/unilabos/device_mesh/devices/plate_reader/meta.json @@ -0,0 +1,71 @@ +{ + "fileName": "plate_reader", + "related": [ + "plate_reader", + "generic_plate_reader", + "_phage_display_plate_reader" + ], + "representation": { + "type": "fallback-composite-stl", + "representativeFormFactor": "Compact front-drawer multimode microplate reader based on the Agilent BioTek Synergy H1 envelope and public front-panel imagery.", + "dimensionsMm": { + "width": 375, + "depth": 464, + "height": 330 + }, + "sourceUrls": [ + "https://www.agilent.com/cs/library/packageinsert/public/SynergyH1_IFU_8041005IAW.E.pdf", + "https://www.biospx.com/wp-content/uploads/2021/10/biotek-synergy-h1-brochure-en-web.pdf", + "https://cheshirelabs.io/3d-models/" + ], + "notes": [ + "No public reusable vendor CAD, STEP, or STL for a generic plate reader surfaced during web search.", + "Representative form factor chosen: a Synergy H1-class bench-top reader with a single front SBS microplate drawer and an upper front service/access panel.", + "The composite fallback mesh uses simple STL boxes sized from official dimensions, with hood, drawer, and service-panel placement estimated from public product imagery and IFU front-panel references." + ] + }, + "accessFrames": [ + { + "name": "drawer-loading-zone", + "linkSuffix": "drawer_access_link", + "description": "Estimated center of the extended front tray used to load or retrieve an SBS microplate.", + "collision_strategy": "fixed_extended_tray", + "poseMeters": { + "x": 0.03, + "y": 0.282, + "z": 0.07, + "roll": 0.0, + "pitch": 0.0, + "yaw": 0.0 + } + }, + { + "name": "front-drawer-face", + "linkSuffix": "front_panel_access_link", + "description": "Estimated operator approach point centered on the plate drawer opening in the front fascia.", + "collision_strategy": "opening_cutout", + "poseMeters": { + "x": 0.03, + "y": 0.242, + "z": 0.08, + "roll": 0.0, + "pitch": 0.0, + "yaw": 0.0 + } + }, + { + "name": "upper-service-panel", + "linkSuffix": "service_access_link", + "description": "Estimated front access point for the upper optics or service panel above the drawer.", + "collision_strategy": "opening_cutout", + "poseMeters": { + "x": 0.055, + "y": 0.242, + "z": 0.195, + "roll": 0.0, + "pitch": 0.0, + "yaw": 0.0 + } + } + ] +} diff --git a/unilabos/device_mesh/devices/qiagen_qiacube_connect/macro_device.xacro b/unilabos/device_mesh/devices/qiagen_qiacube_connect/macro_device.xacro new file mode 100644 index 000000000..1d3446d8d --- /dev/null +++ b/unilabos/device_mesh/devices/qiagen_qiacube_connect/macro_device.xacro @@ -0,0 +1,191 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/unilabos/device_mesh/devices/qiagen_qiacube_connect/meshes/hood_shell.stl b/unilabos/device_mesh/devices/qiagen_qiacube_connect/meshes/hood_shell.stl new file mode 100644 index 000000000..ec70614ac Binary files /dev/null and b/unilabos/device_mesh/devices/qiagen_qiacube_connect/meshes/hood_shell.stl differ diff --git a/unilabos/device_mesh/devices/qiagen_qiacube_connect/meshes/lower_cabinet.stl b/unilabos/device_mesh/devices/qiagen_qiacube_connect/meshes/lower_cabinet.stl new file mode 100644 index 000000000..f73fdf103 Binary files /dev/null and b/unilabos/device_mesh/devices/qiagen_qiacube_connect/meshes/lower_cabinet.stl differ diff --git a/unilabos/device_mesh/devices/qiagen_qiacube_connect/meshes/rear_housing.stl b/unilabos/device_mesh/devices/qiagen_qiacube_connect/meshes/rear_housing.stl new file mode 100644 index 000000000..3f09b02b9 Binary files /dev/null and b/unilabos/device_mesh/devices/qiagen_qiacube_connect/meshes/rear_housing.stl differ diff --git a/unilabos/device_mesh/devices/qiagen_qiacube_connect/meshes/touchscreen_arm.stl b/unilabos/device_mesh/devices/qiagen_qiacube_connect/meshes/touchscreen_arm.stl new file mode 100644 index 000000000..c9446aa05 Binary files /dev/null and b/unilabos/device_mesh/devices/qiagen_qiacube_connect/meshes/touchscreen_arm.stl differ diff --git a/unilabos/device_mesh/devices/qiagen_qiacube_connect/meshes/touchscreen_panel.stl b/unilabos/device_mesh/devices/qiagen_qiacube_connect/meshes/touchscreen_panel.stl new file mode 100644 index 000000000..4358b8285 Binary files /dev/null and b/unilabos/device_mesh/devices/qiagen_qiacube_connect/meshes/touchscreen_panel.stl differ diff --git a/unilabos/device_mesh/devices/qiagen_qiacube_connect/meshes/waste_drawer.stl b/unilabos/device_mesh/devices/qiagen_qiacube_connect/meshes/waste_drawer.stl new file mode 100644 index 000000000..e32e93f02 Binary files /dev/null and b/unilabos/device_mesh/devices/qiagen_qiacube_connect/meshes/waste_drawer.stl differ diff --git a/unilabos/device_mesh/devices/qiagen_qiacube_connect/meta.json b/unilabos/device_mesh/devices/qiagen_qiacube_connect/meta.json new file mode 100644 index 000000000..b562a6320 --- /dev/null +++ b/unilabos/device_mesh/devices/qiagen_qiacube_connect/meta.json @@ -0,0 +1,52 @@ +{ + "fileName": "qiagen_qiacube_connect", + "related": [ + "qiagen_qiacube_connect", + "qiacube_connect", + "qiacube connect", + "qiagen qiacube connect", + "QIAGEN QIAcube Connect" + ], + "access_points": [ + { + "name": "hood_handle_access_link", + "face": "front_upper", + "collision_strategy": "opening_cutout", + "xyz_m": [0.0, 0.365, 0.365], + "rpy_rad": [0.0, 0.0, 0.0], + "description": "Front handle approach used to open the transparent hood." + }, + { + "name": "loading_access_link", + "face": "top_front_internal", + "collision_strategy": "exposed_work_surface", + "xyz_m": [0.0, 0.09, 0.692], + "rpy_rad": [0.0, 0.0, 0.0], + "description": "Estimated center of the worktable loading zone once the hood is opened." + }, + { + "name": "touchscreen_access_link", + "face": "front_left_upper", + "collision_strategy": "opening_cutout", + "xyz_m": [-0.205, 0.385, 0.345], + "rpy_rad": [0.0, 0.0, 0.0], + "description": "Left-front touchscreen and nearby USB interaction area." + }, + { + "name": "waste_drawer_access_link", + "face": "front_lower", + "collision_strategy": "fixed_extended_tray", + "xyz_m": [0.0, 0.325, 0.08], + "rpy_rad": [0.0, 0.0, 0.0], + "description": "Access point centered on the fixed, extended waste drawer used for waste handling." + }, + { + "name": "rear_service_access_link", + "face": "rear", + "collision_strategy": "opening_cutout", + "xyz_m": [0.0, -0.365, 0.32], + "rpy_rad": [0.0, 0.0, 3.141592653589793], + "description": "Rear service side for power, network, and ventilation access." + } + ] +} diff --git a/unilabos/device_mesh/devices/sealer/macro_device.xacro b/unilabos/device_mesh/devices/sealer/macro_device.xacro new file mode 100644 index 000000000..5401818c3 --- /dev/null +++ b/unilabos/device_mesh/devices/sealer/macro_device.xacro @@ -0,0 +1,229 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/unilabos/device_mesh/devices/sealer/meshes/sealer_front_console.stl b/unilabos/device_mesh/devices/sealer/meshes/sealer_front_console.stl new file mode 100644 index 000000000..e1cfff0b8 Binary files /dev/null and b/unilabos/device_mesh/devices/sealer/meshes/sealer_front_console.stl differ diff --git a/unilabos/device_mesh/devices/sealer/meshes/sealer_handle_bar.stl b/unilabos/device_mesh/devices/sealer/meshes/sealer_handle_bar.stl new file mode 100644 index 000000000..4d6001070 Binary files /dev/null and b/unilabos/device_mesh/devices/sealer/meshes/sealer_handle_bar.stl differ diff --git a/unilabos/device_mesh/devices/sealer/meshes/sealer_lower_body.stl b/unilabos/device_mesh/devices/sealer/meshes/sealer_lower_body.stl new file mode 100644 index 000000000..3783ab7ab Binary files /dev/null and b/unilabos/device_mesh/devices/sealer/meshes/sealer_lower_body.stl differ diff --git a/unilabos/device_mesh/devices/sealer/meshes/sealer_lower_fascia.stl b/unilabos/device_mesh/devices/sealer/meshes/sealer_lower_fascia.stl new file mode 100644 index 000000000..c71d43f2e Binary files /dev/null and b/unilabos/device_mesh/devices/sealer/meshes/sealer_lower_fascia.stl differ diff --git a/unilabos/device_mesh/devices/sealer/meshes/sealer_top_roll_cover.stl b/unilabos/device_mesh/devices/sealer/meshes/sealer_top_roll_cover.stl new file mode 100644 index 000000000..46ec54151 Binary files /dev/null and b/unilabos/device_mesh/devices/sealer/meshes/sealer_top_roll_cover.stl differ diff --git a/unilabos/device_mesh/devices/sealer/meshes/sealer_touch_panel.stl b/unilabos/device_mesh/devices/sealer/meshes/sealer_touch_panel.stl new file mode 100644 index 000000000..f68d0cb31 Binary files /dev/null and b/unilabos/device_mesh/devices/sealer/meshes/sealer_touch_panel.stl differ diff --git a/unilabos/device_mesh/devices/sealer/meshes/sealer_tray_face.stl b/unilabos/device_mesh/devices/sealer/meshes/sealer_tray_face.stl new file mode 100644 index 000000000..6c34cc735 Binary files /dev/null and b/unilabos/device_mesh/devices/sealer/meshes/sealer_tray_face.stl differ diff --git a/unilabos/device_mesh/devices/sealer/meshes/sealer_tray_platform.stl b/unilabos/device_mesh/devices/sealer/meshes/sealer_tray_platform.stl new file mode 100644 index 000000000..ae622785c Binary files /dev/null and b/unilabos/device_mesh/devices/sealer/meshes/sealer_tray_platform.stl differ diff --git a/unilabos/device_mesh/devices/sealer/meshes/sealer_tray_rail.stl b/unilabos/device_mesh/devices/sealer/meshes/sealer_tray_rail.stl new file mode 100644 index 000000000..e9978bbb7 Binary files /dev/null and b/unilabos/device_mesh/devices/sealer/meshes/sealer_tray_rail.stl differ diff --git a/unilabos/device_mesh/devices/sealer/meshes/sealer_upper_body.stl b/unilabos/device_mesh/devices/sealer/meshes/sealer_upper_body.stl new file mode 100644 index 000000000..35e92ac9f Binary files /dev/null and b/unilabos/device_mesh/devices/sealer/meshes/sealer_upper_body.stl differ diff --git a/unilabos/device_mesh/devices/sealer/meta.json b/unilabos/device_mesh/devices/sealer/meta.json new file mode 100644 index 000000000..15cf4afb6 --- /dev/null +++ b/unilabos/device_mesh/devices/sealer/meta.json @@ -0,0 +1,93 @@ +{ + "fileName": "sealer", + "related": [ + "sealer", + "plate_sealer", + "generic_plate_sealer", + "automated_roll_heat_sealer", + "4ti_0665" + ], + "model_strategy": "fallback_composite_stl_from_official_dimensions_and_product_imagery", + "representative_form_factor": { + "vendor": "Azenta / 4titude", + "model": "Automated Roll Heat Sealer", + "aliases": [ + "4ti-0665", + "a4S" + ], + "reason": "A directly usable public CAD/STL/STEP model was not located during web search, so this mesh package uses the Azenta automated roll heat sealer as the representative front-loading plate sealer form factor." + }, + "sources": [ + { + "title": "Azenta Automated Roll Heat Sealer product page", + "url": "https://www.azenta.com/products/automated-roll-heat-sealer-formerly-a4s", + "kind": "official_product_page", + "notes": "Used for exterior styling cues including the compact benchtop housing, sloped front operator panel, and front-loading tray presentation." + }, + { + "title": "Automated Roll Heat Sealer Technical Drawing", + "url": "https://web.azenta.com/hubfs/azenta-files/resources/tech-drawings/TD-automated-roll-heat-sealer.pdf", + "kind": "official_technical_drawing", + "notes": "Provides the representative envelope dimensions used here: 230 mm width, 507 mm closed depth, 276 mm height, and 665 mm depth when the front tray is open." + }, + { + "title": "Microplate Sealer User Guide", + "url": "https://web.azenta.com/hubfs/azenta-files/resources/manuals-guides/347598-Microplate-Sealer.pdf", + "kind": "official_user_manual", + "notes": "Used to confirm the machine is operated from the front drawer side and that the film roll is serviced from the upper housing." + } + ], + "dimensions_m": { + "width": 0.23, + "depth": 0.507, + "height": 0.276, + "open_depth": 0.665 + }, + "access_points": [ + { + "name": "front_loading_access_link", + "face": "front", + "collision_strategy": "fixed_extended_tray", + "xyz_m": [0.0, 0.3475, 0.103], + "rpy_rad": [0.0, 0.0, 0.0], + "description": "Approximate approach point for loading or unloading an SBS-format microplate on the extended front tray." + }, + { + "name": "sealing_slot_access_link", + "face": "front_internal", + "collision_strategy": "fixed_extended_tray", + "xyz_m": [0.0, 0.188, 0.104], + "rpy_rad": [0.0, 0.0, 0.0], + "description": "Estimated center of the sealing slot / heated platen region after the tray enters the instrument." + }, + { + "name": "touchscreen_access_link", + "face": "front_upper", + "collision_strategy": "opening_cutout", + "xyz_m": [0.0, 0.2815, 0.194], + "rpy_rad": [-0.349066, 0.0, 0.0], + "description": "Approximate front operator control location on the sloped touchscreen console." + }, + { + "name": "top_roll_access_link", + "face": "top", + "collision_strategy": "opening_cutout", + "xyz_m": [0.0, -0.094, 0.276], + "rpy_rad": [0.0, 0.0, 0.0], + "description": "Estimated top-side service position for loading or replacing the sealing film roll." + }, + { + "name": "rear_service_access_link", + "face": "rear", + "collision_strategy": "opening_cutout", + "xyz_m": [0.0, -0.3035, 0.16], + "rpy_rad": [0.0, 0.0, 3.141592653589793], + "description": "Approximate rear service point for power and cable access." + } + ], + "notes": [ + "No public manufacturer or distributor CAD/STL/STEP file for this exact sealer was found during web search, so this package intentionally uses a documented representative fallback instead of claiming exact factory geometry.", + "Positive Y is treated as the operator-facing front because the Azenta product page and user manual both show the loading drawer and touchscreen on that side.", + "The tray extension, sealing slot center, and top roll access location are estimated from the technical drawing and exterior imagery, so precise internal platen coordinates remain uncertain." + ] +} diff --git a/unilabos/device_mesh/devices/star_backend/macro_device.xacro b/unilabos/device_mesh/devices/star_backend/macro_device.xacro new file mode 100644 index 000000000..c718cea9e --- /dev/null +++ b/unilabos/device_mesh/devices/star_backend/macro_device.xacro @@ -0,0 +1,217 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/unilabos/device_mesh/devices/star_backend/meshes/base_plinth.stl b/unilabos/device_mesh/devices/star_backend/meshes/base_plinth.stl new file mode 100644 index 000000000..d50166cae Binary files /dev/null and b/unilabos/device_mesh/devices/star_backend/meshes/base_plinth.stl differ diff --git a/unilabos/device_mesh/devices/star_backend/meshes/deck_surface.stl b/unilabos/device_mesh/devices/star_backend/meshes/deck_surface.stl new file mode 100644 index 000000000..521c33109 Binary files /dev/null and b/unilabos/device_mesh/devices/star_backend/meshes/deck_surface.stl differ diff --git a/unilabos/device_mesh/devices/star_backend/meshes/front_crossbar.stl b/unilabos/device_mesh/devices/star_backend/meshes/front_crossbar.stl new file mode 100644 index 000000000..16004aa4d Binary files /dev/null and b/unilabos/device_mesh/devices/star_backend/meshes/front_crossbar.stl differ diff --git a/unilabos/device_mesh/devices/star_backend/meshes/front_loading_tray.stl b/unilabos/device_mesh/devices/star_backend/meshes/front_loading_tray.stl new file mode 100644 index 000000000..131e23a94 Binary files /dev/null and b/unilabos/device_mesh/devices/star_backend/meshes/front_loading_tray.stl differ diff --git a/unilabos/device_mesh/devices/star_backend/meshes/pipetting_carriage.stl b/unilabos/device_mesh/devices/star_backend/meshes/pipetting_carriage.stl new file mode 100644 index 000000000..026120152 Binary files /dev/null and b/unilabos/device_mesh/devices/star_backend/meshes/pipetting_carriage.stl differ diff --git a/unilabos/device_mesh/devices/star_backend/meshes/rear_service_tower.stl b/unilabos/device_mesh/devices/star_backend/meshes/rear_service_tower.stl new file mode 100644 index 000000000..c623d5ac2 Binary files /dev/null and b/unilabos/device_mesh/devices/star_backend/meshes/rear_service_tower.stl differ diff --git a/unilabos/device_mesh/devices/star_backend/meshes/side_pillar.stl b/unilabos/device_mesh/devices/star_backend/meshes/side_pillar.stl new file mode 100644 index 000000000..ab2646a2d Binary files /dev/null and b/unilabos/device_mesh/devices/star_backend/meshes/side_pillar.stl differ diff --git a/unilabos/device_mesh/devices/star_backend/meshes/status_light.stl b/unilabos/device_mesh/devices/star_backend/meshes/status_light.stl new file mode 100644 index 000000000..95f6c8f4c Binary files /dev/null and b/unilabos/device_mesh/devices/star_backend/meshes/status_light.stl differ diff --git a/unilabos/device_mesh/devices/star_backend/meshes/top_fascia.stl b/unilabos/device_mesh/devices/star_backend/meshes/top_fascia.stl new file mode 100644 index 000000000..aeb3b8caa Binary files /dev/null and b/unilabos/device_mesh/devices/star_backend/meshes/top_fascia.stl differ diff --git a/unilabos/device_mesh/devices/star_backend/meta.json b/unilabos/device_mesh/devices/star_backend/meta.json new file mode 100644 index 000000000..2e92a6d52 --- /dev/null +++ b/unilabos/device_mesh/devices/star_backend/meta.json @@ -0,0 +1,42 @@ +{ + "fileName": "star_backend", + "related": [ + "star_backend", + "hamilton_star", + "microlab_star" + ], + "access_points": [ + { + "name": "loading_tray_access_link", + "face": "front", + "xyz_m": [0.0, -0.47, 0.133], + "rpy_rad": [0.0, 0.0, 0.0], + "collision_strategy": "fixed_extended_tray", + "description": "Tray-backed handoff point above the fixed extended loading tray, kept outside the body shell for robot approach." + }, + { + "name": "left_deck_access_link", + "face": "left_top_deck", + "xyz_m": [-0.892, -0.055, 0.133], + "rpy_rad": [0.0, 0.0, 0.0], + "collision_strategy": "exposed_work_surface", + "description": "Left-side approach to the exposed main deck work surface without embedding the access frame in body collision." + }, + { + "name": "right_deck_access_link", + "face": "right_top_deck", + "xyz_m": [0.892, -0.055, 0.133], + "rpy_rad": [0.0, 0.0, 0.0], + "collision_strategy": "exposed_work_surface", + "description": "Right-side approach to the exposed main deck work surface without embedding the access frame in body collision." + }, + { + "name": "rear_deck_access_link", + "face": "rear_top_deck", + "xyz_m": [0.0, 0.506, 0.133], + "rpy_rad": [0.0, 0.0, 0.0], + "collision_strategy": "exposed_work_surface", + "description": "Rear-side approach to the exposed main deck work surface for integrations that access the deck from behind." + } + ] +} diff --git a/unilabos/device_mesh/devices/tecan_resolvex_a200/macro_device.xacro b/unilabos/device_mesh/devices/tecan_resolvex_a200/macro_device.xacro new file mode 100644 index 000000000..f441af1f6 --- /dev/null +++ b/unilabos/device_mesh/devices/tecan_resolvex_a200/macro_device.xacro @@ -0,0 +1,256 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/unilabos/device_mesh/devices/tecan_resolvex_a200/meshes/back_panel.stl b/unilabos/device_mesh/devices/tecan_resolvex_a200/meshes/back_panel.stl new file mode 100644 index 000000000..5b6fae846 Binary files /dev/null and b/unilabos/device_mesh/devices/tecan_resolvex_a200/meshes/back_panel.stl differ diff --git a/unilabos/device_mesh/devices/tecan_resolvex_a200/meshes/base_plinth.stl b/unilabos/device_mesh/devices/tecan_resolvex_a200/meshes/base_plinth.stl new file mode 100644 index 000000000..80ecad844 Binary files /dev/null and b/unilabos/device_mesh/devices/tecan_resolvex_a200/meshes/base_plinth.stl differ diff --git a/unilabos/device_mesh/devices/tecan_resolvex_a200/meshes/dispense_head.stl b/unilabos/device_mesh/devices/tecan_resolvex_a200/meshes/dispense_head.stl new file mode 100644 index 000000000..46104450b Binary files /dev/null and b/unilabos/device_mesh/devices/tecan_resolvex_a200/meshes/dispense_head.stl differ diff --git a/unilabos/device_mesh/devices/tecan_resolvex_a200/meshes/front_lintel.stl b/unilabos/device_mesh/devices/tecan_resolvex_a200/meshes/front_lintel.stl new file mode 100644 index 000000000..cf80b8838 Binary files /dev/null and b/unilabos/device_mesh/devices/tecan_resolvex_a200/meshes/front_lintel.stl differ diff --git a/unilabos/device_mesh/devices/tecan_resolvex_a200/meshes/light_curtain_post.stl b/unilabos/device_mesh/devices/tecan_resolvex_a200/meshes/light_curtain_post.stl new file mode 100644 index 000000000..8762329bd Binary files /dev/null and b/unilabos/device_mesh/devices/tecan_resolvex_a200/meshes/light_curtain_post.stl differ diff --git a/unilabos/device_mesh/devices/tecan_resolvex_a200/meshes/lower_body.stl b/unilabos/device_mesh/devices/tecan_resolvex_a200/meshes/lower_body.stl new file mode 100644 index 000000000..ce5bb885d Binary files /dev/null and b/unilabos/device_mesh/devices/tecan_resolvex_a200/meshes/lower_body.stl differ diff --git a/unilabos/device_mesh/devices/tecan_resolvex_a200/meshes/manifold_block.stl b/unilabos/device_mesh/devices/tecan_resolvex_a200/meshes/manifold_block.stl new file mode 100644 index 000000000..491ef1740 Binary files /dev/null and b/unilabos/device_mesh/devices/tecan_resolvex_a200/meshes/manifold_block.stl differ diff --git a/unilabos/device_mesh/devices/tecan_resolvex_a200/meshes/rack_stage.stl b/unilabos/device_mesh/devices/tecan_resolvex_a200/meshes/rack_stage.stl new file mode 100644 index 000000000..28b93e315 Binary files /dev/null and b/unilabos/device_mesh/devices/tecan_resolvex_a200/meshes/rack_stage.stl differ diff --git a/unilabos/device_mesh/devices/tecan_resolvex_a200/meshes/roof_canopy.stl b/unilabos/device_mesh/devices/tecan_resolvex_a200/meshes/roof_canopy.stl new file mode 100644 index 000000000..9f2e9a5a8 Binary files /dev/null and b/unilabos/device_mesh/devices/tecan_resolvex_a200/meshes/roof_canopy.stl differ diff --git a/unilabos/device_mesh/devices/tecan_resolvex_a200/meshes/side_frame.stl b/unilabos/device_mesh/devices/tecan_resolvex_a200/meshes/side_frame.stl new file mode 100644 index 000000000..0c24366a2 Binary files /dev/null and b/unilabos/device_mesh/devices/tecan_resolvex_a200/meshes/side_frame.stl differ diff --git a/unilabos/device_mesh/devices/tecan_resolvex_a200/meshes/side_window_panel.stl b/unilabos/device_mesh/devices/tecan_resolvex_a200/meshes/side_window_panel.stl new file mode 100644 index 000000000..a37bbf5d7 Binary files /dev/null and b/unilabos/device_mesh/devices/tecan_resolvex_a200/meshes/side_window_panel.stl differ diff --git a/unilabos/device_mesh/devices/tecan_resolvex_a200/meshes/touchscreen_bezel.stl b/unilabos/device_mesh/devices/tecan_resolvex_a200/meshes/touchscreen_bezel.stl new file mode 100644 index 000000000..87189416c Binary files /dev/null and b/unilabos/device_mesh/devices/tecan_resolvex_a200/meshes/touchscreen_bezel.stl differ diff --git a/unilabos/device_mesh/devices/tecan_resolvex_a200/meshes/touchscreen_glass.stl b/unilabos/device_mesh/devices/tecan_resolvex_a200/meshes/touchscreen_glass.stl new file mode 100644 index 000000000..052e676a9 Binary files /dev/null and b/unilabos/device_mesh/devices/tecan_resolvex_a200/meshes/touchscreen_glass.stl differ diff --git a/unilabos/device_mesh/devices/tecan_resolvex_a200/meta.json b/unilabos/device_mesh/devices/tecan_resolvex_a200/meta.json new file mode 100644 index 000000000..5cba353c7 --- /dev/null +++ b/unilabos/device_mesh/devices/tecan_resolvex_a200/meta.json @@ -0,0 +1,104 @@ +{ + "fileName": "tecan_resolvex_a200", + "related": [ + "tecan_resolvex_a200", + "resolvex_a200", + "guessed_tecan_resolvex_a200" + ], + "model_strategy": "fallback_box", + "sources": [ + { + "title": "Tecan Resolvex A200 product page", + "url": "https://www.tecan.com/resolvex-a200-liquid-chromatography-mass-spectrometry", + "kind": "product_page", + "notes": "Confirms the exact standalone instrument identity and its use with 96-well plates and SPE columns." + }, + { + "title": "Tecan Resolvex A200 brochure", + "url": "https://www.tecan.com/hubfs/HubDB/Te-DocDB/pdf/BR_Resolvex-A200_401236.pdf", + "kind": "brochure", + "notes": "Provides the clearest official front and angled imagery for estimating the open-front chamber, touchscreen cabinet, and light-curtain placement." + }, + { + "title": "Tecan Resolvex A200 operating manual", + "url": "https://www.tecan.com/hubfs/Knowledgebase/Manuals/Resolvex%20A200/253-5286-REV-L%20DOC%20RESOLVEX%20A200%20OP%20MANUAL%20ENGLISH%201.pdf", + "kind": "operating_manual", + "notes": "Provides official overall dimensions of 378 mm W x 542 mm D x 600 mm H, weight, major-component descriptions, and the rear service-panel diagram." + } + ], + "dimensions_m": { + "width": 0.378, + "depth": 0.542, + "height": 0.6, + "weight_kg": 38.3 + }, + "mesh_assets": { + "visual_stl_parts": [ + "meshes/base_plinth.stl", + "meshes/lower_body.stl", + "meshes/roof_canopy.stl", + "meshes/side_frame.stl", + "meshes/back_panel.stl", + "meshes/side_window_panel.stl", + "meshes/front_lintel.stl", + "meshes/touchscreen_bezel.stl", + "meshes/touchscreen_glass.stl", + "meshes/manifold_block.stl", + "meshes/dispense_head.stl", + "meshes/rack_stage.stl", + "meshes/light_curtain_post.stl" + ] + }, + "access_points": [ + { + "name": "front_loading_access_link", + "description": "Approximate center of the open front loading face used to place or remove racks, plates, and columns beneath the pressure manifold.", + "face": "front", + "collision_strategy": "opening_cutout", + "center_m": [0.0, -0.356, 0.355], + "size_m": [0.286, 0.001, 0.206], + "rpy_rad": [0.0, 0.0, 0.0] + }, + { + "name": "rack_access_link", + "description": "Approximate center of the internal rack / plate work position visible in official front-view images.", + "face": "internal_front", + "collision_strategy": "exposed_work_surface", + "center_m": [0.0, -0.015, 0.292], + "size_m": [0.188, 0.136, 0.001], + "rpy_rad": [0.0, 0.0, 0.0] + }, + { + "name": "socketTypeGenericSbsFootprint", + "description": "Representative SBS footprint reference centered on the exposed rack work surface for plate pickup and placement.", + "face": "internal_front", + "collision_strategy": "exposed_work_surface", + "center_m": [0.0, -0.015, 0.296], + "size_m": [0.12776, 0.08548, 0.001], + "rpy_rad": [0.0, 0.0, 0.0] + }, + { + "name": "screen_access_link", + "description": "Approximate center of the front touchscreen interaction area.", + "face": "front", + "collision_strategy": "opening_cutout", + "center_m": [-0.038, -0.301, 0.167], + "size_m": [0.125, 0.001, 0.22], + "rpy_rad": [0.0, 0.0, 0.0] + }, + { + "name": "rear_service_access_link", + "description": "Approximate center of the rear utility region that includes gas inlet, USB, Ethernet, and 24 VDC power input per the manual rear-view diagram.", + "face": "rear_right", + "collision_strategy": "opening_cutout", + "center_m": [0.105, 0.251, 0.455], + "size_m": [0.12, 0.001, 0.19], + "rpy_rad": [0.0, 0.0, 3.141592653589793] + } + ], + "notes": [ + "No public vendor STL, GLB, STEP, or other directly reusable 3D model for the Tecan Resolvex A200 was located during web search.", + "The visual mesh is therefore a segmented fallback built from official envelope dimensions and official product imagery so the instrument remains recognizable as an open-front positive-pressure workstation.", + "Front-loading, rack, touchscreen, and rear-service positions are integration-oriented estimates and should not be treated as factory metrology." + ] +} diff --git a/unilabos/device_mesh/devices/telesis_bio_bioxp_3250/macro_device.xacro b/unilabos/device_mesh/devices/telesis_bio_bioxp_3250/macro_device.xacro new file mode 100644 index 000000000..13026c160 --- /dev/null +++ b/unilabos/device_mesh/devices/telesis_bio_bioxp_3250/macro_device.xacro @@ -0,0 +1,101 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/unilabos/device_mesh/devices/telesis_bio_bioxp_3250/meshes/body_shell.stl b/unilabos/device_mesh/devices/telesis_bio_bioxp_3250/meshes/body_shell.stl new file mode 100644 index 000000000..804bd2c9a Binary files /dev/null and b/unilabos/device_mesh/devices/telesis_bio_bioxp_3250/meshes/body_shell.stl differ diff --git a/unilabos/device_mesh/devices/telesis_bio_bioxp_3250/meshes/lower_service_panel.stl b/unilabos/device_mesh/devices/telesis_bio_bioxp_3250/meshes/lower_service_panel.stl new file mode 100644 index 000000000..e4af0e896 Binary files /dev/null and b/unilabos/device_mesh/devices/telesis_bio_bioxp_3250/meshes/lower_service_panel.stl differ diff --git a/unilabos/device_mesh/devices/telesis_bio_bioxp_3250/meshes/touchscreen_bezel.stl b/unilabos/device_mesh/devices/telesis_bio_bioxp_3250/meshes/touchscreen_bezel.stl new file mode 100644 index 000000000..dd3b8e7a4 Binary files /dev/null and b/unilabos/device_mesh/devices/telesis_bio_bioxp_3250/meshes/touchscreen_bezel.stl differ diff --git a/unilabos/device_mesh/devices/telesis_bio_bioxp_3250/meshes/touchscreen_glass.stl b/unilabos/device_mesh/devices/telesis_bio_bioxp_3250/meshes/touchscreen_glass.stl new file mode 100644 index 000000000..93c413dac Binary files /dev/null and b/unilabos/device_mesh/devices/telesis_bio_bioxp_3250/meshes/touchscreen_glass.stl differ diff --git a/unilabos/device_mesh/devices/telesis_bio_bioxp_3250/meshes/upper_access_door.stl b/unilabos/device_mesh/devices/telesis_bio_bioxp_3250/meshes/upper_access_door.stl new file mode 100644 index 000000000..ca9f1ffa7 Binary files /dev/null and b/unilabos/device_mesh/devices/telesis_bio_bioxp_3250/meshes/upper_access_door.stl differ diff --git a/unilabos/device_mesh/devices/telesis_bio_bioxp_3250/meta.json b/unilabos/device_mesh/devices/telesis_bio_bioxp_3250/meta.json new file mode 100644 index 000000000..9a0711f71 --- /dev/null +++ b/unilabos/device_mesh/devices/telesis_bio_bioxp_3250/meta.json @@ -0,0 +1,70 @@ +{ + "fileName": "telesis_bio_bioxp_3250", + "related": [ + "telesis_bio_bioxp_3250", + "bioxp_3250", + "codex_dna_bioxp_3250" + ], + "model_strategy": "fallback_composite_stl_from_official_dimensions_and_user_guide_imagery", + "sources": [ + { + "title": "BioXp 3250 system product page", + "url": "https://telesisbio.com/products/bioxp-system/bioxp-3250-system/", + "kind": "product_page", + "notes": "Confirms the current product identity and publishes the external footprint of 69 cm x 77 cm x 53 cm." + }, + { + "title": "BioXp 3250 system product flyer", + "url": "https://files.telesisbio.com/docs/45050_BioXp_3250_prod_flyer.pdf", + "kind": "product_flyer", + "notes": "Official marketing collateral used to corroborate the benchtop footprint and front-facing industrial design." + }, + { + "title": "BioXp 3250 system user guide", + "url": "https://files.telesisbio.com/docs/43029_v2.2_BioXp%C2%AE%203250%20system%20%E2%80%94%20User%20guide%20Effective%2029NOV2022.pdf", + "kind": "user_guide", + "notes": "Provides the front-view hardware overview, deck image, and wording around door-based job-material loading." + }, + { + "title": "Meet the BioXp 3250 system", + "url": "https://telesisbio.com/2020/08/26/meet-the-bioxp-3250-system-our-new-automated-synthetic-biology-workstation", + "kind": "announcement", + "notes": "Official launch post used as a secondary visual cross-check for the recognizable front silhouette and operator-facing screen." + } + ], + "dimensions_m": { + "width": 0.690, + "depth": 0.770, + "height": 0.530 + }, + "mesh_assets": { + "body_shell": "meshes/body_shell.stl", + "upper_access_door": "meshes/upper_access_door.stl", + "lower_service_panel": "meshes/lower_service_panel.stl", + "touchscreen_bezel": "meshes/touchscreen_bezel.stl", + "touchscreen_glass": "meshes/touchscreen_glass.stl" + }, + "access_points": [ + { + "name": "front_loading_access_link", + "face": "front", + "xyz_m": [0.0, 0.47, 0.255], + "rpy_rad": [0.0, 0.0, 0.0], + "collision_strategy": "opening_cutout", + "description": "Estimated primary consumable-loading access point centered on the front door opening shown in the user-guide hardware overview." + }, + { + "name": "touchscreen_access_link", + "face": "front-right", + "xyz_m": [0.19, 0.445, 0.115], + "rpy_rad": [0.0, 0.0, 0.0], + "collision_strategy": "opening_cutout", + "description": "Estimated operator touchscreen center on the smaller front-right control bay." + } + ], + "notes": [ + "No public vendor CAD, STL, STEP, GLB, or USDZ model for the Telesis BIO BIOXp 3250 was located during web search, so this package intentionally uses a fallback mesh rather than implying exact factory geometry.", + "The fallback mesh preserves the official overall envelope and the visible separation between the large upper loading door, the lower front panel, and the right-front touchscreen module.", + "Access frame locations are integration-oriented estimates from official imagery and user-guide wording; they should be treated as approximate until a service drawing or direct measurement is available." + ] +} diff --git a/unilabos/device_mesh/devices/v_spin_backend/macro_device.xacro b/unilabos/device_mesh/devices/v_spin_backend/macro_device.xacro new file mode 100644 index 000000000..a7d206314 --- /dev/null +++ b/unilabos/device_mesh/devices/v_spin_backend/macro_device.xacro @@ -0,0 +1,198 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/unilabos/device_mesh/devices/v_spin_backend/meshes/body_core.stl b/unilabos/device_mesh/devices/v_spin_backend/meshes/body_core.stl new file mode 100644 index 000000000..c6145fb0e Binary files /dev/null and b/unilabos/device_mesh/devices/v_spin_backend/meshes/body_core.stl differ diff --git a/unilabos/device_mesh/devices/v_spin_backend/meshes/door_panel.stl b/unilabos/device_mesh/devices/v_spin_backend/meshes/door_panel.stl new file mode 100644 index 000000000..65b487fd8 Binary files /dev/null and b/unilabos/device_mesh/devices/v_spin_backend/meshes/door_panel.stl differ diff --git a/unilabos/device_mesh/devices/v_spin_backend/meshes/gripper_housing.stl b/unilabos/device_mesh/devices/v_spin_backend/meshes/gripper_housing.stl new file mode 100644 index 000000000..c4400a80d Binary files /dev/null and b/unilabos/device_mesh/devices/v_spin_backend/meshes/gripper_housing.stl differ diff --git a/unilabos/device_mesh/devices/v_spin_backend/meshes/loader_base.stl b/unilabos/device_mesh/devices/v_spin_backend/meshes/loader_base.stl new file mode 100644 index 000000000..248a6f933 Binary files /dev/null and b/unilabos/device_mesh/devices/v_spin_backend/meshes/loader_base.stl differ diff --git a/unilabos/device_mesh/devices/v_spin_backend/meshes/loader_bridge.stl b/unilabos/device_mesh/devices/v_spin_backend/meshes/loader_bridge.stl new file mode 100644 index 000000000..fd808a890 Binary files /dev/null and b/unilabos/device_mesh/devices/v_spin_backend/meshes/loader_bridge.stl differ diff --git a/unilabos/device_mesh/devices/v_spin_backend/meshes/loader_stage.stl b/unilabos/device_mesh/devices/v_spin_backend/meshes/loader_stage.stl new file mode 100644 index 000000000..292b2c3d4 Binary files /dev/null and b/unilabos/device_mesh/devices/v_spin_backend/meshes/loader_stage.stl differ diff --git a/unilabos/device_mesh/devices/v_spin_backend/meshes/plinth.stl b/unilabos/device_mesh/devices/v_spin_backend/meshes/plinth.stl new file mode 100644 index 000000000..7513842cc Binary files /dev/null and b/unilabos/device_mesh/devices/v_spin_backend/meshes/plinth.stl differ diff --git a/unilabos/device_mesh/devices/v_spin_backend/meshes/top_cap.stl b/unilabos/device_mesh/devices/v_spin_backend/meshes/top_cap.stl new file mode 100644 index 000000000..29a542dc5 Binary files /dev/null and b/unilabos/device_mesh/devices/v_spin_backend/meshes/top_cap.stl differ diff --git a/unilabos/device_mesh/devices/v_spin_backend/meta.json b/unilabos/device_mesh/devices/v_spin_backend/meta.json new file mode 100644 index 000000000..41deb5b62 --- /dev/null +++ b/unilabos/device_mesh/devices/v_spin_backend/meta.json @@ -0,0 +1,121 @@ +{ + "fileName": "v_spin_backend", + "related": [ + "v_spin_backend", + "vspin_backend", + "agilent_microplate_centrifuge", + "agilent_vspin", + "velocity11_vspin" + ], + "model_strategy": "fallback_box_assembly_from_official_dimensions", + "sources": [ + { + "title": "Agilent Microplate Centrifuge data sheet", + "url": "https://www.agilent.com/cs/library/datasheets/Public/5990-3484EN_LO.pdf", + "kind": "datasheet", + "notes": "Provides official dimensions for the centrifuge alone and for the centrifuge with the automated loader." + }, + { + "title": "Agilent Microplate Centrifuge with Loader User Guide", + "url": "https://www.agilent.com/cs/library/usermanuals/public/G5405-90002C_Centrifuge_wLoaderUG_EN.pdf", + "kind": "user_guide", + "notes": "Provides front-feature drawings, loader component naming, and overall dimensions for the combined unit." + }, + { + "title": "Agilent VSpin User Guide", + "url": "https://www.agilent.com/Library/usermanuals/Public/G5405-90001C_CentrifugeUG_P_EN.pdf", + "kind": "user_guide", + "notes": "Provides front and rear hardware drawings for the standalone VSpin door and rear connection panel." + } + ], + "dimensions_m": { + "centrifuge_only": { + "width": 0.328, + "depth": 0.457, + "height": 0.206 + }, + "overall_with_loader": { + "width": 0.327, + "depth": 0.711, + "height": 0.251 + }, + "overall_with_loader_datasheet_variant": { + "width": 0.328, + "depth": 0.714, + "height": 0.248 + } + }, + "access_points": [ + { + "name": "door_access", + "description": "Estimated robot-facing center of the centrifuge door opening, based on the front hardware drawings in the VSpin guides.", + "collision_strategy": "opening_cutout", + "face": "front", + "center_m": { + "x": 0.0, + "y": -0.167, + "z": 0.096 + }, + "size_m": { + "x": 0.16, + "y": 0.08, + "z": 0.08 + } + }, + { + "name": "loading_access", + "description": "Estimated loader handoff zone above the plate stage immediately in front of the centrifuge door.", + "collision_strategy": "opening_cutout", + "face": "front_top", + "center_m": { + "x": 0.0, + "y": -0.198, + "z": 0.105 + }, + "size_m": { + "x": 0.16, + "y": 0.12, + "z": 0.06 + } + }, + { + "name": "hover_access", + "description": "Approach pose just outside the door-stage transition for plate transfer.", + "collision_strategy": "opening_cutout", + "face": "front", + "center_m": { + "x": 0.0, + "y": -0.117, + "z": 0.12 + }, + "size_m": { + "x": 0.16, + "y": 0.10, + "z": 0.08 + } + }, + { + "name": "rear_service_access", + "description": "Estimated rear service zone aligned with the documented power, serial, and air connections, placed just outside the simplified rear face.", + "collision_strategy": "opening_cutout", + "face": "rear", + "center_m": { + "x": 0.0, + "y": 0.386, + "z": 0.131 + }, + "size_m": { + "x": 0.18, + "y": 0.06, + "z": 0.10 + } + } + ], + "notes": [ + "No usable public CAD, STL, OBJ, GLB, STEP, or similar download for the Agilent / Velocity11 VSpin with Access2 automated loader was located during this task, so this package uses a generated fallback mesh assembly.", + "The visible assembly is intentionally simple: separate meshes capture the main centrifuge envelope, front door, loader bridge, plate stage, and gripper housing so the robot-facing geometry is more informative than a single box.", + "Agilent official documents differ slightly on the combined-unit dimensions: the loader user guide lists 32.7 x 71.1 x 25.1 cm, while the datasheet lists 32.8 x 71.4 x 24.8 cm. The mesh uses the user-guide footprint and height because that document also contains the component drawings used to place the access frames.", + "Door and loading access frames are estimated from official imagery and diagrams and should be treated as integration-friendly approximations rather than mechanical metrology.", + "Collision access semantics were refactored to opening-cutout behavior: the simplified body collision leaves the loader/front door approach open, and the rear service reference sits just beyond the rear face instead of inside the body envelope." + ] +} diff --git a/unilabos/device_mesh/ros2_controllers.yaml b/unilabos/device_mesh/ros2_controllers.yaml index 230fcd421..add9ea13e 100644 --- a/unilabos/device_mesh/ros2_controllers.yaml +++ b/unilabos/device_mesh/ros2_controllers.yaml @@ -2,4 +2,30 @@ controller_manager: ros__parameters: joint_state_broadcaster: type: joint_state_broadcaster/JointStateBroadcaster + robotic_arm_SCARA_with_slider_moveit_virtual_arm_controller: + type: joint_trajectory_controller/JointTrajectoryController + robotic_arm_SCARA_with_slider_moveit_virtual_gripper_controller: + type: joint_trajectory_controller/JointTrajectoryController update_rate: 100 +robotic_arm_SCARA_with_slider_moveit_virtual_arm_controller: + ros__parameters: + command_interfaces: + - position + joints: + - robotic_arm_SCARA_with_slider_moveit_virtual_arm_base_joint + - robotic_arm_SCARA_with_slider_moveit_virtual_arm_link_1_joint + - robotic_arm_SCARA_with_slider_moveit_virtual_arm_link_2_joint + - robotic_arm_SCARA_with_slider_moveit_virtual_arm_link_3_joint + - robotic_arm_SCARA_with_slider_moveit_virtual_gripper_base_joint + state_interfaces: + - position + - velocity +robotic_arm_SCARA_with_slider_moveit_virtual_gripper_controller: + ros__parameters: + command_interfaces: + - position + joints: + - robotic_arm_SCARA_with_slider_moveit_virtual_gripper_right_joint + state_interfaces: + - position + - velocity diff --git a/unilabos/device_mesh/tools/audit_access_collision.py b/unilabos/device_mesh/tools/audit_access_collision.py new file mode 100644 index 000000000..b8206c294 --- /dev/null +++ b/unilabos/device_mesh/tools/audit_access_collision.py @@ -0,0 +1,278 @@ +#!/usr/bin/env python3 +"""Audit simplified device xacros for access points embedded in base collisions. + +This script intentionally handles the limited xacro patterns used in the +fallback `_phage_display` device meshes: +- macro params with default numeric expressions +- fixed joints +- box collision geometry + +It does not expand full ROS xacro semantics. The goal is to flag the planning +regressions we care about in these simplified macros. +""" + +from __future__ import annotations + +import argparse +import json +import math +import re +import sys +import xml.etree.ElementTree as ET +from dataclasses import dataclass +from pathlib import Path +from typing import Iterable + + +ACCESS_SUFFIXES = ( + "access_link", + "slot_access", + "socketTypeGenericSbsFootprint", +) + +ALLOWED_COLLISION_STRATEGIES = { + "opening_cutout", + "fixed_extended_tray", + "exposed_work_surface", +} + +TRAY_HINTS = ( + "tray_link", + "drawer_link", + "stage_link", + "loading_tray_link", + "loader_stage_link", + "waste_drawer_link", +) + + +@dataclass +class Box: + center: tuple[float, float, float] + size: tuple[float, float, float] + + def contains(self, point: tuple[float, float, float], margin: float = 1e-6) -> bool: + return all( + abs(point[i] - self.center[i]) <= self.size[i] / 2.0 - margin + for i in range(3) + ) + + +def parse_params(params: str) -> dict[str, float]: + result: dict[str, float] = {"pi": math.pi} + for raw_line in params.splitlines(): + line = raw_line.strip() + if not line: + continue + for key, value in re.findall(r"([A-Za-z_][A-Za-z0-9_]*)\s*:=\s*([^ ]+)", line): + try: + result[key] = float(eval_expr(value, result)) + except Exception: + # Keep going if a default cannot be resolved yet. + pass + return result + + +def eval_expr(raw: str | None, variables: dict[str, float]) -> float: + if raw is None: + return 0.0 + text = raw.strip() + if text.startswith("${") and text.endswith("}"): + text = text[2:-1].strip() + allowed = {**variables, "pi": math.pi} + return float(eval(text, {"__builtins__": {}}, allowed)) + + +def eval_xyz(raw: str | None, variables: dict[str, float]) -> tuple[float, float, float]: + if raw is None: + return (0.0, 0.0, 0.0) + parts = re.findall(r"\$\{[^}]+\}|[^ ]+", raw.strip()) + if len(parts) != 3: + raise ValueError(f"Expected xyz triplet, got: {raw}") + return tuple(eval_expr(part, variables) for part in parts) # type: ignore[return-value] + + +def parse_macro(path: Path) -> tuple[dict[str, float], ET.Element]: + tree = ET.parse(path) + root = tree.getroot() + macro = root.find("{http://www.ros.org/wiki/xacro}macro") + if macro is None: + raise ValueError(f"No xacro:macro found in {path}") + variables = parse_params(macro.attrib.get("params", "")) + parse_properties(macro, variables) + return variables, macro + + +def parse_properties(macro: ET.Element, variables: dict[str, float]) -> None: + pending: list[tuple[str, str]] = [] + for child in macro: + if child.tag != "{http://www.ros.org/wiki/xacro}property": + continue + name = child.attrib.get("name") + value = child.attrib.get("value") + if name and value: + pending.append((name, value)) + + while pending: + progressed = False + next_pending: list[tuple[str, str]] = [] + for name, value in pending: + try: + variables[name] = eval_expr(value, variables) + progressed = True + except Exception: + next_pending.append((name, value)) + if not progressed: + unresolved = ", ".join(name for name, _ in next_pending) + raise ValueError(f"Could not resolve xacro properties: {unresolved}") + pending = next_pending + + +def iter_children(element: ET.Element, tag: str) -> Iterable[ET.Element]: + for child in element: + if child.tag.endswith(tag): + yield child + + +def collect_base_collisions(macro: ET.Element, variables: dict[str, float]) -> list[Box]: + boxes: list[Box] = [] + for link in iter_children(macro, "link"): + name = link.attrib.get("name", "") + if not name.endswith("base_link"): + continue + for collision in iter_children(link, "collision"): + origin_el = next(iter_children(collision, "origin"), None) + geom_el = next(iter_children(collision, "geometry"), None) + if geom_el is None: + continue + box_el = next(iter_children(geom_el, "box"), None) + if box_el is None: + continue + center = eval_xyz(origin_el.attrib.get("xyz") if origin_el is not None else None, variables) + size = eval_xyz(box_el.attrib.get("size"), variables) + boxes.append(Box(center=center, size=size)) + return boxes + + +def collect_fixed_joints( + macro: ET.Element, variables: dict[str, float] +) -> dict[str, tuple[str, tuple[float, float, float]]]: + joints: dict[str, tuple[str, tuple[float, float, float]]] = {} + for joint in iter_children(macro, "joint"): + if joint.attrib.get("type") != "fixed": + continue + child_el = next(iter_children(joint, "child"), None) + parent_el = next(iter_children(joint, "parent"), None) + if child_el is None or parent_el is None: + continue + child = child_el.attrib.get("link") + parent = parent_el.attrib.get("link") + if not child or not parent: + continue + origin_el = next(iter_children(joint, "origin"), None) + xyz = eval_xyz(origin_el.attrib.get("xyz") if origin_el is not None else None, variables) + joints[child] = (parent, xyz) + return joints + + +def resolve_to_base( + link_name: str, + joints: dict[str, tuple[str, tuple[float, float, float]]], +) -> tuple[str | None, tuple[float, float, float]]: + total = [0.0, 0.0, 0.0] + seen: set[str] = set() + current = link_name + while current in joints: + if current in seen: + raise ValueError(f"Joint cycle detected at {current}") + seen.add(current) + parent, xyz = joints[current] + total = [total[i] + xyz[i] for i in range(3)] + current = parent + if current.endswith("base_link"): + return current, (total[0], total[1], total[2]) + return None, (total[0], total[1], total[2]) + + +def audit_device(path: Path) -> list[str]: + variables, macro = parse_macro(path) + boxes = collect_base_collisions(macro, variables) + joints = collect_fixed_joints(macro, variables) + problems: list[str] = [] + for link in iter_children(macro, "link"): + name = link.attrib.get("name", "") + if not name.endswith(ACCESS_SUFFIXES): + continue + parent, point = resolve_to_base(name, joints) + if parent is None: + continue + if any(box.contains(point) for box in boxes): + problems.append(f"{path}: access link {name} resolves inside base collision at {point}") + lower = name.lower() + tray_backed = any(token in lower for token in ("tray", "drawer", "loader_stage")) + if tray_backed: + parent_name = joints.get(name, ("", (0.0, 0.0, 0.0)))[0] + if parent_name.endswith("base_link"): + problems.append(f"{path}: tray-backed access link {name} is parented directly to base_link") + return problems + + +def audit_metadata(path: Path) -> list[str]: + problems: list[str] = [] + try: + data = json.loads(path.read_text()) + except Exception as exc: # pragma: no cover - already useful in output + return [f"{path}: invalid JSON: {exc}"] + + def visit_access_points(items: object, context: str) -> None: + if not isinstance(items, list): + return + for item in items: + if not isinstance(item, dict): + continue + name = item.get("name") or item.get("linkSuffix") or "" + if "collision_strategy" not in item: + problems.append(f"{path}: {context} entry {name} missing collision_strategy") + continue + strategy = item.get("collision_strategy") + if strategy not in ALLOWED_COLLISION_STRATEGIES: + problems.append( + f"{path}: {context} entry {name} has invalid collision_strategy {strategy!r}" + ) + + visit_access_points(data.get("access_points"), "access_points") + visit_access_points(data.get("accessFrames"), "accessFrames") + return problems + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("paths", nargs="+", help="macro_device.xacro paths or device directories") + args = parser.parse_args() + + macro_paths: list[Path] = [] + for raw in args.paths: + path = Path(raw) + if path.is_dir(): + macro_paths.append(path / "macro_device.xacro") + else: + macro_paths.append(path) + + problems: list[str] = [] + for macro_path in macro_paths: + problems.extend(audit_device(macro_path)) + meta_path = macro_path.with_name("meta.json") + if meta_path.exists(): + problems.extend(audit_metadata(meta_path)) + + if problems: + for problem in problems: + print(problem) + return 1 + + print(f"OK {len(macro_paths)} device macro(s) passed access collision audit") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/unilabos/device_mesh/tools/generate_box_stl.py b/unilabos/device_mesh/tools/generate_box_stl.py new file mode 100644 index 000000000..87577dd2c --- /dev/null +++ b/unilabos/device_mesh/tools/generate_box_stl.py @@ -0,0 +1,90 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +from pathlib import Path +from typing import Iterable, Sequence + + +Vector = tuple[float, float, float] +Triangle = tuple[Vector, Vector, Vector] + + +def _normal(a: Vector, b: Vector, c: Vector) -> Vector: + ux, uy, uz = (b[0] - a[0], b[1] - a[1], b[2] - a[2]) + vx, vy, vz = (c[0] - a[0], c[1] - a[1], c[2] - a[2]) + nx = uy * vz - uz * vy + ny = uz * vx - ux * vz + nz = ux * vy - uy * vx + length = (nx * nx + ny * ny + nz * nz) ** 0.5 + if length == 0: + return (0.0, 0.0, 0.0) + return (nx / length, ny / length, nz / length) + + +def _format_vertex(vertex: Vector) -> str: + return f"{vertex[0]:.6f} {vertex[1]:.6f} {vertex[2]:.6f}" + + +def build_box(width: float, depth: float, height: float) -> Sequence[Triangle]: + half_width = width / 2.0 + half_depth = depth / 2.0 + vertices = { + "lbf": (-half_width, -half_depth, 0.0), + "rbf": (half_width, -half_depth, 0.0), + "rtf": (half_width, half_depth, 0.0), + "ltf": (-half_width, half_depth, 0.0), + "lbb": (-half_width, -half_depth, height), + "rbb": (half_width, -half_depth, height), + "rtb": (half_width, half_depth, height), + "ltb": (-half_width, half_depth, height), + } + return ( + (vertices["lbf"], vertices["rtf"], vertices["rbf"]), + (vertices["lbf"], vertices["ltf"], vertices["rtf"]), + (vertices["lbb"], vertices["rbb"], vertices["rtb"]), + (vertices["lbb"], vertices["rtb"], vertices["ltb"]), + (vertices["lbf"], vertices["rbf"], vertices["rbb"]), + (vertices["lbf"], vertices["rbb"], vertices["lbb"]), + (vertices["ltf"], vertices["ltb"], vertices["rtb"]), + (vertices["ltf"], vertices["rtb"], vertices["rtf"]), + (vertices["lbf"], vertices["lbb"], vertices["ltb"]), + (vertices["lbf"], vertices["ltb"], vertices["ltf"]), + (vertices["rbf"], vertices["rtf"], vertices["rtb"]), + (vertices["rbf"], vertices["rtb"], vertices["rbb"]), + ) + + +def write_ascii_stl(path: Path, solid_name: str, triangles: Iterable[Triangle]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("w", encoding="ascii") as handle: + handle.write(f"solid {solid_name}\n") + for triangle in triangles: + normal = _normal(*triangle) + handle.write(f" facet normal {_format_vertex(normal)}\n") + handle.write(" outer loop\n") + for vertex in triangle: + handle.write(f" vertex {_format_vertex(vertex)}\n") + handle.write(" endloop\n") + handle.write(" endfacet\n") + handle.write(f"endsolid {solid_name}\n") + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Generate a simple meter-based box STL.") + parser.add_argument("--width", type=float, required=True, help="Box width in meters.") + parser.add_argument("--depth", type=float, required=True, help="Box depth in meters.") + parser.add_argument("--height", type=float, required=True, help="Box height in meters.") + parser.add_argument("--output", type=Path, required=True, help="Output STL path.") + parser.add_argument("--name", default="device_box", help="ASCII STL solid name.") + return parser.parse_args() + + +def main() -> None: + args = parse_args() + triangles = build_box(width=args.width, depth=args.depth, height=args.height) + write_ascii_stl(path=args.output, solid_name=args.name, triangles=triangles) + + +if __name__ == "__main__": + main() diff --git a/unilabos/device_mesh/tools/phage_display_mesh_dispatch_manifest.tsv b/unilabos/device_mesh/tools/phage_display_mesh_dispatch_manifest.tsv new file mode 100644 index 000000000..b61e0ac76 --- /dev/null +++ b/unilabos/device_mesh/tools/phage_display_mesh_dispatch_manifest.tsv @@ -0,0 +1,28 @@ +driver_path device_id mesh_folder notes +/home/xzye/projects/DPTech/LeapLab/Uni-Lab-OS/unilabos/devices/_phage_display/access2_backend.py access2_backend access2_backend generic microplate centrifuge backend; choose closest representative automated plate centrifuge if exact model unclear +/home/xzye/projects/DPTech/LeapLab/Uni-Lab-OS/unilabos/devices/_phage_display/bio_shake.py bio_shake bio_shake representative INHECO-style microplate thermoshaker if exact unit unclear +/home/xzye/projects/DPTech/LeapLab/Uni-Lab-OS/unilabos/devices/_phage_display/bio_tek_plate_reader_backend.py bio_tek_plate_reader_backend bio_tek_plate_reader_backend prefer exact Agilent BioTek reader model if recoverable from docs +/home/xzye/projects/DPTech/LeapLab/Uni-Lab-OS/unilabos/devices/_phage_display/centrifuge.py centrifuge centrifuge generic centrifuge frontend; use representative automated plate centrifuge +/home/xzye/projects/DPTech/LeapLab/Uni-Lab-OS/unilabos/devices/_phage_display/clari_ostar_backend.py clari_ostar_backend clari_ostar_backend prefer exact BMG CLARIOstar family model if recoverable +/home/xzye/projects/DPTech/LeapLab/Uni-Lab-OS/unilabos/devices/_phage_display/cytomat_backend.py cytomat_backend cytomat_backend prioritize loading-tray and transfer opening access points +/home/xzye/projects/DPTech/LeapLab/Uni-Lab-OS/unilabos/devices/_phage_display/guessed_agilent_biotek_406_fx.py agilent_biotek_406_fx agilent_biotek_406_fx target Agilent BioTek 406 FX specifically +/home/xzye/projects/DPTech/LeapLab/Uni-Lab-OS/unilabos/devices/_phage_display/guessed_applied_biosystems_seqstudio_genetic_analyzer.py applied_biosystems_seqstudio_genetic_analyzer applied_biosystems_seqstudio_genetic_analyzer target Applied Biosystems SeqStudio specifically +/home/xzye/projects/DPTech/LeapLab/Uni-Lab-OS/unilabos/devices/_phage_display/guessed_bd_facsmelody.py bd_facsmelody bd_facsmelody target BD FACSMelody specifically +/home/xzye/projects/DPTech/LeapLab/Uni-Lab-OS/unilabos/devices/_phage_display/guessed_cytiva_akta_pure.py cytiva_akta_pure cytiva_akta_pure target Cytiva AKTA pure specifically +/home/xzye/projects/DPTech/LeapLab/Uni-Lab-OS/unilabos/devices/_phage_display/guessed_cytiva_biacore_8k_plus.py cytiva_biacore_8k_plus cytiva_biacore_8k_plus target Cytiva Biacore 8K+ specifically +/home/xzye/projects/DPTech/LeapLab/Uni-Lab-OS/unilabos/devices/_phage_display/guessed_eppendorf_centrifuge_5910_ri.py eppendorf_centrifuge_5910_ri eppendorf_centrifuge_5910_ri target Eppendorf 5910 Ri specifically +/home/xzye/projects/DPTech/LeapLab/Uni-Lab-OS/unilabos/devices/_phage_display/guessed_hettich_rotanta_460_robotic.py hettich_rotanta_460_robotic hettich_rotanta_460_robotic target Hettich Rotanta 460 Robotic specifically +/home/xzye/projects/DPTech/LeapLab/Uni-Lab-OS/unilabos/devices/_phage_display/guessed_molecular_devices_qpix_420.py molecular_devices_qpix_420 molecular_devices_qpix_420 target Molecular Devices QPix 420 specifically +/home/xzye/projects/DPTech/LeapLab/Uni-Lab-OS/unilabos/devices/_phage_display/guessed_qiagen_qiacube_connect.py qiagen_qiacube_connect qiagen_qiacube_connect target QIAGEN QIAcube Connect specifically +/home/xzye/projects/DPTech/LeapLab/Uni-Lab-OS/unilabos/devices/_phage_display/guessed_tecan_resolvex_a200.py tecan_resolvex_a200 tecan_resolvex_a200 target Tecan Resolvex A200 specifically +/home/xzye/projects/DPTech/LeapLab/Uni-Lab-OS/unilabos/devices/_phage_display/guessed_telesis_bio_bioxp_3250.py telesis_bio_bioxp_3250 telesis_bio_bioxp_3250 target Telesis BIO BIOXp 3250 specifically +/home/xzye/projects/DPTech/LeapLab/Uni-Lab-OS/unilabos/devices/_phage_display/incubator.py incubator incubator generic automated microplate incubator; emphasize loading tray +/home/xzye/projects/DPTech/LeapLab/Uni-Lab-OS/unilabos/devices/_phage_display/incubator_shaker_stack.py incubator_shaker_stack incubator_shaker_stack stacked thermoshaker/incubator with per-unit loading trays +/home/xzye/projects/DPTech/LeapLab/Uni-Lab-OS/unilabos/devices/_phage_display/li_ha.py li_ha li_ha generic liquid-handling workstation with pipetting deck opening +/home/xzye/projects/DPTech/LeapLab/Uni-Lab-OS/unilabos/devices/_phage_display/molecular_devices_backend.py molecular_devices_backend molecular_devices_backend generic Molecular Devices reader backend; choose representative plate reader +/home/xzye/projects/DPTech/LeapLab/Uni-Lab-OS/unilabos/devices/_phage_display/peeler.py peeler peeler generic plate desealer; include front loading slot +/home/xzye/projects/DPTech/LeapLab/Uni-Lab-OS/unilabos/devices/_phage_display/plate_reader.py plate_reader plate_reader generic plate reader; include tray/drawer access point +/home/xzye/projects/DPTech/LeapLab/Uni-Lab-OS/unilabos/devices/_phage_display/sealer.py sealer sealer generic plate sealer; include front loading slot +/home/xzye/projects/DPTech/LeapLab/Uni-Lab-OS/unilabos/devices/_phage_display/star_backend.py star_backend star_backend prefer Hamilton STAR canonical slug if clearly supported +/home/xzye/projects/DPTech/LeapLab/Uni-Lab-OS/unilabos/devices/_phage_display/v_spin_backend.py v_spin_backend v_spin_backend automated centrifuge backend; include primary door/loading access point +/home/xzye/projects/DPTech/LeapLab/Uni-Lab-OS/unilabos/devices/_phage_display/vantage_backend.py vantage_backend hamilton_vantage existing example already present; improve sources and add access-point frames if possible diff --git a/unilabos/device_mesh/tools/phage_display_mesh_universal_prompt.md b/unilabos/device_mesh/tools/phage_display_mesh_universal_prompt.md new file mode 100644 index 000000000..e46314dcf --- /dev/null +++ b/unilabos/device_mesh/tools/phage_display_mesh_universal_prompt.md @@ -0,0 +1,75 @@ +You are responsible for exactly one `_phage_display` device in Uni-Lab-OS. Use web search to find a usable 3D representation for the instrument, then wire it into Uni-Lab-OS. + +Goals +1. Inspect the target driver file and identify the decorated `@device(...)` class and device id. +2. Search the web for the exact instrument or the best representative physical model. + - First preference: downloadable `.stl` + - Second preference: downloadable `.glb` plus `.stl` conversion or paired assets + - Third preference: an existing `.xacro` / URDF / CAD model we can adapt + - Fallback: collect reliable external dimensions and visible opening / loading / tray / door access locations from manuals, brochures, product figures, or videos, then build a simple box-based `.stl` and `macro_device.xacro` from those measurements. +3. Add or update a device mesh package under `Uni-Lab-OS/unilabos/device_mesh/devices//`. +4. Update the target driver's `@device(...)` decorator to include: + +```python +model={ + "type": "device", + "mesh": "", +}, +``` + +5. Do not touch other drivers or other workers' files. + +Required outputs +- `Uni-Lab-OS/unilabos/device_mesh/devices//macro_device.xacro` +- `Uni-Lab-OS/unilabos/device_mesh/devices//meshes/<...>.stl` +- optional `.glb` if you found a good one and it is easy to keep +- `Uni-Lab-OS/unilabos/device_mesh/devices//meta.json` +- updated target driver file with the `model` block in `@device(...)` + +`macro_device.xacro` requirements +- Match the style of `hamilton_vantage/macro_device.xacro` +- Define a macro named `` +- Support params: + `parent_link`, `station_name`, `device_name`, `x`, `y`, `z`, `rx`, `ry`, `r`, `mesh_path` +- Include a fixed base link and visual + collision geometry +- If using fallback dimensions, expose `width`, `depth`, `height` params with sensible defaults +- Include access-point frames as fixed child links / joints on the base link when applicable + Examples: front door, loading tray, plate slot, carousel opening, pipetting deck opening +- Add short XML comments explaining any assumptions + +`meta.json` requirements +- Keep `fileName` and `related` +- Also include: + - `model_strategy`: `downloaded_model` or `fallback_box` + - `sources`: array of URLs used + - `dimensions_m`: width / depth / height in meters + - `access_points`: array with `name`, `description`, `face`, `center_m`, `size_m` when known + - `notes`: brief assumptions / uncertainty + +Research rules +- Prefer vendor manuals / official specs for dimensions +- Prefer openly accessible CAD / mesh sources when available +- Record exact URLs in `meta.json` +- If a source is ambiguous, say so in `notes` +- If the driver is generic rather than vendor-specific, choose the closest representative instrument matching the class description and record that assumption + +Fallback rules +- Use `/home/xzye/projects/DPTech/LeapLab/Uni-Lab-OS/unilabos/device_mesh/tools/generate_box_stl.py` for fallback box STL generation +- The helper is meter-based and accepts: + `--width --depth --height --output --name` +- Keep access points in xacro frames and `meta.json` even when the STL itself is only a box + +Implementation rules +- You are not alone in the codebase. Do not revert unrelated changes and do not edit files outside your ownership. +- Keep ownership to: + - the target driver file + - the new or existing device mesh folder for this device +- Use the existing `hamilton_vantage` package as the main reference +- Validate your edited Python file with `python3 -m py_compile ` +- If you create a fallback STL, keep coordinates in meters and make the mesh loadable by the xacro + +At the end, report: +- whether you found a real downloadable model or used fallback geometry +- the URLs you relied on +- the files you changed +- any uncertainty that still remains diff --git a/unilabos/device_mesh/tools/phage_display_mesh_worker_prompts.md b/unilabos/device_mesh/tools/phage_display_mesh_worker_prompts.md new file mode 100644 index 000000000..6c9d3e686 --- /dev/null +++ b/unilabos/device_mesh/tools/phage_display_mesh_worker_prompts.md @@ -0,0 +1,117 @@ +# Phage Display Device Mesh Worker Prompts + +This file captures the shared worker prompt and the per-device ownership boundaries for +`Uni-Lab-OS/unilabos/devices/_phage_display`. + +## Universal Prompt + +```text +You are responsible for exactly one `_phage_display` device in Uni-Lab-OS. Use web search to +find a usable 3D representation for the instrument, then wire it into Uni-Lab-OS. + +Goals +1. Inspect the target driver file and identify the decorated `@device(...)` class and device id. +2. Search the web for the exact instrument or the best representative physical model. + - First preference: downloadable `.stl` + - Second preference: downloadable `.glb` plus `.stl` conversion or paired assets + - Third preference: an existing `.xacro` / URDF / CAD model we can adapt + - Fallback: collect reliable external dimensions and visible opening / loading / tray / door + access locations from manuals, brochures, product figures, or videos, then build a simple + box-based `.stl` and `macro_device.xacro` from those measurements. +3. Add or update a device mesh package under + `Uni-Lab-OS/unilabos/device_mesh/devices//`. +4. Update the target driver's `@device(...)` decorator to include: + model={ + "type": "device", + "mesh": "", + }, +5. Do not touch other drivers or other workers' files. + +Required outputs +- `Uni-Lab-OS/unilabos/device_mesh/devices//macro_device.xacro` +- `Uni-Lab-OS/unilabos/device_mesh/devices//meshes/<...>.stl` +- optional `.glb` if you found a good one and it is easy to keep +- `Uni-Lab-OS/unilabos/device_mesh/devices//meta.json` +- updated target driver file with the `model` block in `@device(...)` + +`macro_device.xacro` requirements +- Match the style of `hamilton_vantage/macro_device.xacro` +- Define a macro named `` +- Support params: + `parent_link`, `station_name`, `device_name`, `x`, `y`, `z`, `rx`, `ry`, `r`, `mesh_path` +- Include a fixed base link and visual + collision geometry +- If using fallback dimensions, expose `width`, `depth`, `height` params with sensible defaults +- Include access-point frames as fixed child links / joints on the base link when applicable + Examples: front door, loading tray, plate slot, carousel opening, pipetting deck opening +- Add short XML comments explaining any assumptions + +`meta.json` requirements +- Keep `fileName` and `related` +- Also include: + - `model_strategy`: `downloaded_model` or `fallback_box` + - `sources`: array of URLs used + - `dimensions_m`: width / depth / height in meters + - `access_points`: array with `name`, `description`, `face`, `center_m`, `size_m` when known + - `notes`: brief assumptions / uncertainty + +Research rules +- Prefer vendor manuals / official specs for dimensions +- Prefer openly accessible CAD / mesh sources when available +- Record exact URLs in `meta.json` +- If a source is ambiguous, say so in `notes` +- If the driver is generic rather than vendor-specific, choose the closest representative + instrument matching the class description and record that assumption + +Implementation rules +- You are not alone in the codebase. Do not revert unrelated changes and do not edit files outside + your ownership. +- Keep ownership to: + - the target driver file + - the new or existing device mesh folder for this device +- Use the existing `hamilton_vantage` package as the main reference +- Use the fallback STL helper when you need a box mesh: + `/home/xzye/projects/DPTech/LeapLab/Uni-Lab-OS/unilabos/device_mesh/tools/generate_box_stl.py` +- Validate your edited Python file with `python3 -m py_compile ` +- If you create a fallback STL, keep coordinates in meters and make the mesh loadable by the xacro + +At the end, report: +- whether you found a real downloadable model or used fallback geometry +- the URLs you relied on +- the files you changed +- any uncertainty that still remains +``` + +## Per-Device Ownership + +Use the universal prompt above, then substitute the target-specific values below. + +| Device ID | Driver | Suggested Mesh Folder | Notes | +| --- | --- | --- | --- | +| `access2_backend` | `Uni-Lab-OS/unilabos/devices/_phage_display/access2_backend.py` | `access2_backend` | Generic plate centrifuge backend; choose closest representative automated plate centrifuge if exact model is unclear. | +| `bio_shake` | `Uni-Lab-OS/unilabos/devices/_phage_display/bio_shake.py` | `bio_shake` | Representative INHECO-style microplate thermoshaker is acceptable if needed. | +| `bio_tek_plate_reader_backend` | `Uni-Lab-OS/unilabos/devices/_phage_display/bio_tek_plate_reader_backend.py` | `bio_tek_plate_reader_backend` | Prefer a specific Agilent BioTek reader if research clearly identifies one. | +| `centrifuge` | `Uni-Lab-OS/unilabos/devices/_phage_display/centrifuge.py` | `centrifuge` | Generic front-end; choose a representative automated plate centrifuge. | +| `clari_ostar_backend` | `Uni-Lab-OS/unilabos/devices/_phage_display/clari_ostar_backend.py` | `clari_ostar_backend` | Prefer a BMG CLARIOstar family model if clearly supported. | +| `cytomat_backend` | `Uni-Lab-OS/unilabos/devices/_phage_display/cytomat_backend.py` | `cytomat_backend` | Prioritize loading-tray / transfer opening frames. | +| `agilent_biotek_406_fx` | `Uni-Lab-OS/unilabos/devices/_phage_display/guessed_agilent_biotek_406_fx.py` | `agilent_biotek_406_fx` | Target the Agilent BioTek 406 FX specifically. | +| `applied_biosystems_seqstudio_genetic_analyzer` | `Uni-Lab-OS/unilabos/devices/_phage_display/guessed_applied_biosystems_seqstudio_genetic_analyzer.py` | `applied_biosystems_seqstudio_genetic_analyzer` | Include cartridge / consumable access area if it can be estimated. | +| `bd_facsmelody` | `Uni-Lab-OS/unilabos/devices/_phage_display/guessed_bd_facsmelody.py` | `bd_facsmelody` | Estimate sample-loading / sort-output faces if needed. | +| `cytiva_akta_pure` | `Uni-Lab-OS/unilabos/devices/_phage_display/guessed_cytiva_akta_pure.py` | `cytiva_akta_pure` | Prioritize front service / fraction collector access faces. | +| `cytiva_biacore_8k_plus` | `Uni-Lab-OS/unilabos/devices/_phage_display/guessed_cytiva_biacore_8k_plus.py` | `cytiva_biacore_8k_plus` | Prioritize plate / sample loading area if available. | +| `eppendorf_centrifuge_5910_ri` | `Uni-Lab-OS/unilabos/devices/_phage_display/guessed_eppendorf_centrifuge_5910_ri.py` | `eppendorf_centrifuge_5910_ri` | Include front lid / top opening semantics if estimated. | +| `hettich_rotanta_460_robotic` | `Uni-Lab-OS/unilabos/devices/_phage_display/guessed_hettich_rotanta_460_robotic.py` | `hettich_rotanta_460_robotic` | Prioritize robotic loading interface / door location. | +| `molecular_devices_qpix_420` | `Uni-Lab-OS/unilabos/devices/_phage_display/guessed_molecular_devices_qpix_420.py` | `molecular_devices_qpix_420` | Include colony plate input / output access faces when known. | +| `qiagen_qiacube_connect` | `Uni-Lab-OS/unilabos/devices/_phage_display/guessed_qiagen_qiacube_connect.py` | `qiagen_qiacube_connect` | Include front door / rotor access if estimated. | +| `tecan_resolvex_a200` | `Uni-Lab-OS/unilabos/devices/_phage_display/guessed_tecan_resolvex_a200.py` | `tecan_resolvex_a200` | Prioritize front deck / filter plate access. | +| `telesis_bio_bioxp_3250` | `Uni-Lab-OS/unilabos/devices/_phage_display/guessed_telesis_bio_bioxp_3250.py` | `telesis_bio_bioxp_3250` | Capture the most likely consumable access face. | +| `incubator` | `Uni-Lab-OS/unilabos/devices/_phage_display/incubator.py` | `incubator` | Generic incubator front-end; choose representative microplate incubator with tray access. | +| `incubator_shaker_stack` | `Uni-Lab-OS/unilabos/devices/_phage_display/incubator_shaker_stack.py` | `incubator_shaker_stack` | Include per-unit tray access frames when practical. | +| `li_ha` | `Uni-Lab-OS/unilabos/devices/_phage_display/li_ha.py` | `li_ha` | Generic liquid handling workstation; choose representative deck-opening geometry. | +| `molecular_devices_backend` | `Uni-Lab-OS/unilabos/devices/_phage_display/molecular_devices_backend.py` | `molecular_devices_backend` | Choose representative multi-mode plate reader if exact model is unclear. | +| `peeler` | `Uni-Lab-OS/unilabos/devices/_phage_display/peeler.py` | `peeler` | Generic plate desealer; include front plate slot or door frame. | +| `plate_reader` | `Uni-Lab-OS/unilabos/devices/_phage_display/plate_reader.py` | `plate_reader` | Generic front-end; choose representative plate-reader drawer form factor. | +| `sealer` | `Uni-Lab-OS/unilabos/devices/_phage_display/sealer.py` | `sealer` | Generic plate sealer; include tray / plate insertion slot frame. | +| `star_backend` | `Uni-Lab-OS/unilabos/devices/_phage_display/star_backend.py` | `star_backend` | Hamilton STAR family deck and loading-tray access points matter most. | +| `v_spin_backend` | `Uni-Lab-OS/unilabos/devices/_phage_display/v_spin_backend.py` | `v_spin_backend` | Prefer exact vendor model if discoverable; otherwise representative robotic centrifuge. | +| `vantage_backend` | `Uni-Lab-OS/unilabos/devices/_phage_display/vantage_backend.py` | `hamilton_vantage` | Existing example already present; improve research provenance and add access-point frames if helpful. | + diff --git a/unilabos/device_mesh/view_robot.rviz b/unilabos/device_mesh/view_robot.rviz index 25ffd2e82..f1ed39a57 100644 --- a/unilabos/device_mesh/view_robot.rviz +++ b/unilabos/device_mesh/view_robot.rviz @@ -7,11 +7,12 @@ Panels: - /TF1/Tree1 - /PlanningScene1 - /PlanningScene1/Scene Geometry1 + - /MotionPlanning1 - /MotionPlanning1/Scene Geometry1 - /MotionPlanning1/Scene Robot1 - /MotionPlanning1/Planning Request1 Splitter Ratio: 0.5016146302223206 - Tree Height: 563 + Tree Height: 276 - Class: rviz_common/Selection Name: Selection - Class: rviz_common/Tool Properties @@ -107,392 +108,1346 @@ Visualization Manager: Expand Link Details: false Expand Tree: false Link Tree Style: Links in Alphabetic Order - arm_slider_arm_base: + agilent_biotek_406_fx_base_link: Alpha: 1 Show Axes: false Show Trail: false Value: true - arm_slider_arm_link_1: + agilent_biotek_406_fx_device_link: + Alpha: 1 + Show Axes: false + Show Trail: false + agilent_biotek_406_fx_left_plate_access_link: + Alpha: 1 + Show Axes: false + Show Trail: false + agilent_biotek_406_fx_plate_carrier_link: + Alpha: 1 + Show Axes: false + Show Trail: false + Value: true + agilent_biotek_406_fx_right_plate_access_link: + Alpha: 1 + Show Axes: false + Show Trail: false + agilent_biotek_406_fx_socketTypeGenericSbsFootprint: + Alpha: 1 + Show Axes: false + Show Trail: false + applied_biosystems_seqstudio_genetic_analyzer_base_link: + Alpha: 1 + Show Axes: false + Show Trail: false + Value: true + applied_biosystems_seqstudio_genetic_analyzer_cartridge_access_link: + Alpha: 1 + Show Axes: false + Show Trail: false + applied_biosystems_seqstudio_genetic_analyzer_device_link: + Alpha: 1 + Show Axes: false + Show Trail: false + applied_biosystems_seqstudio_genetic_analyzer_front_loading_access_link: + Alpha: 1 + Show Axes: false + Show Trail: false + applied_biosystems_seqstudio_genetic_analyzer_sample_plate_access_link: + Alpha: 1 + Show Axes: false + Show Trail: false + applied_biosystems_seqstudio_genetic_analyzer_screen_access_link: + Alpha: 1 + Show Axes: false + Show Trail: false + applied_biosystems_seqstudio_genetic_analyzer_socketTypeGenericSbsFootprint: + Alpha: 1 + Show Axes: false + Show Trail: false + bd_facsmelody_base_link: + Alpha: 1 + Show Axes: false + Show Trail: false + Value: true + bd_facsmelody_device_link: + Alpha: 1 + Show Axes: false + Show Trail: false + bd_facsmelody_flow_cell_access_link: + Alpha: 1 + Show Axes: false + Show Trail: false + bd_facsmelody_rear_service_access_link: + Alpha: 1 + Show Axes: false + Show Trail: false + bd_facsmelody_sample_loading_access_link: + Alpha: 1 + Show Axes: false + Show Trail: false + bd_facsmelody_sort_output_access_link: + Alpha: 1 + Show Axes: false + Show Trail: false + cytiva_akta_pure_base_link: + Alpha: 1 + Show Axes: false + Show Trail: false + Value: true + cytiva_akta_pure_device_link: + Alpha: 1 + Show Axes: false + Show Trail: false + cytiva_akta_pure_front_access_link: + Alpha: 1 + Show Axes: false + Show Trail: false + cytiva_akta_pure_rear_service_access_link: + Alpha: 1 + Show Axes: false + Show Trail: false + cytiva_biacore_8k_plus_base_link: + Alpha: 1 + Show Axes: false + Show Trail: false + Value: true + cytiva_biacore_8k_plus_device_link: + Alpha: 1 + Show Axes: false + Show Trail: false + cytiva_biacore_8k_plus_sample_compartment_access_link: + Alpha: 1 + Show Axes: false + Show Trail: false + cytiva_biacore_8k_plus_sample_hotel_access_link: + Alpha: 1 + Show Axes: false + Show Trail: false + cytiva_biacore_8k_plus_sensor_chip_access_link: + Alpha: 1 + Show Axes: false + Show Trail: false + cytomat_backend_base_link: + Alpha: 1 + Show Axes: false + Show Trail: false + Value: true + cytomat_backend_device_link: + Alpha: 1 + Show Axes: false + Show Trail: false + cytomat_backend_loading_tray_access_link: + Alpha: 1 + Show Axes: false + Show Trail: false + cytomat_backend_loading_tray_link: + Alpha: 1 + Show Axes: false + Show Trail: false + Value: true + cytomat_backend_transfer_opening_access_link: + Alpha: 1 + Show Axes: false + Show Trail: false + hettich_rotanta_460_robotic_base_link: + Alpha: 1 + Show Axes: false + Show Trail: false + Value: true + hettich_rotanta_460_robotic_device_link: + Alpha: 1 + Show Axes: false + Show Trail: false + hettich_rotanta_460_robotic_front_lid_hatch_access_link: + Alpha: 1 + Show Axes: false + Show Trail: false + hettich_rotanta_460_robotic_rear_lid_hatch_access_link: + Alpha: 1 + Show Axes: false + Show Trail: false + hettich_rotanta_460_robotic_rear_service_access_link: + Alpha: 1 + Show Axes: false + Show Trail: false + hettich_rotanta_460_robotic_top_loading_access_link: + Alpha: 1 + Show Axes: false + Show Trail: false + molecular_devices_qpix_420_base_link: + Alpha: 1 + Show Axes: false + Show Trail: false + Value: true + molecular_devices_qpix_420_control_access_link: + Alpha: 1 + Show Axes: false + Show Trail: false + molecular_devices_qpix_420_deck_access_link: + Alpha: 1 + Show Axes: false + Show Trail: false + molecular_devices_qpix_420_device_link: + Alpha: 1 + Show Axes: false + Show Trail: false + molecular_devices_qpix_420_front_door_access_link: + Alpha: 1 + Show Axes: false + Show Trail: false + molecular_devices_qpix_420_imaging_access_link: + Alpha: 1 + Show Axes: false + Show Trail: false + molecular_devices_qpix_420_loading_access_link: + Alpha: 1 + Show Axes: false + Show Trail: false + molecular_devices_qpix_420_rear_service_access_link: + Alpha: 1 + Show Axes: false + Show Trail: false + qiagen_qiacube_connect_base_link: + Alpha: 1 + Show Axes: false + Show Trail: false + qiagen_qiacube_connect_body_link: + Alpha: 1 + Show Axes: false + Show Trail: false + Value: true + qiagen_qiacube_connect_device_link: + Alpha: 1 + Show Axes: false + Show Trail: false + qiagen_qiacube_connect_hood_handle_access_link: + Alpha: 1 + Show Axes: false + Show Trail: false + qiagen_qiacube_connect_loading_access_link: + Alpha: 1 + Show Axes: false + Show Trail: false + qiagen_qiacube_connect_rear_service_access_link: + Alpha: 1 + Show Axes: false + Show Trail: false + qiagen_qiacube_connect_touchscreen_access_link: + Alpha: 1 + Show Axes: false + Show Trail: false + qiagen_qiacube_connect_waste_drawer_access_link: + Alpha: 1 + Show Axes: false + Show Trail: false + qiagen_qiacube_connect_waste_drawer_link: + Alpha: 1 + Show Axes: false + Show Trail: false + Value: true + robotic_arm_SCARA_with_slider_moveit_virtual_arm_base: + Alpha: 1 + Show Axes: false + Show Trail: false + Value: true + robotic_arm_SCARA_with_slider_moveit_virtual_arm_link_1: + Alpha: 1 + Show Axes: false + Show Trail: false + Value: true + robotic_arm_SCARA_with_slider_moveit_virtual_arm_link_2: + Alpha: 1 + Show Axes: false + Show Trail: false + Value: true + robotic_arm_SCARA_with_slider_moveit_virtual_arm_link_3: + Alpha: 1 + Show Axes: false + Show Trail: false + Value: true + robotic_arm_SCARA_with_slider_moveit_virtual_arm_slideway: + Alpha: 1 + Show Axes: false + Show Trail: false + Value: true + robotic_arm_SCARA_with_slider_moveit_virtual_copy_arm_base: + Alpha: 1 + Show Axes: false + Show Trail: false + Value: true + robotic_arm_SCARA_with_slider_moveit_virtual_copy_arm_link_1: + Alpha: 1 + Show Axes: false + Show Trail: false + Value: true + robotic_arm_SCARA_with_slider_moveit_virtual_copy_arm_link_2: + Alpha: 1 + Show Axes: false + Show Trail: false + Value: true + robotic_arm_SCARA_with_slider_moveit_virtual_copy_arm_link_3: + Alpha: 1 + Show Axes: false + Show Trail: false + Value: true + robotic_arm_SCARA_with_slider_moveit_virtual_copy_arm_slideway: + Alpha: 1 + Show Axes: false + Show Trail: false + Value: true + robotic_arm_SCARA_with_slider_moveit_virtual_copy_device_link: + Alpha: 1 + Show Axes: false + Show Trail: false + robotic_arm_SCARA_with_slider_moveit_virtual_copy_gripper_base: + Alpha: 1 + Show Axes: false + Show Trail: false + Value: true + robotic_arm_SCARA_with_slider_moveit_virtual_copy_gripper_left: + Alpha: 1 + Show Axes: false + Show Trail: false + Value: true + robotic_arm_SCARA_with_slider_moveit_virtual_copy_gripper_right: + Alpha: 1 + Show Axes: false + Show Trail: false + Value: true + robotic_arm_SCARA_with_slider_moveit_virtual_device_link: + Alpha: 1 + Show Axes: false + Show Trail: false + robotic_arm_SCARA_with_slider_moveit_virtual_gripper_base: + Alpha: 1 + Show Axes: false + Show Trail: false + Value: true + robotic_arm_SCARA_with_slider_moveit_virtual_gripper_left: + Alpha: 1 + Show Axes: false + Show Trail: false + Value: true + robotic_arm_SCARA_with_slider_moveit_virtual_gripper_right: + Alpha: 1 + Show Axes: false + Show Trail: false + Value: true + tecan_resolvex_a200_base_link: + Alpha: 1 + Show Axes: false + Show Trail: false + Value: true + tecan_resolvex_a200_device_link: + Alpha: 1 + Show Axes: false + Show Trail: false + tecan_resolvex_a200_front_loading_access_link: + Alpha: 1 + Show Axes: false + Show Trail: false + tecan_resolvex_a200_rack_access_link: + Alpha: 1 + Show Axes: false + Show Trail: false + tecan_resolvex_a200_rear_service_access_link: + Alpha: 1 + Show Axes: false + Show Trail: false + tecan_resolvex_a200_screen_access_link: + Alpha: 1 + Show Axes: false + Show Trail: false + tecan_resolvex_a200_socketTypeGenericSbsFootprint: + Alpha: 1 + Show Axes: false + Show Trail: false + telesis_bio_bioxp_3250_base_link: + Alpha: 1 + Show Axes: false + Show Trail: false + Value: true + telesis_bio_bioxp_3250_device_link: + Alpha: 1 + Show Axes: false + Show Trail: false + telesis_bio_bioxp_3250_front_loading_access_link: + Alpha: 1 + Show Axes: false + Show Trail: false + telesis_bio_bioxp_3250_touchscreen_access_link: + Alpha: 1 + Show Axes: false + Show Trail: false + vantage_backend_base_link: + Alpha: 1 + Show Axes: false + Show Trail: false + Value: true + vantage_backend_deck_access_link: + Alpha: 1 + Show Axes: false + Show Trail: false + vantage_backend_device_link: + Alpha: 1 + Show Axes: false + Show Trail: false + vantage_backend_front_loading_access_link: + Alpha: 1 + Show Axes: false + Show Trail: false + vantage_backend_front_service_access_link: + Alpha: 1 + Show Axes: false + Show Trail: false + vantage_backend_loading_tray_access_link: + Alpha: 1 + Show Axes: false + Show Trail: false + vantage_backend_loading_tray_link: + Alpha: 1 + Show Axes: false + Show Trail: false + Value: true + vantage_backend_logistics_cabinet_access_link: + Alpha: 1 + Show Axes: false + Show Trail: false + vantage_backend_rear_service_access_link: + Alpha: 1 + Show Axes: false + Show Trail: false + vantage_backend_socketTypeGenericSbsFootprint: + Alpha: 1 + Show Axes: false + Show Trail: false + world: + Alpha: 1 + Show Axes: false + Show Trail: false + Robot Alpha: 1 + Show Robot Collision: false + Show Robot Visual: false + Value: true + - Attached Body Color: 150; 50; 150 + Class: moveit_rviz_plugin/RobotState + Collision Enabled: false + Enabled: false + Links: + All Links Enabled: true + Expand Joint Details: false + Expand Link Details: false + Expand Tree: false + Link Tree Style: Links in Alphabetic Order + Name: RobotState + Robot Alpha: 1 + Robot Description: robot_description + Robot State Topic: display_robot_state + Show All Links: true + Show Highlights: true + Value: false + Visual Enabled: true + - Acceleration_Scaling_Factor: 0.1 + Class: moveit_rviz_plugin/MotionPlanning + Enabled: true + Move Group Namespace: "" + MoveIt_Allow_Approximate_IK: false + MoveIt_Allow_External_Program: false + MoveIt_Allow_Replanning: false + MoveIt_Allow_Sensor_Positioning: false + MoveIt_Planning_Attempts: 10 + MoveIt_Planning_Time: 5 + MoveIt_Use_Cartesian_Path: false + MoveIt_Use_Constraint_Aware_IK: false + MoveIt_Workspace: + Center: + X: 0 + Y: 0 + Z: 0 + Size: + X: 2 + Y: 2 + Z: 2 + Name: MotionPlanning + Planned Path: + Color Enabled: false + Interrupt Display: false + Links: + All Links Enabled: true + Expand Joint Details: false + Expand Link Details: false + Expand Tree: false + Link Tree Style: Links in Alphabetic Order + agilent_biotek_406_fx_base_link: + Alpha: 1 + Show Axes: false + Show Trail: false + Value: true + agilent_biotek_406_fx_device_link: + Alpha: 1 + Show Axes: false + Show Trail: false + agilent_biotek_406_fx_left_plate_access_link: + Alpha: 1 + Show Axes: false + Show Trail: false + agilent_biotek_406_fx_plate_carrier_link: + Alpha: 1 + Show Axes: false + Show Trail: false + Value: true + agilent_biotek_406_fx_right_plate_access_link: + Alpha: 1 + Show Axes: false + Show Trail: false + agilent_biotek_406_fx_socketTypeGenericSbsFootprint: + Alpha: 1 + Show Axes: false + Show Trail: false + applied_biosystems_seqstudio_genetic_analyzer_base_link: + Alpha: 1 + Show Axes: false + Show Trail: false + Value: true + applied_biosystems_seqstudio_genetic_analyzer_cartridge_access_link: + Alpha: 1 + Show Axes: false + Show Trail: false + applied_biosystems_seqstudio_genetic_analyzer_device_link: + Alpha: 1 + Show Axes: false + Show Trail: false + applied_biosystems_seqstudio_genetic_analyzer_front_loading_access_link: + Alpha: 1 + Show Axes: false + Show Trail: false + applied_biosystems_seqstudio_genetic_analyzer_sample_plate_access_link: + Alpha: 1 + Show Axes: false + Show Trail: false + applied_biosystems_seqstudio_genetic_analyzer_screen_access_link: + Alpha: 1 + Show Axes: false + Show Trail: false + applied_biosystems_seqstudio_genetic_analyzer_socketTypeGenericSbsFootprint: + Alpha: 1 + Show Axes: false + Show Trail: false + bd_facsmelody_base_link: + Alpha: 1 + Show Axes: false + Show Trail: false + Value: true + bd_facsmelody_device_link: + Alpha: 1 + Show Axes: false + Show Trail: false + bd_facsmelody_flow_cell_access_link: + Alpha: 1 + Show Axes: false + Show Trail: false + bd_facsmelody_rear_service_access_link: + Alpha: 1 + Show Axes: false + Show Trail: false + bd_facsmelody_sample_loading_access_link: + Alpha: 1 + Show Axes: false + Show Trail: false + bd_facsmelody_sort_output_access_link: + Alpha: 1 + Show Axes: false + Show Trail: false + cytiva_akta_pure_base_link: + Alpha: 1 + Show Axes: false + Show Trail: false + Value: true + cytiva_akta_pure_device_link: + Alpha: 1 + Show Axes: false + Show Trail: false + cytiva_akta_pure_front_access_link: + Alpha: 1 + Show Axes: false + Show Trail: false + cytiva_akta_pure_rear_service_access_link: + Alpha: 1 + Show Axes: false + Show Trail: false + cytiva_biacore_8k_plus_base_link: + Alpha: 1 + Show Axes: false + Show Trail: false + Value: true + cytiva_biacore_8k_plus_device_link: + Alpha: 1 + Show Axes: false + Show Trail: false + cytiva_biacore_8k_plus_sample_compartment_access_link: + Alpha: 1 + Show Axes: false + Show Trail: false + cytiva_biacore_8k_plus_sample_hotel_access_link: + Alpha: 1 + Show Axes: false + Show Trail: false + cytiva_biacore_8k_plus_sensor_chip_access_link: + Alpha: 1 + Show Axes: false + Show Trail: false + cytomat_backend_base_link: + Alpha: 1 + Show Axes: false + Show Trail: false + Value: true + cytomat_backend_device_link: + Alpha: 1 + Show Axes: false + Show Trail: false + cytomat_backend_loading_tray_access_link: + Alpha: 1 + Show Axes: false + Show Trail: false + cytomat_backend_loading_tray_link: + Alpha: 1 + Show Axes: false + Show Trail: false + Value: true + cytomat_backend_transfer_opening_access_link: + Alpha: 1 + Show Axes: false + Show Trail: false + hettich_rotanta_460_robotic_base_link: + Alpha: 1 + Show Axes: false + Show Trail: false + Value: true + hettich_rotanta_460_robotic_device_link: + Alpha: 1 + Show Axes: false + Show Trail: false + hettich_rotanta_460_robotic_front_lid_hatch_access_link: + Alpha: 1 + Show Axes: false + Show Trail: false + hettich_rotanta_460_robotic_rear_lid_hatch_access_link: + Alpha: 1 + Show Axes: false + Show Trail: false + hettich_rotanta_460_robotic_rear_service_access_link: + Alpha: 1 + Show Axes: false + Show Trail: false + hettich_rotanta_460_robotic_top_loading_access_link: + Alpha: 1 + Show Axes: false + Show Trail: false + molecular_devices_qpix_420_base_link: + Alpha: 1 + Show Axes: false + Show Trail: false + Value: true + molecular_devices_qpix_420_control_access_link: + Alpha: 1 + Show Axes: false + Show Trail: false + molecular_devices_qpix_420_deck_access_link: + Alpha: 1 + Show Axes: false + Show Trail: false + molecular_devices_qpix_420_device_link: + Alpha: 1 + Show Axes: false + Show Trail: false + molecular_devices_qpix_420_front_door_access_link: + Alpha: 1 + Show Axes: false + Show Trail: false + molecular_devices_qpix_420_imaging_access_link: + Alpha: 1 + Show Axes: false + Show Trail: false + molecular_devices_qpix_420_loading_access_link: + Alpha: 1 + Show Axes: false + Show Trail: false + molecular_devices_qpix_420_rear_service_access_link: + Alpha: 1 + Show Axes: false + Show Trail: false + qiagen_qiacube_connect_base_link: + Alpha: 1 + Show Axes: false + Show Trail: false + qiagen_qiacube_connect_body_link: + Alpha: 1 + Show Axes: false + Show Trail: false + Value: true + qiagen_qiacube_connect_device_link: + Alpha: 1 + Show Axes: false + Show Trail: false + qiagen_qiacube_connect_hood_handle_access_link: + Alpha: 1 + Show Axes: false + Show Trail: false + qiagen_qiacube_connect_loading_access_link: + Alpha: 1 + Show Axes: false + Show Trail: false + qiagen_qiacube_connect_rear_service_access_link: + Alpha: 1 + Show Axes: false + Show Trail: false + qiagen_qiacube_connect_touchscreen_access_link: + Alpha: 1 + Show Axes: false + Show Trail: false + qiagen_qiacube_connect_waste_drawer_access_link: + Alpha: 1 + Show Axes: false + Show Trail: false + qiagen_qiacube_connect_waste_drawer_link: + Alpha: 1 + Show Axes: false + Show Trail: false + Value: true + robotic_arm_SCARA_with_slider_moveit_virtual_arm_base: + Alpha: 1 + Show Axes: false + Show Trail: false + Value: true + robotic_arm_SCARA_with_slider_moveit_virtual_arm_link_1: + Alpha: 1 + Show Axes: false + Show Trail: false + Value: true + robotic_arm_SCARA_with_slider_moveit_virtual_arm_link_2: + Alpha: 1 + Show Axes: false + Show Trail: false + Value: true + robotic_arm_SCARA_with_slider_moveit_virtual_arm_link_3: + Alpha: 1 + Show Axes: false + Show Trail: false + Value: true + robotic_arm_SCARA_with_slider_moveit_virtual_arm_slideway: + Alpha: 1 + Show Axes: false + Show Trail: false + Value: true + robotic_arm_SCARA_with_slider_moveit_virtual_copy_arm_base: + Alpha: 1 + Show Axes: false + Show Trail: false + Value: true + robotic_arm_SCARA_with_slider_moveit_virtual_copy_arm_link_1: + Alpha: 1 + Show Axes: false + Show Trail: false + Value: true + robotic_arm_SCARA_with_slider_moveit_virtual_copy_arm_link_2: + Alpha: 1 + Show Axes: false + Show Trail: false + Value: true + robotic_arm_SCARA_with_slider_moveit_virtual_copy_arm_link_3: + Alpha: 1 + Show Axes: false + Show Trail: false + Value: true + robotic_arm_SCARA_with_slider_moveit_virtual_copy_arm_slideway: + Alpha: 1 + Show Axes: false + Show Trail: false + Value: true + robotic_arm_SCARA_with_slider_moveit_virtual_copy_device_link: + Alpha: 1 + Show Axes: false + Show Trail: false + robotic_arm_SCARA_with_slider_moveit_virtual_copy_gripper_base: + Alpha: 1 + Show Axes: false + Show Trail: false + Value: true + robotic_arm_SCARA_with_slider_moveit_virtual_copy_gripper_left: + Alpha: 1 + Show Axes: false + Show Trail: false + Value: true + robotic_arm_SCARA_with_slider_moveit_virtual_copy_gripper_right: + Alpha: 1 + Show Axes: false + Show Trail: false + Value: true + robotic_arm_SCARA_with_slider_moveit_virtual_device_link: + Alpha: 1 + Show Axes: false + Show Trail: false + robotic_arm_SCARA_with_slider_moveit_virtual_gripper_base: + Alpha: 1 + Show Axes: false + Show Trail: false + Value: true + robotic_arm_SCARA_with_slider_moveit_virtual_gripper_left: + Alpha: 1 + Show Axes: false + Show Trail: false + Value: true + robotic_arm_SCARA_with_slider_moveit_virtual_gripper_right: + Alpha: 1 + Show Axes: false + Show Trail: false + Value: true + tecan_resolvex_a200_base_link: + Alpha: 1 + Show Axes: false + Show Trail: false + Value: true + tecan_resolvex_a200_device_link: + Alpha: 1 + Show Axes: false + Show Trail: false + tecan_resolvex_a200_front_loading_access_link: + Alpha: 1 + Show Axes: false + Show Trail: false + tecan_resolvex_a200_rack_access_link: + Alpha: 1 + Show Axes: false + Show Trail: false + tecan_resolvex_a200_rear_service_access_link: + Alpha: 1 + Show Axes: false + Show Trail: false + tecan_resolvex_a200_screen_access_link: + Alpha: 1 + Show Axes: false + Show Trail: false + tecan_resolvex_a200_socketTypeGenericSbsFootprint: + Alpha: 1 + Show Axes: false + Show Trail: false + telesis_bio_bioxp_3250_base_link: + Alpha: 1 + Show Axes: false + Show Trail: false + Value: true + telesis_bio_bioxp_3250_device_link: + Alpha: 1 + Show Axes: false + Show Trail: false + telesis_bio_bioxp_3250_front_loading_access_link: + Alpha: 1 + Show Axes: false + Show Trail: false + telesis_bio_bioxp_3250_touchscreen_access_link: + Alpha: 1 + Show Axes: false + Show Trail: false + vantage_backend_base_link: + Alpha: 1 + Show Axes: false + Show Trail: false + Value: true + vantage_backend_deck_access_link: + Alpha: 1 + Show Axes: false + Show Trail: false + vantage_backend_device_link: + Alpha: 1 + Show Axes: false + Show Trail: false + vantage_backend_front_loading_access_link: + Alpha: 1 + Show Axes: false + Show Trail: false + vantage_backend_front_service_access_link: + Alpha: 1 + Show Axes: false + Show Trail: false + vantage_backend_loading_tray_access_link: + Alpha: 1 + Show Axes: false + Show Trail: false + vantage_backend_loading_tray_link: Alpha: 1 Show Axes: false Show Trail: false Value: true - arm_slider_arm_link_2: + vantage_backend_logistics_cabinet_access_link: + Alpha: 1 + Show Axes: false + Show Trail: false + vantage_backend_rear_service_access_link: + Alpha: 1 + Show Axes: false + Show Trail: false + vantage_backend_socketTypeGenericSbsFootprint: + Alpha: 1 + Show Axes: false + Show Trail: false + world: + Alpha: 1 + Show Axes: false + Show Trail: false + Loop Animation: false + Robot Alpha: 0.5 + Robot Color: 150; 50; 150 + Show Robot Collision: false + Show Robot Visual: true + Show Trail: false + State Display Time: 3x + Trail Step Size: 1 + Trajectory Topic: /display_planned_path + Use Sim Time: false + Planning Metrics: + Payload: 1 + Show Joint Torques: false + Show Manipulability: false + Show Manipulability Index: false + Show Weight Limit: false + TextHeight: 0.07999999821186066 + Planning Request: + Colliding Link Color: 255; 0; 0 + Goal State Alpha: 1 + Goal State Color: 250; 128; 0 + Interactive Marker Size: 0 + Joint Violation Color: 255; 0; 255 + Planning Group: robotic_arm_SCARA_with_slider_moveit_virtual_copy_arm + Query Goal State: true + Query Start State: false + Show Workspace: false + Start State Alpha: 1 + Start State Color: 0; 255; 0 + Planning Scene Topic: /monitored_planning_scene + Robot Description: robot_description + Scene Geometry: + Scene Alpha: 0.8999999761581421 + Scene Color: 50; 230; 50 + Scene Display Time: 0.009999999776482582 + Show Scene Geometry: false + Voxel Coloring: Cell Probability + Voxel Rendering: All Voxels + Scene Robot: + Attached Body Color: 150; 50; 150 + Links: + All Links Enabled: true + Expand Joint Details: false + Expand Link Details: false + Expand Tree: false + Link Tree Style: Links in Alphabetic Order + agilent_biotek_406_fx_base_link: Alpha: 1 Show Axes: false Show Trail: false Value: true - arm_slider_arm_link_3: + agilent_biotek_406_fx_device_link: Alpha: 1 Show Axes: false Show Trail: false - Value: true - arm_slider_arm_slideway: + agilent_biotek_406_fx_left_plate_access_link: + Alpha: 1 + Show Axes: false + Show Trail: false + agilent_biotek_406_fx_plate_carrier_link: Alpha: 1 Show Axes: false Show Trail: false Value: true - arm_slider_device_link: + agilent_biotek_406_fx_right_plate_access_link: Alpha: 1 Show Axes: false Show Trail: false - arm_slider_gripper_base: + agilent_biotek_406_fx_socketTypeGenericSbsFootprint: Alpha: 1 Show Axes: false Show Trail: false - Value: true - arm_slider_gripper_left: + applied_biosystems_seqstudio_genetic_analyzer_base_link: Alpha: 1 Show Axes: false Show Trail: false Value: true - arm_slider_gripper_right: + applied_biosystems_seqstudio_genetic_analyzer_cartridge_access_link: Alpha: 1 Show Axes: false Show Trail: false - Value: true - deck_device_link: + applied_biosystems_seqstudio_genetic_analyzer_device_link: Alpha: 1 Show Axes: false Show Trail: false - deck_first_link: + applied_biosystems_seqstudio_genetic_analyzer_front_loading_access_link: Alpha: 1 Show Axes: false Show Trail: false - Value: true - deck_fourth_link: + applied_biosystems_seqstudio_genetic_analyzer_sample_plate_access_link: Alpha: 1 Show Axes: false Show Trail: false - Value: true - deck_main_link: + applied_biosystems_seqstudio_genetic_analyzer_screen_access_link: Alpha: 1 Show Axes: false Show Trail: false - Value: true - deck_second_link: + applied_biosystems_seqstudio_genetic_analyzer_socketTypeGenericSbsFootprint: + Alpha: 1 + Show Axes: false + Show Trail: false + bd_facsmelody_base_link: Alpha: 1 Show Axes: false Show Trail: false Value: true - deck_socketTypeGenericSbsFootprint: + bd_facsmelody_device_link: Alpha: 1 Show Axes: false Show Trail: false - deck_socketTypeHEPAModule: + bd_facsmelody_flow_cell_access_link: Alpha: 1 Show Axes: false Show Trail: false - deck_third_link: + bd_facsmelody_rear_service_access_link: Alpha: 1 Show Axes: false Show Trail: false - Value: true - hotel_base_link: + bd_facsmelody_sample_loading_access_link: + Alpha: 1 + Show Axes: false + Show Trail: false + bd_facsmelody_sort_output_access_link: + Alpha: 1 + Show Axes: false + Show Trail: false + cytiva_akta_pure_base_link: Alpha: 1 Show Axes: false Show Trail: false Value: true - hotel_device_link: + cytiva_akta_pure_device_link: Alpha: 1 Show Axes: false Show Trail: false - hotel_socketTypeGenericSbsFootprint: + cytiva_akta_pure_front_access_link: Alpha: 1 Show Axes: false Show Trail: false - world: + cytiva_akta_pure_rear_service_access_link: Alpha: 1 Show Axes: false Show Trail: false - Robot Alpha: 1 - Show Robot Collision: false - Show Robot Visual: false - Value: true - - Attached Body Color: 150; 50; 150 - Class: moveit_rviz_plugin/RobotState - Collision Enabled: false - Enabled: false - Links: - All Links Enabled: true - Expand Joint Details: false - Expand Link Details: false - Expand Tree: false - Link Tree Style: Links in Alphabetic Order - Name: RobotState - Robot Alpha: 1 - Robot Description: robot_description - Robot State Topic: display_robot_state - Show All Links: true - Show Highlights: true - Value: false - Visual Enabled: true - - Acceleration_Scaling_Factor: 0.1 - Class: moveit_rviz_plugin/MotionPlanning - Enabled: true - Move Group Namespace: "" - MoveIt_Allow_Approximate_IK: false - MoveIt_Allow_External_Program: false - MoveIt_Allow_Replanning: false - MoveIt_Allow_Sensor_Positioning: false - MoveIt_Planning_Attempts: 10 - MoveIt_Planning_Time: 5 - MoveIt_Use_Cartesian_Path: false - MoveIt_Use_Constraint_Aware_IK: false - MoveIt_Workspace: - Center: - X: 0 - Y: 0 - Z: 0 - Size: - X: 2 - Y: 2 - Z: 2 - Name: MotionPlanning - Planned Path: - Color Enabled: false - Interrupt Display: false - Links: - All Links Enabled: true - Expand Joint Details: false - Expand Link Details: false - Expand Tree: false - Link Tree Style: Links in Alphabetic Order - arm_slider_arm_base: + cytiva_biacore_8k_plus_base_link: Alpha: 1 Show Axes: false Show Trail: false Value: true - arm_slider_arm_link_1: + cytiva_biacore_8k_plus_device_link: Alpha: 1 Show Axes: false Show Trail: false - Value: true - arm_slider_arm_link_2: + cytiva_biacore_8k_plus_sample_compartment_access_link: Alpha: 1 Show Axes: false Show Trail: false - Value: true - arm_slider_arm_link_3: + cytiva_biacore_8k_plus_sample_hotel_access_link: Alpha: 1 Show Axes: false Show Trail: false - Value: true - arm_slider_arm_slideway: + cytiva_biacore_8k_plus_sensor_chip_access_link: + Alpha: 1 + Show Axes: false + Show Trail: false + cytomat_backend_base_link: Alpha: 1 Show Axes: false Show Trail: false Value: true - arm_slider_device_link: + cytomat_backend_device_link: Alpha: 1 Show Axes: false Show Trail: false - arm_slider_gripper_base: + cytomat_backend_loading_tray_access_link: Alpha: 1 Show Axes: false Show Trail: false - Value: true - arm_slider_gripper_left: + cytomat_backend_loading_tray_link: Alpha: 1 Show Axes: false Show Trail: false Value: true - arm_slider_gripper_right: + cytomat_backend_transfer_opening_access_link: + Alpha: 1 + Show Axes: false + Show Trail: false + hettich_rotanta_460_robotic_base_link: Alpha: 1 Show Axes: false Show Trail: false Value: true - deck_device_link: + hettich_rotanta_460_robotic_device_link: Alpha: 1 Show Axes: false Show Trail: false - deck_first_link: + hettich_rotanta_460_robotic_front_lid_hatch_access_link: Alpha: 1 Show Axes: false Show Trail: false - Value: true - deck_fourth_link: + hettich_rotanta_460_robotic_rear_lid_hatch_access_link: Alpha: 1 Show Axes: false Show Trail: false - Value: true - deck_main_link: + hettich_rotanta_460_robotic_rear_service_access_link: + Alpha: 1 + Show Axes: false + Show Trail: false + hettich_rotanta_460_robotic_top_loading_access_link: + Alpha: 1 + Show Axes: false + Show Trail: false + molecular_devices_qpix_420_base_link: Alpha: 1 Show Axes: false Show Trail: false Value: true - deck_second_link: + molecular_devices_qpix_420_control_access_link: + Alpha: 1 + Show Axes: false + Show Trail: false + molecular_devices_qpix_420_deck_access_link: + Alpha: 1 + Show Axes: false + Show Trail: false + molecular_devices_qpix_420_device_link: + Alpha: 1 + Show Axes: false + Show Trail: false + molecular_devices_qpix_420_front_door_access_link: + Alpha: 1 + Show Axes: false + Show Trail: false + molecular_devices_qpix_420_imaging_access_link: + Alpha: 1 + Show Axes: false + Show Trail: false + molecular_devices_qpix_420_loading_access_link: + Alpha: 1 + Show Axes: false + Show Trail: false + molecular_devices_qpix_420_rear_service_access_link: + Alpha: 1 + Show Axes: false + Show Trail: false + qiagen_qiacube_connect_base_link: + Alpha: 1 + Show Axes: false + Show Trail: false + qiagen_qiacube_connect_body_link: Alpha: 1 Show Axes: false Show Trail: false Value: true - deck_socketTypeGenericSbsFootprint: + qiagen_qiacube_connect_device_link: + Alpha: 1 + Show Axes: false + Show Trail: false + qiagen_qiacube_connect_hood_handle_access_link: + Alpha: 1 + Show Axes: false + Show Trail: false + qiagen_qiacube_connect_loading_access_link: + Alpha: 1 + Show Axes: false + Show Trail: false + qiagen_qiacube_connect_rear_service_access_link: + Alpha: 1 + Show Axes: false + Show Trail: false + qiagen_qiacube_connect_touchscreen_access_link: Alpha: 1 Show Axes: false Show Trail: false - deck_socketTypeHEPAModule: + qiagen_qiacube_connect_waste_drawer_access_link: Alpha: 1 Show Axes: false Show Trail: false - deck_third_link: + qiagen_qiacube_connect_waste_drawer_link: Alpha: 1 Show Axes: false Show Trail: false Value: true - hotel_base_link: + robotic_arm_SCARA_with_slider_moveit_virtual_arm_base: Alpha: 1 Show Axes: false Show Trail: false Value: true - hotel_device_link: + robotic_arm_SCARA_with_slider_moveit_virtual_arm_link_1: Alpha: 1 Show Axes: false Show Trail: false - hotel_socketTypeGenericSbsFootprint: + Value: true + robotic_arm_SCARA_with_slider_moveit_virtual_arm_link_2: Alpha: 1 Show Axes: false Show Trail: false - world: + Value: true + robotic_arm_SCARA_with_slider_moveit_virtual_arm_link_3: Alpha: 1 Show Axes: false Show Trail: false - Loop Animation: false - Robot Alpha: 0.5 - Robot Color: 150; 50; 150 - Show Robot Collision: false - Show Robot Visual: true - Show Trail: false - State Display Time: 3x - Trail Step Size: 1 - Trajectory Topic: /display_planned_path - Use Sim Time: false - Planning Metrics: - Payload: 1 - Show Joint Torques: false - Show Manipulability: false - Show Manipulability Index: false - Show Weight Limit: false - TextHeight: 0.07999999821186066 - Planning Request: - Colliding Link Color: 255; 0; 0 - Goal State Alpha: 1 - Goal State Color: 250; 128; 0 - Interactive Marker Size: 0 - Joint Violation Color: 255; 0; 255 - Planning Group: arm_slider_arm - Query Goal State: false - Query Start State: false - Show Workspace: false - Start State Alpha: 1 - Start State Color: 0; 255; 0 - Planning Scene Topic: /monitored_planning_scene - Robot Description: robot_description - Scene Geometry: - Scene Alpha: 0.8999999761581421 - Scene Color: 50; 230; 50 - Scene Display Time: 0.009999999776482582 - Show Scene Geometry: false - Voxel Coloring: Cell Probability - Voxel Rendering: All Voxels - Scene Robot: - Attached Body Color: 150; 50; 150 - Links: - All Links Enabled: true - Expand Joint Details: false - Expand Link Details: false - Expand Tree: false - Link Tree Style: Links in Alphabetic Order - arm_slider_arm_base: + Value: true + robotic_arm_SCARA_with_slider_moveit_virtual_arm_slideway: + Alpha: 1 + Show Axes: false + Show Trail: false + Value: true + robotic_arm_SCARA_with_slider_moveit_virtual_copy_arm_base: Alpha: 1 Show Axes: false Show Trail: false Value: true - arm_slider_arm_link_1: + robotic_arm_SCARA_with_slider_moveit_virtual_copy_arm_link_1: Alpha: 1 Show Axes: false Show Trail: false Value: true - arm_slider_arm_link_2: + robotic_arm_SCARA_with_slider_moveit_virtual_copy_arm_link_2: Alpha: 1 Show Axes: false Show Trail: false Value: true - arm_slider_arm_link_3: + robotic_arm_SCARA_with_slider_moveit_virtual_copy_arm_link_3: Alpha: 1 Show Axes: false Show Trail: false Value: true - arm_slider_arm_slideway: + robotic_arm_SCARA_with_slider_moveit_virtual_copy_arm_slideway: Alpha: 1 Show Axes: false Show Trail: false Value: true - arm_slider_device_link: + robotic_arm_SCARA_with_slider_moveit_virtual_copy_device_link: Alpha: 1 Show Axes: false Show Trail: false - arm_slider_gripper_base: + robotic_arm_SCARA_with_slider_moveit_virtual_copy_gripper_base: Alpha: 1 Show Axes: false Show Trail: false Value: true - arm_slider_gripper_left: + robotic_arm_SCARA_with_slider_moveit_virtual_copy_gripper_left: Alpha: 1 Show Axes: false Show Trail: false Value: true - arm_slider_gripper_right: + robotic_arm_SCARA_with_slider_moveit_virtual_copy_gripper_right: Alpha: 1 Show Axes: false Show Trail: false Value: true - deck_device_link: + robotic_arm_SCARA_with_slider_moveit_virtual_device_link: + Alpha: 1 + Show Axes: false + Show Trail: false + robotic_arm_SCARA_with_slider_moveit_virtual_gripper_base: Alpha: 1 Show Axes: false Show Trail: false - deck_first_link: + Value: true + robotic_arm_SCARA_with_slider_moveit_virtual_gripper_left: Alpha: 1 Show Axes: false Show Trail: false Value: true - deck_fourth_link: + robotic_arm_SCARA_with_slider_moveit_virtual_gripper_right: Alpha: 1 Show Axes: false Show Trail: false Value: true - deck_main_link: + tecan_resolvex_a200_base_link: Alpha: 1 Show Axes: false Show Trail: false Value: true - deck_second_link: + tecan_resolvex_a200_device_link: + Alpha: 1 + Show Axes: false + Show Trail: false + tecan_resolvex_a200_front_loading_access_link: + Alpha: 1 + Show Axes: false + Show Trail: false + tecan_resolvex_a200_rack_access_link: + Alpha: 1 + Show Axes: false + Show Trail: false + tecan_resolvex_a200_rear_service_access_link: + Alpha: 1 + Show Axes: false + Show Trail: false + tecan_resolvex_a200_screen_access_link: + Alpha: 1 + Show Axes: false + Show Trail: false + tecan_resolvex_a200_socketTypeGenericSbsFootprint: + Alpha: 1 + Show Axes: false + Show Trail: false + telesis_bio_bioxp_3250_base_link: Alpha: 1 Show Axes: false Show Trail: false Value: true - deck_socketTypeGenericSbsFootprint: + telesis_bio_bioxp_3250_device_link: + Alpha: 1 + Show Axes: false + Show Trail: false + telesis_bio_bioxp_3250_front_loading_access_link: Alpha: 1 Show Axes: false Show Trail: false - deck_socketTypeHEPAModule: + telesis_bio_bioxp_3250_touchscreen_access_link: Alpha: 1 Show Axes: false Show Trail: false - deck_third_link: + vantage_backend_base_link: Alpha: 1 Show Axes: false Show Trail: false Value: true - hotel_base_link: + vantage_backend_deck_access_link: + Alpha: 1 + Show Axes: false + Show Trail: false + vantage_backend_device_link: + Alpha: 1 + Show Axes: false + Show Trail: false + vantage_backend_front_loading_access_link: + Alpha: 1 + Show Axes: false + Show Trail: false + vantage_backend_front_service_access_link: + Alpha: 1 + Show Axes: false + Show Trail: false + vantage_backend_loading_tray_access_link: + Alpha: 1 + Show Axes: false + Show Trail: false + vantage_backend_loading_tray_link: Alpha: 1 Show Axes: false Show Trail: false Value: true - hotel_device_link: + vantage_backend_logistics_cabinet_access_link: + Alpha: 1 + Show Axes: false + Show Trail: false + vantage_backend_rear_service_access_link: Alpha: 1 Show Axes: false Show Trail: false - hotel_socketTypeGenericSbsFootprint: + vantage_backend_socketTypeGenericSbsFootprint: Alpha: 1 Show Axes: false Show Trail: false @@ -551,37 +1506,37 @@ Visualization Manager: Views: Current: Class: rviz_default_plugins/Orbit - Distance: 2.622864246368408 + Distance: 12.818230628967285 Enable Stereo Rendering: Stereo Eye Separation: 0.05999999865889549 Stereo Focal Distance: 1 Swap Stereo Eyes: false Value: false Focal Point: - X: -0.2880733013153076 - Y: -0.16004444658756256 - Z: -0.16730672121047974 + X: 0.6061106324195862 + Y: -1.7148518562316895 + Z: 1.1465555429458618 Focal Shape Fixed Size: true Focal Shape Size: 0.05000000074505806 Invert Z Axis: false Name: Current View Near Clip Distance: 0.009999999776482582 - Pitch: 0.4297958016395569 + Pitch: 0.804795503616333 Target Frame: Value: Orbit (rviz) - Yaw: 0.36756160855293274 + Yaw: 1.3875614404678345 Saved: ~ Window Geometry: Displays: collapsed: false - Height: 1088 + Height: 1210 Hide Left Dock: false Hide Right Dock: true MotionPlanning: collapsed: false MotionPlanning - Trajectory Slider: collapsed: false - QMainWindow State: 000000ff00000000fd0000000400000000000003a30000040bfc020000000bfb0000001200530065006c0065006300740069006f006e00000001e10000009b0000005c00fffffffb0000001e0054006f006f006c002000500072006f007000650072007400690065007302000001ed000001df00000185000000a3fb000000120056006900650077007300200054006f006f02000001df000002110000018500000122fb000000200054006f006f006c002000500072006f0070006500720074006900650073003203000002880000011d000002210000017afb000000100044006900730070006c006100790073010000001700000271000000ca00fffffffb0000002000730065006c0065006300740069006f006e00200062007500660066006500720200000138000000aa0000023a00000294fb00000014005700690064006500530074006500720065006f02000000e6000000d2000003ee0000030bfb0000000c004b0069006e0065006300740200000186000001060000030c00000261fb000000280020002d0020005400720061006a006500630074006f0072007900200053006c00690064006500720000000000ffffffff0000000000000000fb00000044004d006f00740069006f006e0050006c0061006e006e0069006e00670020002d0020005400720061006a006500630074006f0072007900200053006c00690064006500720000000000ffffffff0000004200fffffffb0000001c004d006f00740069006f006e0050006c0061006e006e0069006e0067010000028e000001940000018900ffffff000000010000010f00000387fc0200000003fb0000001e0054006f006f006c002000500072006f00700065007200740069006500730100000041000000780000000000000000fb0000000a00560069006500770073000000003b00000387000000a600fffffffb0000001200530065006c0065006300740069006f006e010000025a000000b200000000000000000000000200000490000000a9fc0100000001fb0000000a00560069006500770073030000004e00000080000002e10000019700000003000004420000003efc0100000002fb0000000800540069006d00650100000000000004420000000000000000fb0000000800540069006d00650100000000000004500000000000000000000004110000040b00000004000000040000000800000008fc0000000100000002000000010000000a0054006f006f006c00730000000000ffffffff0000000000000000 + QMainWindow State: 000000ff00000000fd0000000400000000000003a300000463fc020000000bfb0000001200530065006c0065006300740069006f006e00000001e10000009b000000b100fffffffb0000001e0054006f006f006c002000500072006f007000650072007400690065007302000001ed000001df00000185000000a3fb000000120056006900650077007300200054006f006f02000001df000002110000018500000122fb000000200054006f006f006c002000500072006f0070006500720074006900650073003203000002880000011d000002210000017afb000000100044006900730070006c0061007900730100000028000001840000018400fffffffb0000002000730065006c0065006300740069006f006e00200062007500660066006500720200000138000000aa0000023a00000294fb00000014005700690064006500530074006500720065006f02000000e6000000d2000003ee0000030bfb0000000c004b0069006e0065006300740200000186000001060000030c00000261fb000000280020002d0020005400720061006a006500630074006f0072007900200053006c00690064006500720000000000ffffffff0000000000000000fb00000044004d006f00740069006f006e0050006c0061006e006e0069006e00670020002d0020005400720061006a006500630074006f0072007900200053006c00690064006500720000000000ffffffff0000007c00fffffffb0000001c004d006f00740069006f006e0050006c0061006e006e0069006e006701000001b8000002d3000002c200ffffff000000010000010f00000387fc0200000003fb0000001e0054006f006f006c002000500072006f00700065007200740069006500730100000041000000780000000000000000fb0000000a00560069006500770073000000003b000003870000013500fffffffb0000001200530065006c0065006300740069006f006e010000025a000000b200000000000000000000000200000490000000a9fc0100000001fb0000000a00560069006500770073030000004e00000080000002e10000019700000003000004420000003efc0100000002fb0000000800540069006d00650100000000000004420000000000000000fb0000000800540069006d006501000000000000045000000000000000000000040b0000046300000004000000040000000800000008fc0000000100000002000000010000000a0054006f006f006c00730000000000ffffffff0000000000000000 Selection: collapsed: false Tool Properties: @@ -589,5 +1544,5 @@ Window Geometry: Views: collapsed: true Width: 1978 - X: 70 - Y: 27 + X: 140 + Y: 54 diff --git a/unilabos/devices/_phage_display/access2_backend.py b/unilabos/devices/_phage_display/access2_backend.py new file mode 100644 index 000000000..4e2b95073 --- /dev/null +++ b/unilabos/devices/_phage_display/access2_backend.py @@ -0,0 +1,856 @@ +""" +Auto-generated by Build Agent +Original: pylabrobot/centrifuge/vspin_backend.py + +[UNILAB DEBUG] 此驱动已注入调试日志 +- 每个方法被调用时会打印日志 +- 日志格式: [UNILAB] ClassName.method_name() called +- 查看日志: logging.getLogger("unilab.driver").setLevel(logging.DEBUG) +""" +from unilabos.registry.decorators import action, device +from typing import Dict, Any, Optional +import logging +import warnings as _warnings +_warnings.filterwarnings("ignore", message=r"pkg_resources is deprecated as an API\..*") +import sys as _sys +import types as _types + +# 兼容性:部分上游框架(例如 lantz)在 import-time 强制加载 Qt(PyQt4/PySide1), +# 在 headless/现代 Python 环境中通常不存在这些绑定,会导致导入直接失败。 +# 这里预注入一个最小的 `lantz.utils.qt` shim,避免触发其 Qt loader。 +if "lantz.utils.qt" not in _sys.modules: + _qt_mod = _types.ModuleType("lantz.utils.qt") + + class _DummyQtCore: + class QObject: + def __init__(self, *_args, **_kwargs): + # 兼容 QObject(parent=...) 等调用 + return None + + @staticmethod + def Signal(*_args, **_kwargs): + return object() + + @staticmethod + def Slot(*_args, **_kwargs): + def _decorator(fn): + return fn + + return _decorator + + _qt_mod.QtCore = _DummyQtCore + + class _DummySuperQObject(_DummyQtCore.QObject): + def __new__(cls, *_args, **_kwargs): + return super().__new__(cls) + + def __init__(self, *_args, **_kwargs): + # 允许 Driver(...) 传参,不触发 object.__init__ 的 TypeError + return None + + _qt_mod.MetaQObject = type + _qt_mod.SuperQObject = _DummySuperQObject + _sys.modules["lantz.utils.qt"] = _qt_mod + +# Unilab 驱动调试日志 +_unilab_logger = logging.getLogger("unilab.driver.Access2Backend") +_unilab_logger.setLevel(logging.DEBUG) +if not _unilab_logger.handlers: + _handler = logging.StreamHandler() + _handler.setFormatter(logging.Formatter("[%(asctime)s] %(message)s")) + _unilab_logger.addHandler(_handler) + +# Vendored repo packages: pylabrobot, pylabrobot +import sys as _sys +from pathlib import Path as _Path +_vendor_root = _Path(__file__).resolve().parent / "_vendor" +if _vendor_root.exists() and str(_vendor_root) not in _sys.path: + _sys.path.insert(0, str(_vendor_root)) + +import asyncio +import ctypes +import json +import logging +import math +import os +import time +import warnings +from typing import Optional + +from pylabrobot.io.ftdi import FTDI + +from pylabrobot.centrifuge.backend import CentrifugeBackend, LoaderBackend +from pylabrobot.centrifuge.standard import LoaderNoPlateError + +logger = logging.getLogger(__name__) + + +@device( + id="access2_backend", + category=["Centrifuge"], + description="一款用于实验室自动化系统的微孔板离心机,可对样品板进行装载、卸载、开关门、锁定转篮并执行离心运行。常用于微孔板样品的短时离心、收集液滴和自动化流程中的板级前处理。", + model={ + "type": "device", + "mesh": "access2_backend", + "path": "https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/devices/access2_backend/macro_device.xacro", + }, +) +class Access2Backend(LoaderBackend): + def __init__( + self, + device_id: str, + timeout: int = 60, + ): + print("[UNILAB] Access2Backend.__init__() called", flush=True) + """ + Args: + device_id: The libftdi id for the loader. Find using + `python3 -m pylibftdi.examples.list_devices` + """ + self._device_id = device_id + self.io = FTDI(device_id=device_id) + self.timeout = timeout + + async def _read(self) -> bytes: + _unilab_logger.debug("[UNILAB] Access2Backend._read() called") + x = b"" + r = None + start = time.time() + while r != b"" or x == b"": + r = await self.io.read(1) + x += r + if r == b"": + await asyncio.sleep(0.1) + if x == b"" and (time.time() - start) > self.timeout: + raise TimeoutError("No data received within the specified timeout period") + return x + + @action(auto_prefix=True, description="向设备发送原始控制命令。") + async def send_command(self, command: bytes) -> bytes: + _unilab_logger.debug("[UNILAB] Access2Backend.send_command() called") + logger.debug("[loader] Sending %s", command.hex()) + await self.io.write(command) + return await self._read() + + @action(auto_prefix=True, description="设置并准备设备进入工作状态。") + async def setup(self): + _unilab_logger.debug("[UNILAB] Access2Backend.setup() called") + logger.debug("[loader] setup") + + await self.io.setup() + await self.io.set_baudrate(115384) + + status = await self.get_status() + if not status.startswith(bytes.fromhex("1105")): + raise RuntimeError("Failed to get status") + + await self.send_command(bytes.fromhex("110500030014000072b1")) + await self.send_command(bytes.fromhex("1105000300100000ae71")) + await self.send_command(bytes.fromhex("110500070024040000008000be89")) + await self.send_command(bytes.fromhex("11050007002404008000800063b1")) + await self.send_command(bytes.fromhex("11050007002404000001800089b9")) + await self.send_command(bytes.fromhex("1105000700240400800180005481")) + await self.send_command(bytes.fromhex("110500070024040000024000c6bd")) + await self.send_command(bytes.fromhex("1105000300400000f0bf")) + await self.send_command(bytes.fromhex("1105000a004607000100000000020235bf")) + # await self.send_command(bytes.fromhex("11050003002000006bd4")) + await self.send_command(bytes.fromhex("1105000e00440b00000000000000007041020203c7")) + # await self.send_command(bytes.fromhex("11050003002000006bd4")) + + @action(auto_prefix=True, description="停止当前运行或运动。") + async def stop(self): + _unilab_logger.debug("[UNILAB] Access2Backend.stop() called") + logger.debug("[loader] stop") + await self.io.stop() + + @action(auto_prefix=True, description="序列化当前设备配置或状态。") + def serialize(self): + _unilab_logger.debug("[UNILAB] Access2Backend.serialize() called") + return {"io": self.io.serialize(), "timeout": self.timeout} + + @action(auto_prefix=True, description="获取设备状态。") + async def get_status(self) -> bytes: + _unilab_logger.debug("[UNILAB] Access2Backend.get_status() called") + logger.debug("[loader] get_status") + return await self.send_command(bytes.fromhex("11050003002000006bd4")) + + @action(auto_prefix=True, description="将机构移至停靠位。") + async def park(self): + _unilab_logger.debug("[UNILAB] Access2Backend.park() called") + logger.debug("[loader] park") + await self.send_command(bytes.fromhex("1105000e00440b0000000000410000704103007539")) + + @action(auto_prefix=True, description="关闭装载位或访问机构。") + async def close(self): + _unilab_logger.debug("[UNILAB] Access2Backend.close() called") + logger.debug("[loader] close") + await self.send_command(bytes.fromhex("1105000a00420700010000803f02008c64")) + + @action(auto_prefix=True, description="打开装载位或访问机构。") + async def open(self): + _unilab_logger.debug("[UNILAB] Access2Backend.open() called") + logger.debug("[loader] open") + await self.send_command(bytes.fromhex("1105000a0042070001000080bf0200b73e")) + + @action(auto_prefix=True, description="将微孔板装入离心机。") + async def load(self): + _unilab_logger.debug("[UNILAB] Access2Backend.load() called") + """only tested for 1cm plate, 3mm pickup height""" + logger.debug("[loader] load") + + await self.send_command(bytes.fromhex("1105000a004607000100000000020235bf")) + await self.send_command(bytes.fromhex("1105000e00440b000100004040000020410200a5cb")) + + # laser check + r = await self.send_command(bytes.fromhex("1105000300500000b3dc")) + if r == bytes.fromhex("1105000800510500000300000079f1"): + raise LoaderNoPlateError("no plate found on stage") + + await self.send_command(bytes.fromhex("1105000a00460700018fc2b540020023dc")) + await self.send_command(bytes.fromhex("1105000e00440b000200004040000020410300ee00")) + await self.send_command(bytes.fromhex("1105000a004607000100000000020015fd")) + await self.send_command(bytes.fromhex("1105000e00440b0000000040400000204102007d82")) + + @action(auto_prefix=True, description="将微孔板从离心机中取出。") + async def unload(self): + _unilab_logger.debug("[UNILAB] Access2Backend.unload() called") + """only tested for 1cm plate, 3mm pickup height""" + logger.debug("[loader] unload") + + await self.send_command(bytes.fromhex("1105000a004607000100000000020235bf")) + await self.send_command(bytes.fromhex("1105000e00440b000200004040000020410200dd31")) + + # laser check + r = await self.send_command(bytes.fromhex("1105000300500000b3dc")) + if r == bytes.fromhex("1105000800510500000300000079f1"): + raise LoaderNoPlateError("no plate found in centrifuge") + + await self.send_command(bytes.fromhex("1105000a00460700017b14b6400200d57a")) + await self.send_command(bytes.fromhex("1105000e00440b00010000404000002041030096fa")) + await self.send_command(bytes.fromhex("1105000a004607000100000000020015fd")) + await self.send_command(bytes.fromhex("1105000e00440b00000000000000002041020056be")) + def _ensure_vspin_backend(self): + backend = getattr(self, "_vspin_backend", None) + if backend is None: + backend = VSpinBackend(device_id=self._device_id) + self._vspin_backend = backend + return backend + + @action(auto_prefix=True, description="获取 1 号转篮的余量值。") + def bucket_1_remainder(self) -> int: + return self._ensure_vspin_backend().bucket_1_remainder + + @action(auto_prefix=True, description="将当前位置信息定义为 1 号转篮位置。") + async def set_bucket_1_position_to_current(self) -> None: + return await self._ensure_vspin_backend().set_bucket_1_position_to_current() + + @action(auto_prefix=True, description="获取 1 号转篮位置。") + async def get_bucket_1_position(self) -> int: + return await self._ensure_vspin_backend().get_bucket_1_position() + + @action(auto_prefix=True, description="获取当前位置。") + async def get_position(self) -> int: + return await self._ensure_vspin_backend().get_position() + + @action(auto_prefix=True, description="获取转速计读数。") + async def get_tachometer(self) -> int: + return await self._ensure_vspin_backend().get_tachometer() + + @action(auto_prefix=True, description="获取原点位置。") + async def get_home_position(self) -> int: + return await self._ensure_vspin_backend().get_home_position() + + @action(auto_prefix=True, description="获取转篮是否锁定。") + async def get_bucket_locked(self) -> bool: + return await self._ensure_vspin_backend().get_bucket_locked() + + @action(auto_prefix=True, description="获取门是否打开。") + async def get_door_open(self) -> bool: + return await self._ensure_vspin_backend().get_door_open() + + @action(auto_prefix=True, description="获取门是否锁定。") + async def get_door_locked(self) -> bool: + return await self._ensure_vspin_backend().get_door_locked() + + @action(auto_prefix=True, description="配置并初始化设备。") + async def configure_and_initialize(self): + return await self._ensure_vspin_backend().configure_and_initialize() + + @action(auto_prefix=True, description="设置配置数据。") + async def set_configuration_data(self): + return await self._ensure_vspin_backend().set_configuration_data() + + @action(auto_prefix=True, description="初始化设备。") + async def initialize(self): + return await self._ensure_vspin_backend().initialize() + + @action(auto_prefix=True, description="打开离心机门。") + async def open_door(self): + return await self._ensure_vspin_backend().open_door() + + @action(auto_prefix=True, description="关闭离心机门。") + async def close_door(self): + return await self._ensure_vspin_backend().close_door() + + @action(auto_prefix=True, description="锁定离心机门。") + async def lock_door(self): + return await self._ensure_vspin_backend().lock_door() + + @action(auto_prefix=True, description="解锁离心机门。") + async def unlock_door(self): + return await self._ensure_vspin_backend().unlock_door() + + @action(auto_prefix=True, description="锁定转篮。") + async def lock_bucket(self): + return await self._ensure_vspin_backend().lock_bucket() + + @action(auto_prefix=True, description="解锁转篮。") + async def unlock_bucket(self): + return await self._ensure_vspin_backend().unlock_bucket() + + @action(auto_prefix=True, description="移动到 1 号转篮位置。") + async def go_to_bucket1(self): + return await self._ensure_vspin_backend().go_to_bucket1() + + @action(auto_prefix=True, description="移动到 2 号转篮位置。") + async def go_to_bucket2(self): + return await self._ensure_vspin_backend().go_to_bucket2() + + @action(auto_prefix=True, description="移动到指定位置。") + async def go_to_position(self, position: int): + return await self._ensure_vspin_backend().go_to_position(position) + + @action(auto_prefix=True, description="将离心力数值换算为转速。") + def g_to_rpm(g: float) -> int: + # https://en.wikipedia.org/wiki/Centrifugation#Mathematical_formula + return self._ensure_vspin_backend().g_to_rpm(g) + + @action(auto_prefix=True, description="执行离心运行。") + async def spin( + self, + g: float = 500, + duration: float = 60, + acceleration: float = 0.8, + deceleration: float = 0.8, + ) -> None: + return await self._ensure_vspin_backend().spin(g, duration, acceleration, deceleration) + # await self.send_command(bytes.fromhex("11050003002000006bd4")) + + +_vspin_bucket_calibrations_path = os.path.join( + os.path.expanduser("~"), + ".pylabrobot", + "vspin_bucket_calibrations.json", +) + + +def _load_vspin_calibrations(device_id: str) -> Optional[int]: + if not os.path.exists(_vspin_bucket_calibrations_path): + warnings.warn( + f"No calibration found for VSpin with device id {device_id}. " + "Please set the bucket 1 position using `set_bucket_1_position_to_current` method after setup.", + UserWarning, + ) + return None + with open(_vspin_bucket_calibrations_path, "r") as f: + return json.load(f).get(device_id) # type: ignore + + +def _save_vspin_calibrations(device_id, remainder: int): + if os.path.exists(_vspin_bucket_calibrations_path): + with open(_vspin_bucket_calibrations_path, "r") as f: + data = json.load(f) + else: + data = {} + data[device_id] = remainder + os.makedirs(os.path.dirname(_vspin_bucket_calibrations_path), exist_ok=True) + with open(_vspin_bucket_calibrations_path, "w") as f: + json.dump(data, f) + + +FULL_ROTATION: int = 8000 + + +bucket_1_not_set_error = RuntimeError( + "Bucket 1 position not set. " + "Please rotate the bucket to bucket 1 using VSpinBackend.go_to_position and " + "then calling VSpinBackend.set_bucket_1_position_to_current." +) + + +class VSpinBackend(CentrifugeBackend): + """Backend for the Agilent Centrifuge. + Note that this is not a complete implementation.""" + + def __init__(self, device_id: Optional[str] = None): + """ + Args: + device_id: The libftdi id for the centrifuge. Find using `python -m pylibftdi.examples.list_devices` + """ + self.io = FTDI(device_id=device_id) + self._bucket_1_remainder: Optional[int] = None + # only attempt loading calibration if device_id is not None + # if it is None, we will load it after setup when we can query the device id from the io + if device_id is not None: + self._bucket_1_remainder = _load_vspin_calibrations(device_id) + + async def setup(self): + await self.io.setup() + # TODO: add functionality where if robot has been initialized before nothing needs to happen + for _ in range(3): + await self.configure_and_initialize() + await self._send_command(bytes.fromhex("aa002101ff21")) + await self._send_command(bytes.fromhex("aa002101ff21")) + await self._send_command(bytes.fromhex("aa01132034")) + await self._send_command(bytes.fromhex("aa002102ff22")) + await self._send_command(bytes.fromhex("aa02132035")) + await self._send_command(bytes.fromhex("aa002103ff23")) + await self._send_command(bytes.fromhex("aaff1a142d")) + + await self.io.set_baudrate(57600) + await self.io.set_rts(True) + await self.io.set_dtr(True) + + await self._send_command(bytes.fromhex("aa01121f32")) + for _ in range(8): + await self._send_command(bytes.fromhex("aa0220ff0f30")) + await self._send_command(bytes.fromhex("aa0220df0f10")) + await self._send_command(bytes.fromhex("aa0220df0e0f")) + await self._send_command(bytes.fromhex("aa0220df0c0d")) + await self._send_command(bytes.fromhex("aa0220df0809")) + for _ in range(4): + await self._send_command(bytes.fromhex("aa0226000028")) + await self._send_command(bytes.fromhex("aa02120317")) + for _ in range(5): + await self._send_command(bytes.fromhex("aa0226200048")) + await self._send_command(bytes.fromhex("aa0226000028")) + await self.lock_door() + + await self._send_command(bytes.fromhex("aa0226000028")) + + await self._send_command(bytes.fromhex("aa0117021a")) + await self._send_command(bytes.fromhex("aa01e6c800b00496000f004b00a00f050007")) + await self._send_command(bytes.fromhex("aa0117041c")) + await self._send_command(bytes.fromhex("aa01170119")) + + await self._send_command(bytes.fromhex("aa010b0c")) + await self._send_command(bytes.fromhex("aa010001")) + await self._send_command(bytes.fromhex("aa01e605006400000000003200e80301006e")) + await self._send_command(bytes.fromhex("aa0194b61283000012010000f3")) + await self._send_command(bytes.fromhex("aa01192842")) + + resp = 0x89 + while resp == 0x89: + resp = (await self._get_positions_and_tachometer()).status + + # --- almost the same as go to position --- + await self._send_command(bytes.fromhex("aa0117021a")) + await self._send_command(bytes.fromhex("aa01e6c800b00496000f004b00a00f050007")) + await self._send_command(bytes.fromhex("aa0117041c")) + await self._send_command(bytes.fromhex("aa01170119")) + + await self._send_command(bytes.fromhex("aa010b0c")) + await self._send_command(bytes.fromhex("aa01e6c800b00496000f004b00a00f050007")) + new_position = (0).to_bytes(4, byteorder="little") # arbitrary + # rpm = 600, + # acceleration = 75.09289617486338 + await self._send_command( + bytes.fromhex("aa01d497") + new_position + bytes.fromhex("c3f52800d71a000049") + ) + # ----------------------------------------- + + resp = 0x08 + while resp != 0x09: + resp = (await self._get_positions_and_tachometer()).status + + await self._send_command(bytes.fromhex("aa0117021a")) + + await self.lock_door() + + # If we have not set the calibration yet, load it now. + if self._bucket_1_remainder is None: + device_id = await self.io.get_serial() + self._bucket_1_remainder = _load_vspin_calibrations(device_id) + + @property + def bucket_1_remainder(self) -> int: + if self._bucket_1_remainder is None: + raise bucket_1_not_set_error + return self._bucket_1_remainder + + async def set_bucket_1_position_to_current(self) -> None: + """Set the current position as bucket 1 position and save calibration.""" + current_position = await self.get_position() + device_id = await self.io.get_serial() + remainder = await self.get_home_position() - current_position + self._bucket_1_remainder = current_position % FULL_ROTATION + _save_vspin_calibrations(device_id, remainder) + + async def get_bucket_1_position(self) -> int: + """Get the bucket 1 position based on calibration. + Normally it is the home position minus the remainder (calibration). + The bucket 1 position must be greater than the current position, so we find + the first position greater than the current position by adding full rotations if needed. + """ + if self._bucket_1_remainder is None: + raise bucket_1_not_set_error + home_position = await self.get_home_position() + bucket_1_position_mod_full_rotation = home_position - self.bucket_1_remainder + # first number after current position that matches bucket 1 position mod FULL_ROTATION + current_position = await self.get_position() + bucket_1_position = ( + FULL_ROTATION + * math.floor((current_position - bucket_1_position_mod_full_rotation) / FULL_ROTATION + 1) + + bucket_1_position_mod_full_rotation + ) + return bucket_1_position + + async def stop(self): + await self.configure_and_initialize() + await self.io.stop() + + class _StatusPositionTachometer(ctypes.LittleEndianStructure): + _pack_ = 1 + _fields_ = [ + ("status", ctypes.c_uint8), + ("current_position", ctypes.c_uint32), + ("unknown1", ctypes.c_uint8), + ("tachometer", ctypes.c_int16), + ("unknown2", ctypes.c_uint8), + ("home_position", ctypes.c_uint32), + ("checksum", ctypes.c_uint8), + ] + + async def _get_positions_and_tachometer(self) -> _StatusPositionTachometer: + """Returns 14 bytes + + Example: + 11 22 25 00 00 4f 00 00 18 e0 05 00 00 a4 + ^^ checksum + ^^ ^^ ^^ ^^ home position + ^^ ? (probably binary status objects) + ^^ ^^ tachometer + ^^ ? (probably binary status objects) + ^^ ^^ ^^ ^^ current position + ^^ + - First byte (index 0): + - 11 = 0b0001011 = idle + - 13 = 0b0001101 = unknown + - 08 = 0b0001000 = spinning + - 09 = 0b0001001 = also spinning but different + - 19 = 0b0010011 = unknown + - 88 = 0b1011000 = unknown + - 89 = 0b1011001 = unknown + - 10th to 13th byte (index 9-12) = Homing Position + - Last byte (index 13) = checksum + """ + resp = await self._send_command(bytes.fromhex("aa010e0f")) + if len(resp) == 0: + raise IOError("Empty status from centrifuge") + return VSpinBackend._StatusPositionTachometer.from_buffer_copy(resp) + + async def get_position(self) -> int: + return (await self._get_positions_and_tachometer()).current_position # type: ignore + + async def get_tachometer(self) -> int: + """current speed in rpm""" + tack_to_rpm = -14.69320388 # R^2 = 0.9999 when spinning, but not specific at single-digit RPM + return (await self._get_positions_and_tachometer()).tachometer * tack_to_rpm # type: ignore + + async def get_home_position(self) -> int: + """changes during a run, but the bucket 1 position relative to it does not""" + return (await self._get_positions_and_tachometer()).home_position # type: ignore + + async def _get_status(self): + """ + examples: + - 0080d0015 + - 0080f0015 + """ + + resp = await self._send_command(bytes.fromhex("aa020e10")) + if len(resp) == 0: + raise IOError("Empty status from centrifuge. Is the machine on?") + return resp + + async def get_bucket_locked(self) -> bool: + resp = await self._get_status() + return resp[2] & 0b0001 != 0 # type: ignore + + async def get_door_open(self) -> bool: + resp = await self._get_status() + return resp[2] & 0b0010 != 0 # type: ignore + + async def get_door_locked(self) -> bool: + resp = await self._get_status() + return resp[2] & 0b0100 == 0 # type: ignore + + # Centrifuge communication: read_resp, send + + async def _read_resp(self, timeout: float = 20) -> bytes: + """Read a response from the centrifuge. If the timeout is reached, return the data that has + been read so far.""" + data = b"" + end_byte_found = False + start_time = time.time() + + while True: + chunk = await self.io.read(25) + if chunk: + data += chunk + end_byte_found = data[-1] == 0x0D + if len(chunk) < 25 and end_byte_found: + break + else: + if end_byte_found or time.time() - start_time > timeout: + break + await asyncio.sleep(0.0001) + + logger.debug("Read %s", data.hex()) + return data + + async def _send_command(self, cmd: bytes, read_timeout=0.2) -> bytes: + written = await self.io.write(bytes(cmd)) + + if written != len(cmd): + raise RuntimeError("Failed to write all bytes") + return await self._read_resp(timeout=read_timeout) + + async def configure_and_initialize(self): + await self.set_configuration_data() + await self.initialize() + + async def set_configuration_data(self): + """Set the device configuration data.""" + await self.io.set_latency_timer(16) + await self.io.set_line_property(bits=8, stopbits=1, parity=0) + await self.io.set_flowctrl(0) + await self.io.set_baudrate(19200) + + async def initialize(self): + await self.io.write(b"\x00" * 20) + for i in range(33): + packet = b"\xaa" + bytes([i & 0xFF, 0x0E, 0x0E + (i & 0xFF)]) + b"\x00" * 8 + await self.io.write(packet) + await self._send_command(bytes.fromhex("aaff0f0e")) + + # Centrifuge operations + + async def open_door(self): + if await self.get_door_open(): + return + # used to be: aa022600072f + await self._send_command(bytes.fromhex("aa022600062e")) # same as unlock door + + # we can't tell when the door is fully open, so we just wait a bit + await asyncio.sleep(4) + + async def close_door(self): + if not (await self.get_door_open()): + return + # used to be: aa022600052d + await self._send_command(bytes.fromhex("aa022600042c")) # same as unlock door + # we can't tell when the door is fully closed, so we just wait a bit + await asyncio.sleep(2) + + async def lock_door(self): + if await self.get_door_open(): + raise RuntimeError("Cannot lock door while it is open.") + if await self.get_door_locked(): + return + # used to be aa0226000129 + await self._send_command(bytes.fromhex("aa0226000028")) + + async def unlock_door(self): + if not await self.get_door_locked(): + return + # used to be aa022600052d + await self._send_command(bytes.fromhex("aa022600042c")) # same as close door + + async def lock_bucket(self): + if await self.get_bucket_locked(): + return + await self._send_command(bytes.fromhex("aa022600072f")) + + async def unlock_bucket(self): + if not await self.get_bucket_locked(): + return + await self._send_command(bytes.fromhex("aa022600062e")) # same as open door + + async def go_to_bucket1(self): + await self.go_to_position(await self.get_bucket_1_position()) + + async def go_to_bucket2(self): + await self.go_to_position(await self.get_bucket_1_position() + FULL_ROTATION // 2) + + async def go_to_position(self, position: int): + await self.close_door() + await self.lock_door() + + position_bytes = position.to_bytes(4, byteorder="little") + byte_string = bytes.fromhex("aa01d497") + position_bytes + bytes.fromhex("c3f52800d71a0000") + sum_byte = (sum(byte_string) - 0xAA) & 0xFF + byte_string += sum_byte.to_bytes(1, byteorder="little") + await self._send_command(bytes.fromhex("aa0226000028")) + await self._send_command(bytes.fromhex("aa0117021a")) + await self._send_command(bytes.fromhex("aa01e6c800b00496000f004b00a00f050007")) + await self._send_command(bytes.fromhex("aa0117041c")) + await self._send_command(bytes.fromhex("aa01170119")) + await self._send_command(bytes.fromhex("aa010b0c")) + await self._send_command(bytes.fromhex("aa01e6c800b00496000f004b00a00f050007")) + await self._send_command(byte_string) + + # await self._send_command(bytes.fromhex("aa0117021a")) + while ( + abs(await self.get_position() - position) > 10 + ): # 10 tacks tolerance (10/8000 * 360 = 0.45 degrees) + await asyncio.sleep(0.1) + await self.open_door() + + @staticmethod + def g_to_rpm(g: float) -> int: + # https://en.wikipedia.org/wiki/Centrifugation#Mathematical_formula + r = 10 + rpm = int((g / (1.118 * 10**-5 * r)) ** 0.5) + return rpm + + async def spin( + self, + g: float = 500, + duration: float = 60, + acceleration: float = 0.8, + deceleration: float = 0.8, + ) -> None: + """Start a spin cycle. spin spin spin spin + + Args: + g: relative centrifugal force, also known as g-force + duration: time in seconds spent at speed (g) + acceleration: 0-1 of total acceleration + deceleration: 0-1 of total deceleration + """ + + if acceleration <= 0 or acceleration > 1: + raise ValueError("Acceleration must be within 0-1.") + if deceleration <= 0 or deceleration > 1: + raise ValueError("Deceleration must be within 0-1.") + if g < 1 or g > 1000: + raise ValueError("G-force must be within 1-1000") + if duration < 1: + raise ValueError("Spin time must be at least 1 second") + + if await self.get_door_open(): + await self.close_door() + if not await self.get_door_locked(): + await self.lock_door() + if await self.get_bucket_locked(): + await self.unlock_bucket() + + # 1 - compute the final position + rpm = VSpinBackend.g_to_rpm(g) + + # compute the distance traveled during the acceleration period + # distance = 1/2 * v^2 / a. area under 0 to t (triangle). t = a/v_max + # 12903.2 ticks/s^2 is 100% acceleration + acceleration_ticks_per_second2 = 12903.2 * acceleration + rounds_per_second = rpm / 60 + ticks_per_second = rounds_per_second * 8000 + distance_during_acceleration = int(0.5 * (ticks_per_second**2) / acceleration_ticks_per_second2) + + # compute the distance traveled at speed + distance_at_speed = ticks_per_second * duration + + current_position = await self.get_position() + final_position = int(current_position + distance_during_acceleration + distance_at_speed) + + if final_position > 2**32 - 1: + # this is almost 3 hours of spinning at 3000 rpm (max speed), + # so we assume nobody will ever hit this. + raise NotImplementedError( + "We don't know what happens if the destination position exceeds 2^32-1. " + "Please report this issue on discuss.pylabrobot.org." + ) + + # 2 - send "go to position" command with computed final position and rpm + position_b = final_position.to_bytes(4, byteorder="little") + rpm_b = int(rpm * 4473.925).to_bytes(4, byteorder="little") + acceleration_b = int(9.15 * 100 * acceleration).to_bytes(4, byteorder="little") + + byte_string = bytes.fromhex("aa01d497") + position_b + rpm_b + acceleration_b + checksum = (sum(byte_string) - 0xAA) & 0xFF + byte_string += checksum.to_bytes(1, byteorder="little") + + await self._send_command(bytes.fromhex("aa0226000028")) + await self._send_command(bytes.fromhex("aa0117021a")) + await self._send_command(bytes.fromhex("aa01e6c800b00496000f004b00a00f050007")) + await self._send_command(bytes.fromhex("aa0117041c")) + await self._send_command(bytes.fromhex("aa01170119")) + await self._send_command(bytes.fromhex("aa010b0c")) + await self._send_command(bytes.fromhex("aa01e60500640000000000fd00803e01000c")) + + await self._send_command(byte_string) + + # 3 - wait for acceleration to the set rpm + # we also check the position to avoid waiting forever if the speed is not reached (e.g. short spin...) + while await self.get_tachometer() < rpm * 0.95 and await self.get_position() < final_position: + await asyncio.sleep(0.1) + + # 4 - once the speed is reached, compute the position at which to start deceleration + # this is different than computed above, because above we assumed constant acceleration from 0 to rpm. + # however, in reality there is jerk and the acceleration is not constant, so we have to adjust as we go. + # this is what the vendor software does too. + # if we are already past that position, we skip this part. + if await self.get_position() < final_position: + decel_start_position = await self.get_position() + distance_at_speed + + # then wait until we reach that position + while await self.get_position() < decel_start_position: + await asyncio.sleep(0.1) + + # 5 - send deceleration command + await self._send_command(bytes.fromhex("aa01e60500640000000000fd00803e01000c")) + # aa0194b600000000dc02000029: decel at 80 + # aa0194b6000000000a03000058: decel at 85 + # aa0194b61283000012010000f3: used in setup (30%) + decc = int(9.15 * 100 * deceleration).to_bytes(2, byteorder="little") + decel_command = bytes.fromhex("aa0194b600000000") + decc + bytes.fromhex("0000") + decel_command += ((sum(decel_command) - 0xAA) & 0xFF).to_bytes(1, byteorder="little") + await self._send_command(decel_command) + + await asyncio.sleep(2) + + # 6 - reset position back to 0ish + # this part is aneeded because otherwise calling go_to_position will not work after + async def _reset_to_zero(): + await self._send_command(bytes.fromhex("aa0117021a")) + await self._send_command(bytes.fromhex("aa01e6c800b00496000f004b00a00f050007")) + await self._send_command(bytes.fromhex("aa0117041c")) + await self._send_command(bytes.fromhex("aa01170119")) + await self._send_command(bytes.fromhex("aa010b0c")) + await self._send_command(bytes.fromhex("aa010001")) # set position back to 0 (exactly) + await self._send_command(bytes.fromhex("aa01e605006400000000003200e80301006e")) + await self._send_command(bytes.fromhex("aa0194b61283000012010000f3")) + await self._send_command(bytes.fromhex("aa01192842")) # it starts moving again + + await _reset_to_zero() + + # 7 - wait for home position to change + # go_to_bucket{1,2} does not work until the home position changes + start = await self.get_home_position() + num_tries = 0 + while await self.get_home_position() == start: + await asyncio.sleep(0.1) + num_tries += 1 + if num_tries % 25 == 0: + await _reset_to_zero() + if num_tries > 100: + raise RuntimeError("Home position did not change after spin.") + + +# Deprecated alias with warning # TODO: remove mid May 2025 (giving people 1 month to update) +# https://github.com/PyLabRobot/pylabrobot/issues/466 + + +class VSpin: + def __init__(self, *args, **kwargs): + raise RuntimeError("`VSpin` is deprecated. Please use `VSpinBackend` instead. ") diff --git a/unilabos/devices/_phage_display/bio_shake.py b/unilabos/devices/_phage_display/bio_shake.py new file mode 100644 index 000000000..ae1f71a5a --- /dev/null +++ b/unilabos/devices/_phage_display/bio_shake.py @@ -0,0 +1,358 @@ +""" +Auto-generated by Build Agent +Original: pylabrobot/heating_shaking/bioshake_backend.py + +[UNILAB DEBUG] 此驱动已注入调试日志 +- 每个方法被调用时会打印日志 +- 日志格式: [UNILAB] ClassName.method_name() called +- 查看日志: logging.getLogger("unilab.driver").setLevel(logging.DEBUG) +""" +from unilabos.registry.decorators import action, device +from typing import Dict, Any, Optional +import logging +import warnings as _warnings +_warnings.filterwarnings("ignore", message=r"pkg_resources is deprecated as an API\..*") +import sys as _sys +import types as _types + +# 兼容性:部分上游框架(例如 lantz)在 import-time 强制加载 Qt(PyQt4/PySide1), +# 在 headless/现代 Python 环境中通常不存在这些绑定,会导致导入直接失败。 +# 这里预注入一个最小的 `lantz.utils.qt` shim,避免触发其 Qt loader。 +if "lantz.utils.qt" not in _sys.modules: + _qt_mod = _types.ModuleType("lantz.utils.qt") + + class _DummyQtCore: + class QObject: + def __init__(self, *_args, **_kwargs): + # 兼容 QObject(parent=...) 等调用 + return None + + @staticmethod + def Signal(*_args, **_kwargs): + return object() + + @staticmethod + def Slot(*_args, **_kwargs): + def _decorator(fn): + return fn + + return _decorator + + _qt_mod.QtCore = _DummyQtCore + + class _DummySuperQObject(_DummyQtCore.QObject): + def __new__(cls, *_args, **_kwargs): + return super().__new__(cls) + + def __init__(self, *_args, **_kwargs): + # 允许 Driver(...) 传参,不触发 object.__init__ 的 TypeError + return None + + _qt_mod.MetaQObject = type + _qt_mod.SuperQObject = _DummySuperQObject + _sys.modules["lantz.utils.qt"] = _qt_mod + +# Unilab 驱动调试日志 +_unilab_logger = logging.getLogger("unilab.driver.BioShake") +_unilab_logger.setLevel(logging.DEBUG) +if not _unilab_logger.handlers: + _handler = logging.StreamHandler() + _handler.setFormatter(logging.Formatter("[%(asctime)s] %(message)s")) + _unilab_logger.addHandler(_handler) + +# Vendored repo packages: pylabrobot, pylabrobot +import sys as _sys +from pathlib import Path as _Path +_vendor_root = _Path(__file__).resolve().parent / "_vendor" +if _vendor_root.exists() and str(_vendor_root) not in _sys.path: + _sys.path.insert(0, str(_vendor_root)) + +import asyncio + +from pylabrobot.heating_shaking.backend import HeaterShakerBackend +from pylabrobot.io.serial import Serial +from pylabrobot.machines.backend import MachineBackend + +try: + import serial + + HAS_SERIAL = True +except ImportError as e: + HAS_SERIAL = False + _SERIAL_IMPORT_ERROR = e + + +@device( + id="bio_shake", + category=["Thermomixer", "Microplate Thermoshaker"], + description="这是一种实验室微孔板加热振荡器,可对板式样品进行控温并同时振荡混匀。它常用于样品孵育、反应混合、酶反应、核酸与蛋白相关实验等需要稳定温度和持续摇匀的流程,部分机型还支持锁板与主动冷却功能。", + model={ + "type": "device", + "mesh": "bio_shake", + "path": "https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/devices/bio_shake/macro_device.xacro", + }, +) +class BioShake(HeaterShakerBackend): + def __init__(self, port: str, timeout: int = 60): + print("[UNILAB] BioShake.__init__() called", flush=True) + if not HAS_SERIAL: + raise RuntimeError( + f"pyserial is required for the BioShake module backend. Import error: {_SERIAL_IMPORT_ERROR}" + ) + + self.setup_finished = False + self.port = port + self.timeout = timeout + self.io = Serial( + port=self.port, + baudrate=9600, + bytesize=serial.EIGHTBITS, + parity=serial.PARITY_NONE, + stopbits=serial.STOPBITS_ONE, + write_timeout=10, + timeout=self.timeout, + ) + + async def _send_command(self, cmd: str, delay: float = 0.5, timeout: float = 2): + _unilab_logger.debug("[UNILAB] BioShake._send_command() called") + try: + # Flush serial buffers for a clean start + await self.io.reset_input_buffer() + await self.io.reset_output_buffer() + + # Send the command + await self.io.write((cmd + "\r").encode("ascii")) + await asyncio.sleep(delay) + + # Read and decode the response with a timeout + try: + response = await asyncio.wait_for(self.io.readline(), timeout=timeout) + + except asyncio.TimeoutError: + raise RuntimeError(f"Timed out waiting for response to '{cmd}'") + + decoded = response.decode("ascii", errors="ignore").strip() + + # Parsing the response from the BioShake + + # No response at all + if not decoded: + raise RuntimeError(f"No response for '{cmd}'") + + # Device-specific errors + if decoded.startswith("e"): + raise RuntimeError(f"Device returned error for '{cmd}': '{decoded}'") + + if decoded.startswith("u ->"): + raise NotImplementedError(f"'{cmd}' not supported: '{decoded}'") + + # Standard OK + if decoded.lower().startswith("ok"): + return None + + # All other valid responses (e.g. temperature and remaining time) + return decoded + + except Exception as e: + raise RuntimeError(f"Unexpected error while sending '{cmd}': {type(e).__name__}: {e}") from e + + @action(auto_prefix=True, description="初始化设备并准备运行") + async def setup(self, skip_home: bool = False): + _unilab_logger.debug("[UNILAB] BioShake.setup() called") + await MachineBackend.setup(self) + await self.io.setup() + if not skip_home: + # Reset first before homing it to ensure the device is ready for run + await self.reset() + # Additional seconds until next command can be send after reset + await asyncio.sleep(4) + # Now home the device + await self.home() + + @action(auto_prefix=True, description="停止设备运行") + async def stop(self): + _unilab_logger.debug("[UNILAB] BioShake.stop() called") + await MachineBackend.stop(self) + await self.io.stop() + + @action(auto_prefix=True, description="复位设备") + async def reset(self): + _unilab_logger.debug("[UNILAB] BioShake.reset() called") + # Reset the BioShake if stuck in "e" state + # Flush serial buffers for a clean start + await self.io.reset_input_buffer() + await self.io.reset_output_buffer() + + # Send the command + await self.io.write(("resetDevice\r").encode("ascii")) + + start = asyncio.get_event_loop().time() + max_seconds = 30 # How long a reset typically last + + while True: + # Break the loop if process takes longer than 30 seconds + if asyncio.get_event_loop().time() - start > max_seconds: + raise TimeoutError("Reset did not complete in time") + + try: + # Wait for each line with a timeout + response = await asyncio.wait_for(self.io.readline(), timeout=2) + decoded = response.decode("ascii", errors="ignore").strip() + await asyncio.sleep(0.1) + + if len(decoded) > 0: + # Stop when the final message arrives + if "Initialization complete" in decoded: + break + + except asyncio.TimeoutError: + # Keep polling if nothing arrives within timeout + continue + + @action(auto_prefix=True, description="回到初始位置") + async def home(self): + _unilab_logger.debug("[UNILAB] BioShake.home() called") + # Initialize the BioShake into home position + await self._send_command(cmd="shakeGoHome", delay=5) + + @action(auto_prefix=True, description="以设定速度开始振荡") + async def shake(self, speed: float, acceleration: int = 0): + _unilab_logger.debug("[UNILAB] BioShake.shake() called") + # Check if speed is an integer + if isinstance(speed, float): + if not speed.is_integer(): + raise ValueError(f"Speed must be a whole number, not {speed}") + speed = int(speed) + if not isinstance(speed, int): + raise TypeError( + f"Speed must be an integer or a whole number float, not {type(speed).__name__}" + ) + + # Get the min and max speed of the device to assert speed + min_speed = int(float(await self._send_command(cmd="getShakeMinRpm", delay=0.2))) + max_speed = int(float(await self._send_command(cmd="getShakeMaxRpm", delay=0.2))) + + assert ( + min_speed <= speed <= max_speed + ), f"Speed {speed} RPM is out of range. Allowed range is {min_speed}{max_speed} RPM" + + # Set the speed of the shaker + set_speed_cmd = f"setShakeTargetSpeed{speed}" + await self._send_command(cmd=set_speed_cmd) + + # Check if accel is an integer + if isinstance(acceleration, float): + if not acceleration.is_integer(): # type: ignore[attr-defined] # mypy is retarded + raise ValueError(f"Acceleration must be a whole number, not {acceleration}") + acceleration = int(acceleration) + if not isinstance(acceleration, int): + raise TypeError( + f"Acceleration must be an integer or a whole number float, not {type(acceleration).__name__}" + ) + + # Get the min and max acceleration of the device to check bounds + min_accel = int(float(await self._send_command(cmd="getShakeAccelerationMin", delay=0.2))) + max_accel = int(float(await self._send_command(cmd="getShakeAccelerationMax", delay=0.2))) + + assert ( + min_accel <= acceleration <= max_accel + ), f"Acceleration {acceleration} seconds is out of range. Allowed range is {min_accel}-{max_accel} seconds" + + # Set the acceleration of the shaker + set_accel_cmd = f"setShakeAcceleration{acceleration}" + await self._send_command(cmd=set_accel_cmd, delay=0.2) + + # Send the command to start shaking, either with or without duration + + await self._send_command(cmd="shakeOn", delay=0.2) + + @action(auto_prefix=True, description="停止振荡") + async def stop_shaking(self, deceleration: int = 0): + _unilab_logger.debug("[UNILAB] BioShake.stop_shaking() called") + # Check if decel is an integer + if isinstance(deceleration, float): + if not deceleration.is_integer(): # type: ignore[attr-defined] # mypy is retarded + raise ValueError(f"Deceleration must be a whole number, not {deceleration}") + deceleration = int(deceleration) + if not isinstance(deceleration, int): + raise TypeError( + f"Deceleration must be an integer or a whole number float, not {type(deceleration).__name__}" + ) + + # Get the min and max decel of the device to asset decel + min_decel = int(float(await self._send_command(cmd="getShakeAccelerationMin", delay=0.2))) + max_decel = int(float(await self._send_command(cmd="getShakeAccelerationMax", delay=0.2))) + + assert ( + min_decel <= deceleration <= max_decel + ), f"Deceleration {deceleration} seconds is out of range. Allowed range is {min_decel}-{max_decel} seconds" + + # Set the deceleration of the shaker + set_decel_cmd = f"setShakeAcceleration{deceleration}" + await self._send_command(cmd=set_decel_cmd, delay=0.2) + + # stop shaking + await self._send_command(cmd="shakeOff", delay=0.2) + + @property + @action(auto_prefix=True, description="获取是否支持锁板") + def supports_locking(self) -> bool: + _unilab_logger.debug("[UNILAB] BioShake.supports_locking() called") + return True + + @action(auto_prefix=True, description="锁定样品板") + async def lock_plate(self): + _unilab_logger.debug("[UNILAB] BioShake.lock_plate() called") + await self._send_command(cmd="setElmLockPos", delay=0.3) + + @action(auto_prefix=True, description="解锁样品板") + async def unlock_plate(self): + _unilab_logger.debug("[UNILAB] BioShake.unlock_plate() called") + await self._send_command(cmd="setElmUnlockPos", delay=0.3) + + @property + @action(auto_prefix=True, description="获取是否支持主动冷却") + def supports_active_cooling(self) -> bool: + _unilab_logger.debug("[UNILAB] BioShake.supports_active_cooling() called") + return True + + @action(auto_prefix=True, description="设置目标温度") + async def set_temperature(self, temperature: float): + _unilab_logger.debug("[UNILAB] BioShake.set_temperature() called") + # Get the min and max set points of the device to assert temperature + min_temp = int(float(await self._send_command(cmd="getTempMin", delay=0.2))) + max_temp = int(float(await self._send_command(cmd="getTempMax", delay=0.2))) + + assert ( + min_temp <= temperature <= max_temp + ), f"Temperature {temperature} C is out of range. Allowed range is {min_temp}–{max_temp} C." + + temperature = temperature * 10 + + # Check if temperature is an integer + if isinstance(temperature, float): + if not temperature.is_integer(): + raise ValueError(f"Temperature must be a whole number, not {temperature} (1/10 C)") + temperature = int(temperature) + if not isinstance(temperature, int): + raise TypeError( + f"Temperature must be an integer or a whole number float, not {type(temperature).__name__} (1/10 C)" + ) + + set_temp_cmd = f"setTempTarget{temperature}" + await self._send_command(cmd=set_temp_cmd, delay=0.2) + + # Start temperature control + await self._send_command(cmd="tempOn", delay=0.2) + + @action(auto_prefix=True, description="获取当前温度") + async def get_current_temperature(self) -> float: + _unilab_logger.debug("[UNILAB] BioShake.get_current_temperature() called") + response = await self._send_command(cmd="getTempActual", delay=0.2) + return float(response) + + @action(auto_prefix=True, description="关闭温控") + async def deactivate(self): + _unilab_logger.debug("[UNILAB] BioShake.deactivate() called") + # Stop temperature control + await self._send_command(cmd="tempOff", delay=0.2) diff --git a/unilabos/devices/_phage_display/bio_tek_plate_reader_backend.py b/unilabos/devices/_phage_display/bio_tek_plate_reader_backend.py new file mode 100644 index 000000000..070808a46 --- /dev/null +++ b/unilabos/devices/_phage_display/bio_tek_plate_reader_backend.py @@ -0,0 +1,733 @@ +""" +Auto-generated by Build Agent +Original: pylabrobot/plate_reading/agilent/biotek_backend.py + +[UNILAB DEBUG] 此驱动已注入调试日志 +- 每个方法被调用时会打印日志 +- 日志格式: [UNILAB] ClassName.method_name() called +- 查看日志: logging.getLogger("unilab.driver").setLevel(logging.DEBUG) +""" +from unilabos.registry.decorators import action, device +from typing import Dict, Any, Optional +import logging +import warnings as _warnings +_warnings.filterwarnings("ignore", message=r"pkg_resources is deprecated as an API\..*") +import sys as _sys +import types as _types + +# 兼容性:部分上游框架(例如 lantz)在 import-time 强制加载 Qt(PyQt4/PySide1), +# 在 headless/现代 Python 环境中通常不存在这些绑定,会导致导入直接失败。 +# 这里预注入一个最小的 `lantz.utils.qt` shim,避免触发其 Qt loader。 +if "lantz.utils.qt" not in _sys.modules: + _qt_mod = _types.ModuleType("lantz.utils.qt") + + class _DummyQtCore: + class QObject: + def __init__(self, *_args, **_kwargs): + # 兼容 QObject(parent=...) 等调用 + return None + + @staticmethod + def Signal(*_args, **_kwargs): + return object() + + @staticmethod + def Slot(*_args, **_kwargs): + def _decorator(fn): + return fn + + return _decorator + + _qt_mod.QtCore = _DummyQtCore + + class _DummySuperQObject(_DummyQtCore.QObject): + def __new__(cls, *_args, **_kwargs): + return super().__new__(cls) + + def __init__(self, *_args, **_kwargs): + # 允许 Driver(...) 传参,不触发 object.__init__ 的 TypeError + return None + + _qt_mod.MetaQObject = type + _qt_mod.SuperQObject = _DummySuperQObject + _sys.modules["lantz.utils.qt"] = _qt_mod + +# Unilab 驱动调试日志 +_unilab_logger = logging.getLogger("unilab.driver.BioTekPlateReaderBackend") +_unilab_logger.setLevel(logging.DEBUG) +if not _unilab_logger.handlers: + _handler = logging.StreamHandler() + _handler.setFormatter(logging.Formatter("[%(asctime)s] %(message)s")) + _unilab_logger.addHandler(_handler) + +# Vendored repo packages: pylabrobot, pylabrobot +import sys as _sys +from pathlib import Path as _Path +_vendor_root = _Path(__file__).resolve().parent / "_vendor" +if _vendor_root.exists() and str(_vendor_root) not in _sys.path: + _sys.path.insert(0, str(_vendor_root)) + +import asyncio +import enum +import logging +import time +from typing import Dict, Iterable, List, Optional, Tuple + +from pylabrobot.io.ftdi import FTDI +from pylabrobot.plate_reading.backend import PlateReaderBackend +from pylabrobot.resources import Plate, Well + +logger = logging.getLogger(__name__) + + +@device( + id="bio_tek_plate_reader_backend", + category=["Microplate Reader"], + description="这是一种台式微孔板读板仪,用于对微孔板中的样品进行吸光度、荧光和发光检测。设备通常带有载板抽屉、温度控制和振荡功能,适用于生化分析、细胞实验和高通量筛选等实验室检测工作。", + model={ + "type": "device", + "mesh": "bio_tek_plate_reader_backend", + "path": "https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/devices/bio_tek_plate_reader_backend/macro_device.xacro", + }, +) +class BioTekPlateReaderBackend(PlateReaderBackend): + """Backend for Agilent BioTek plate readers.""" + + def __init__( + self, + timeout: float = 20, + device_id: Optional[str] = None, + ) -> None: + print("[UNILAB] BioTekPlateReaderBackend.__init__() called", flush=True) + super().__init__() + self.timeout = timeout + + self.io = FTDI(device_id=device_id) + + self._version: Optional[str] = None + + self._plate: Optional[Plate] = None + self._shaking = False + self._slow_mode: Optional[bool] = None + + def _non_overlapping_rectangles( + self, + points: Iterable[Tuple[int, int]], + ) -> List[Tuple[int, int, int, int]]: + _unilab_logger.debug("[UNILAB] BioTekPlateReaderBackend._non_overlapping_rectangles() called") + """Find non-overlapping rectangles that cover all given points. + + Example: + >>> points = [ + >>> (1, 1), + >>> (2, 2), (2, 3), (2, 4), + >>> (3, 2), (3, 3), (3, 4), + >>> (4, 2), (4, 3), (4, 4), (4, 5), + >>> (5, 2), (5, 3), (5, 4), (5, 5), + >>> (6, 2), (6, 3), (6, 4), (6, 5), + >>> (7, 2), (7, 3), (7, 4), + >>> ] + >>> non_overlapping_rectangles(points) + [ + (1, 1, 1, 1), + (2, 2, 7, 4), + (4, 5, 6, 5), + ] + """ + + pts = set(points) + rects = [] + + while pts: + # start a rectangle from one arbitrary point + r0, c0 = min(pts) + # expand right + c1 = c0 + while (r0, c1 + 1) in pts: + c1 += 1 + # expand downward as long as entire row segment is filled + r1 = r0 + while all((r1 + 1, c) in pts for c in range(c0, c1 + 1)): + r1 += 1 + + rects.append((r0, c0, r1, c1)) + # remove covered points + for r in range(r0, r1 + 1): + for c in range(c0, c1 + 1): + pts.discard((r, c)) + + rects.sort() + return rects + + @action(auto_prefix=True, description="初始化读板仪并建立通信。") + async def setup(self) -> None: + _unilab_logger.debug("[UNILAB] BioTekPlateReaderBackend.setup() called") + logger.info(f"{self.__class__.__name__} setting up") + + await self.io.setup() + await self.io.usb_reset() + await self.io.set_latency_timer(16) + await self.io.set_baudrate(9600) # 0x38 0x41 + await self.io.set_line_property(8, 2, 0) # 8 data bits, 2 stop bits, no parity + SIO_RTS_CTS_HS = 0x1 << 8 + await self.io.set_flowctrl(SIO_RTS_CTS_HS) + await self.io.set_rts(True) + + # see if we need to adjust baudrate. This appears to be the case sometimes. + try: + self._version = await self.get_firmware_version() + except TimeoutError: + await self.io.set_baudrate(38_461) # 4e c0 + self._version = await self.get_firmware_version() + + self._shaking = False + self._shaking_task: Optional[asyncio.Task] = None + + @action(auto_prefix=True, description="停止设备当前操作。") + async def stop(self) -> None: + _unilab_logger.debug("[UNILAB] BioTekPlateReaderBackend.stop() called") + logger.info(f"{self.__class__.__name__} stopping") + await self.stop_shaking() + await self.io.stop() + + self._slow_mode = None + + @property + @action(auto_prefix=True, description="获取设备版本信息。") + def version(self) -> str: + _unilab_logger.debug("[UNILAB] BioTekPlateReaderBackend.version() called") + if self._version is None: + raise RuntimeError(f"{self.__class__.__name__}: Firmware version is not set") + return self._version + + @property + @action(auto_prefix=True, description="获取吸光度测量波长范围。") + def abs_wavelength_range(self) -> tuple[int, int]: + _unilab_logger.debug("[UNILAB] BioTekPlateReaderBackend.abs_wavelength_range() called") + return (230, 999) + + @property + @action(auto_prefix=True, description="获取焦距高度范围。") + def focal_height_range(self) -> tuple[float, float]: + _unilab_logger.debug("[UNILAB] BioTekPlateReaderBackend.focal_height_range() called") + return (4.5, 13.88) + + @property + @action(auto_prefix=True, description="获取激发波长范围。") + def excitation_range(self) -> tuple[int, int]: + _unilab_logger.debug("[UNILAB] BioTekPlateReaderBackend.excitation_range() called") + return (250, 700) + + @property + @action(auto_prefix=True, description="获取发射波长范围。") + def emission_range(self) -> tuple[int, int]: + _unilab_logger.debug("[UNILAB] BioTekPlateReaderBackend.emission_range() called") + return (250, 700) + + @property + @action(auto_prefix=True, description="获取是否支持加热。") + def supports_heating(self) -> bool: + _unilab_logger.debug("[UNILAB] BioTekPlateReaderBackend.supports_heating() called") + return False + + @property + @action(auto_prefix=True, description="获取是否支持制冷。") + def supports_cooling(self) -> bool: + _unilab_logger.debug("[UNILAB] BioTekPlateReaderBackend.supports_cooling() called") + return False + + @property + @action(auto_prefix=True, description="获取温控范围。") + def temperature_range(self) -> Tuple[Optional[float], Optional[float]]: + _unilab_logger.debug("[UNILAB] BioTekPlateReaderBackend.temperature_range() called") + """Return (min_temp, max_temp). + If cooling is not supported (heating only), min_temp is None. + If heating is not supported (cooling only), max_temp is None. + """ + max_temp = 45.0 if self.supports_heating else None # default BioTek max + min_temp = 4.0 if self.supports_cooling else None # default cooling minimum + return (min_temp, max_temp) + + async def _purge_buffers(self) -> None: + _unilab_logger.debug("[UNILAB] BioTekPlateReaderBackend._purge_buffers() called") + """Purge the RX and TX buffers, as implemented in Gen5.exe""" + for _ in range(6): + await self.io.usb_purge_rx_buffer() + await self.io.usb_purge_tx_buffer() + + async def _read_until(self, terminator: bytes, timeout: Optional[float] = None) -> bytes: + _unilab_logger.debug("[UNILAB] BioTekPlateReaderBackend._read_until() called") + """If timeout is None, use self.timeout""" + if timeout is None: + timeout = self.timeout + x = None + res = b"" + t0 = time.time() + while x != terminator: + x = await self.io.read(1) + res += x + + if time.time() - t0 > timeout: + logger.debug(f"{self.__class__.__name__} received incomplete %s", res) + raise TimeoutError(f"{self.__class__.__name__}: Timeout while waiting for response") + + if x == b"": + await asyncio.sleep(0.01) + + logger.debug(f"{self.__class__.__name__} received %s", res) + return res + + @action(auto_prefix=True, description="向仪器发送原始控制命令。") + async def send_command( + self, + command: str, + parameter: Optional[str] = None, + wait_for_response=True, + timeout: Optional[float] = None, + ) -> Optional[bytes]: + _unilab_logger.debug("[UNILAB] BioTekPlateReaderBackend.send_command() called") + await self._purge_buffers() + + await self.io.write(command.encode()) + logger.debug(f"{self.__class__.__name__} sent %s", command) + response: Optional[bytes] = None + if wait_for_response or parameter is not None: + response = await self._read_until( + b"\x06" if parameter is not None else b"\x03", timeout=timeout + ) + + if parameter is not None: + await self.io.write(parameter.encode()) + logger.debug(f"{self.__class__.__name__} sent %s", parameter) + if wait_for_response: + response = await self._read_until(b"\x03", timeout=timeout) + + return response + + @action(auto_prefix=True, description="获取序列号。") + async def get_serial_number(self) -> str: + _unilab_logger.debug("[UNILAB] BioTekPlateReaderBackend.get_serial_number() called") + resp = await self.send_command("C", timeout=1) + assert resp is not None + return resp[1:].split(b" ")[0].decode() + + @action(auto_prefix=True, description="获取固件版本。") + async def get_firmware_version(self) -> str: + _unilab_logger.debug("[UNILAB] BioTekPlateReaderBackend.get_firmware_version() called") + resp = await self.send_command("e", timeout=1) + assert resp is not None + return " ".join(resp[1:-1].decode().split(" ")[3:4]) + + async def _set_slow_mode(self, slow: bool): + _unilab_logger.debug("[UNILAB] BioTekPlateReaderBackend._set_slow_mode() called") + if self._slow_mode == slow: + return + await self.send_command("&", "S1" if slow else "S0") + self._slow_mode = slow + + @action(auto_prefix=True, description="打开微孔板载板抽屉。") + async def open(self, slow: bool = False): + _unilab_logger.debug("[UNILAB] BioTekPlateReaderBackend.open() called") + await self._set_slow_mode(slow) + return await self.send_command("J") + + @action(auto_prefix=True, description="关闭微孔板载板抽屉。") + async def close(self, plate: Optional[Plate], slow: bool = False): + _unilab_logger.debug("[UNILAB] BioTekPlateReaderBackend.close() called") + # reset cache + self._plate = None + + await self._set_slow_mode(slow) + if plate is not None: + await self.set_plate(plate) + return await self.send_command("A") + + @action(auto_prefix=True, description="使设备运动机构回零。") + async def home(self): + _unilab_logger.debug("[UNILAB] BioTekPlateReaderBackend.home() called") + return await self.send_command("i", "x") + + @action(auto_prefix=True, description="获取当前温度。") + async def get_current_temperature(self) -> float: + _unilab_logger.debug("[UNILAB] BioTekPlateReaderBackend.get_current_temperature() called") + """Get current temperature in degrees Celsius.""" + resp = await self.send_command("h", timeout=1) + assert resp is not None + return int(resp[1:-1]) / 100000 + + @action(auto_prefix=True, description="设置温控温度。") + async def set_temperature(self, temperature: float): + _unilab_logger.debug("[UNILAB] BioTekPlateReaderBackend.set_temperature() called") + """Set temperature in degrees Celsius.""" + if not self.supports_heating and not self.supports_cooling: + raise NotImplementedError(f"{self.__class__.__name__} does not support temperature control.") + + tmin, tmax = self.temperature_range + current_temperature = await self.get_current_temperature() + + if (tmin is not None and temperature < tmin) or (tmax is not None and temperature > tmax): + raise ValueError( + f"{self.__class__.__name__}: " + f"Requested temperature {temperature}°C is outside supported range " + f"{tmin}-{tmax}°C" + ) + if temperature < current_temperature and not self.supports_cooling: + raise ValueError(f"{self.__class__.__name__}: Cooling is not supported.") + if temperature > current_temperature and not self.supports_heating: + raise ValueError(f"{self.__class__.__name__}: Heating is not supported.") + + return await self.send_command("g", f"{int(temperature * 1000):05}") + + @action(auto_prefix=True, description="停止加热或制冷。") + async def stop_heating_or_cooling(self): + _unilab_logger.debug("[UNILAB] BioTekPlateReaderBackend.stop_heating_or_cooling() called") + return await self.send_command("g", "00000") + + def _parse_body(self, body: bytes) -> List[List[Optional[float]]]: + _unilab_logger.debug("[UNILAB] BioTekPlateReaderBackend._parse_body() called") + assert self._plate is not None, "Plate must be set before reading data" + plate = self._plate + start_index = 22 + end_index = body.rindex(b"\r\n") + num_rows = plate.num_items_y + rows = body[start_index:end_index].split(b"\r\n,")[:num_rows] + + parsed_data: Dict[Tuple[int, int], float] = {} + for row in rows: + values = row.split(b",") + grouped_values = [values[i : i + 3] for i in range(0, len(values), 3)] + + for group in grouped_values: + assert len(group) == 3 + row_index = int(group[0].decode()) - 1 # 1-based index in the response + column_index = int(group[1].decode()) - 1 # 1-based index in the response + value = float(group[2].decode()) + parsed_data[(row_index, column_index)] = value + + result: List[List[Optional[float]]] = [ + [None for _ in range(plate.num_items_x)] for _ in range(plate.num_items_y) + ] + for (row_idx, col_idx), value in parsed_data.items(): + result[row_idx][col_idx] = value + return result + + @action(auto_prefix=True, description="定义当前微孔板的几何参数。") + async def set_plate(self, plate: Plate): + _unilab_logger.debug("[UNILAB] BioTekPlateReaderBackend.set_plate() called") + # 08120112207434014351135308559127881422 + # ^^^^ plate size z + # ^^^^^ plate size x + # ^^^^^ plate size y + # ^^^^^ bottom right x + # ^^^^^ top left x + # ^^^^^ bottom right y + # ^^^^^ top left y + # ^^ columns + # ^^ rows + + if plate is self._plate: + return + + rows = plate.num_items_y + columns = plate.num_items_x + + bottom_right_well = plate.get_item(plate.num_items - 1) + assert bottom_right_well.location is not None + bottom_right_well_center = bottom_right_well.location + bottom_right_well.get_anchor( + x="c", y="c" + ) + top_left_well = plate.get_item(0) + assert top_left_well.location is not None + top_left_well_center = top_left_well.location + top_left_well.get_anchor(x="c", y="c") + + plate_size_y = plate.get_size_y() + plate_size_x = plate.get_size_x() + plate_size_z = plate.get_size_z() + if plate.lid is not None: + plate_size_z += plate.lid.get_size_z() - plate.lid.nesting_z_height + + top_left_well_center_y = plate.get_size_y() - top_left_well_center.y # invert y axis + bottom_right_well_center_y = plate.get_size_y() - bottom_right_well_center.y # invert y axis + + cmd = ( + f"{rows:02}" + f"{columns:02}" + f"{int(top_left_well_center_y * 100):05}" + f"{int(bottom_right_well_center_y * 100):05}" + f"{int(top_left_well_center.x * 100):05}" + f"{int(bottom_right_well_center.x * 100):05}" + f"{int(plate_size_y * 100):05}" + f"{int(plate_size_x * 100):05}" + f"{int(plate_size_z * 100):04}" + "\x03" + ) + + resp = await self.send_command("y", cmd, timeout=1) + self._plate = plate + return resp + + def _get_min_max_row_col_tuples( + self, wells: List[Well], plate: Plate + ) -> List[Tuple[int, int, int, int]]: # min_row, min_col, max_row, max_col + # check if all wells are in the same plate + plates = set(well.parent for well in wells) + if len(plates) != 1 or plates.pop() != plate: + raise ValueError("All wells must be in the specified plate") + return self._non_overlapping_rectangles((well.get_row(), well.get_column()) for well in wells) + + @action(auto_prefix=True, description="测量指定孔位的吸光度。") + async def read_absorbance(self, plate: Plate, wells: List[Well], wavelength: int) -> List[Dict]: + _unilab_logger.debug("[UNILAB] BioTekPlateReaderBackend._get_min_max_row_col_tuples() called") + min_abs, max_abs = self.abs_wavelength_range + if not (min_abs <= wavelength <= max_abs): + raise ValueError(f"{self.__class__.__name__}: wavelength must be within {min_abs}-{max_abs}") + + await self.set_plate(plate) + + wavelength_str = str(wavelength).zfill(4) + all_data: List[List[Optional[float]]] = [ + [None for _ in range(plate.num_items_x)] for _ in range(plate.num_items_y) + ] + + for min_row, min_col, max_row, max_col in self._get_min_max_row_col_tuples(wells, plate): + cmd = f"004701{min_row + 1:02}{min_col + 1:02}{max_row + 1:02}{max_col + 1:02}000120010000110010000010600008{wavelength_str}1" + checksum = str(sum(cmd.encode()) % 100).zfill(2) + cmd = cmd + checksum + "\x03" + await self.send_command("D", cmd) + + resp = await self.send_command("O") + assert resp == b"\x060000\x03" + + # read data + body = await self._read_until(b"\x03", timeout=60 * 3) + assert body is not None + parsed_data = self._parse_body(body) + # Merge data + for r in range(plate.num_items_y): + for c in range(plate.num_items_x): + if parsed_data[r][c] is not None: + all_data[r][c] = parsed_data[r][c] + + # Get current temperature + try: + temp = await self.get_current_temperature() + except TimeoutError: + temp = float("nan") + + return [ + { + "wavelength": wavelength, + "data": all_data, + "temperature": temp, + "time": time.time(), + } + ] + + @action(auto_prefix=True, description="测量指定孔位的发光信号。") + async def read_luminescence( + self, plate: Plate, wells: List[Well], focal_height: float, integration_time: float = 1 + ) -> List[Dict]: + _unilab_logger.debug("[UNILAB] BioTekPlateReaderBackend.read_luminescence() called") + min_fh, max_fh = self.focal_height_range + if not (min_fh <= focal_height <= max_fh): + raise ValueError(f"{self.__class__.__name__}: focal height must be within {min_fh}-{max_fh}") + + await self.set_plate(plate) + + cmd = f"3{14220 + int(1000 * focal_height)}\x03" + await self.send_command("t", cmd) + + integration_time_seconds = int(integration_time) + assert 0 <= integration_time_seconds <= 60, "Integration time seconds must be between 0 and 60" + integration_time_milliseconds = integration_time - int(integration_time) + # TODO: I don't know if the multiple of 0.2 is a firmware requirement, but it's what gen5.exe requires. + # round because of floating point precision issues + assert ( + round(integration_time_milliseconds * 10) % 2 == 0 + ), "Integration time milliseconds must be a multiple of 0.2" + integration_time_seconds_s = str(integration_time_seconds * 5).zfill(2) + integration_time_milliseconds_s = str(int(float(integration_time_milliseconds * 50))).zfill(2) + + all_data: List[List[Optional[float]]] = [ + [None for _ in range(plate.num_items_x)] for _ in range(plate.num_items_y) + ] + for min_row, min_col, max_row, max_col in self._get_min_max_row_col_tuples(wells, plate): + cmd = f"008401{min_row + 1:02}{min_col + 1:02}{max_row + 1:02}{max_col + 1:02}000120010000110010000012300{integration_time_seconds_s}{integration_time_milliseconds_s}200200-001000-003000000000000000000013510" # 0812 + checksum = str((sum(cmd.encode()) + 8) % 100).zfill(2) # don't know why +8 + cmd = cmd + checksum + await self.send_command("D", cmd) + + resp = await self.send_command("O") + assert resp == b"\x060000\x03" + + # 2m10s of reading per 1 second of integration time + # allow 60 seconds flat + timeout = 60 + integration_time_seconds * (2 * 60 + 10) + body = await self._read_until(b"\x03", timeout=timeout) + assert body is not None + parsed_data = self._parse_body(body) + # Merge data + for r in range(plate.num_items_y): + for c in range(plate.num_items_x): + if parsed_data[r][c] is not None: + all_data[r][c] = parsed_data[r][c] + + # Get current temperature + try: + temp = await self.get_current_temperature() + except TimeoutError: + temp = float("nan") + + return [ + { + "data": all_data, + "temperature": temp, + "time": time.time(), + } + ] + + @action(auto_prefix=True, description="测量指定孔位的荧光信号。") + async def read_fluorescence( + self, + plate: Plate, + wells: List[Well], + excitation_wavelength: int, + emission_wavelength: int, + focal_height: float, + ) -> List[Dict]: + _unilab_logger.debug("[UNILAB] BioTekPlateReaderBackend.read_fluorescence() called") + min_fh, max_fh = self.focal_height_range + if not (min_fh <= focal_height <= max_fh): + raise ValueError(f"{self.__class__.__name__}: focal height must be within {min_fh}-{max_fh}") + + min_ex, max_ex = self.excitation_range + if not (min_ex <= excitation_wavelength <= max_ex): + raise ValueError( + f"{self.__class__.__name__}: excitation wavelength must be {min_ex}-{max_ex}" + ) + + min_em, max_em = self.emission_range + if not (min_em <= emission_wavelength <= max_em): + raise ValueError(f"{self.__class__.__name__}: emission wavelength must be {min_em}-{max_em}") + + await self.set_plate(plate) + + cmd = f"{614220 + int(1000 * focal_height)}\x03" + await self.send_command("t", cmd) + + excitation_wavelength_str = str(excitation_wavelength).zfill(4) + emission_wavelength_str = str(emission_wavelength).zfill(4) + + all_data: List[List[Optional[float]]] = [ + [None for _ in range(plate.num_items_x)] for _ in range(plate.num_items_y) + ] + for min_row, min_col, max_row, max_col in self._get_min_max_row_col_tuples(wells, plate): + cmd = ( + f"008401{min_row + 1:02}{min_col + 1:02}{max_row + 1:02}{max_col + 1:02}0001200100001100100000135000100200200{excitation_wavelength_str}000" + f"{emission_wavelength_str}000000000000000000210011" + ) + checksum = str((sum(cmd.encode()) + 7) % 100).zfill(2) # don't know why +7 + cmd = cmd + checksum + "\x03" + await self.send_command("D", cmd) + + resp = await self.send_command("O") + assert resp == b"\x060000\x03" + + body = await self._read_until(b"\x03", timeout=60 * 2) + assert body is not None + parsed_data = self._parse_body(body) + # Merge data + for r in range(plate.num_items_y): + for c in range(plate.num_items_x): + if parsed_data[r][c] is not None: + all_data[r][c] = parsed_data[r][c] + + # Get current temperature + try: + temp = await self.get_current_temperature() + except TimeoutError: + temp = float("nan") + + return [ + { + "ex_wavelength": excitation_wavelength, + "em_wavelength": emission_wavelength, + "data": all_data, + "temperature": temp, + "time": time.time(), + } + ] + + async def _abort(self) -> None: + _unilab_logger.debug("[UNILAB] BioTekPlateReaderBackend._abort() called") + await self.send_command("x", wait_for_response=False) + + class ShakeType(enum.IntEnum): + LINEAR = 0 + ORBITAL = 1 + + @action(auto_prefix=True, description="启动微孔板振荡。") + async def shake(self, shake_type: ShakeType, frequency: int) -> None: + _unilab_logger.debug("[UNILAB] BioTekPlateReaderBackend.shake() called") + """Warning: the duration for shaking has to be specified on the machine, and the maximum is + 16 minutes. As a hack, we start shaking for the maximum duration every time as long as stop + is not called. I think the machine might open the door at the end of the 16 minutes and then + move it back in. We have to find a way to shake continuously, which is possible in protocol-mode + with kinetics. + + Args: + frequency: speed, in mm. 360 CPM = 6mm; 410 CPM = 5mm; 493 CPM = 4mm; 567 CPM = 3mm; 731 CPM = 2mm; 1096 CPM = 1mm + """ + + max_duration = 16 * 60 # 16 minutes + self._shaking_started = asyncio.Event() + + async def shake_maximal_duration(): + _unilab_logger.debug("[UNILAB] BioTekPlateReaderBackend.shake_maximal_duration() called") + """This method will start the shaking, but returns immediately after + shaking has started.""" + shake_type_bit = str(shake_type.value) + duration = str(max_duration).zfill(3) + assert 1 <= frequency <= 6, "Frequency must be between 1 and 6" + cmd = f"0033010101010100002000000013{duration}{shake_type_bit}{frequency}01" + checksum = str((sum(cmd.encode()) + 73) % 100).zfill(2) # don't know why +73 + cmd = cmd + checksum + "\x03" + await self.send_command("D", cmd) + + resp = await self.send_command("O") + assert resp == b"\x060000\x03" + + if not self._shaking_started.is_set(): + self._shaking_started.set() + + async def shake_continuous(): + _unilab_logger.debug("[UNILAB] BioTekPlateReaderBackend.shake_continuous() called") + while self._shaking: + await shake_maximal_duration() + + # short sleep allows = frequent checks for fast stopping + seconds_since_start: float = 0 + loop_wait_time = 0.25 + while seconds_since_start < max_duration and self._shaking: + seconds_since_start += loop_wait_time + await asyncio.sleep(loop_wait_time) + + self._shaking = True + self._shaking_task = asyncio.create_task(shake_continuous()) + + await self._shaking_started.wait() + + @action(auto_prefix=True, description="停止微孔板振荡。") + async def stop_shaking(self) -> None: + _unilab_logger.debug("[UNILAB] BioTekPlateReaderBackend.stop_shaking() called") + if self._shaking: + await self._abort() + self._shaking = False + if self._shaking_task is not None: + self._shaking_task.cancel() + try: + await self._shaking_task + except asyncio.CancelledError: + # Task cancellation is expected here; safe to ignore this exception. + pass + self._shaking_task = None diff --git a/unilabos/devices/_phage_display/centrifuge.py b/unilabos/devices/_phage_display/centrifuge.py new file mode 100644 index 000000000..6f6ef2b5f --- /dev/null +++ b/unilabos/devices/_phage_display/centrifuge.py @@ -0,0 +1,363 @@ +""" +Auto-generated by Build Agent +Original: pylabrobot/centrifuge/centrifuge.py + +[UNILAB DEBUG] 此驱动已注入调试日志 +- 每个方法被调用时会打印日志 +- 日志格式: [UNILAB] ClassName.method_name() called +- 查看日志: logging.getLogger("unilab.driver").setLevel(logging.DEBUG) +""" +from unilabos.registry.decorators import action, device +from typing import Dict, Any, Optional +import logging +import warnings as _warnings +_warnings.filterwarnings("ignore", message=r"pkg_resources is deprecated as an API\..*") +import sys as _sys +import types as _types + +# 兼容性:部分上游框架(例如 lantz)在 import-time 强制加载 Qt(PyQt4/PySide1), +# 在 headless/现代 Python 环境中通常不存在这些绑定,会导致导入直接失败。 +# 这里预注入一个最小的 `lantz.utils.qt` shim,避免触发其 Qt loader。 +if "lantz.utils.qt" not in _sys.modules: + _qt_mod = _types.ModuleType("lantz.utils.qt") + + class _DummyQtCore: + class QObject: + def __init__(self, *_args, **_kwargs): + # 兼容 QObject(parent=...) 等调用 + return None + + @staticmethod + def Signal(*_args, **_kwargs): + return object() + + @staticmethod + def Slot(*_args, **_kwargs): + def _decorator(fn): + return fn + + return _decorator + + _qt_mod.QtCore = _DummyQtCore + + class _DummySuperQObject(_DummyQtCore.QObject): + def __new__(cls, *_args, **_kwargs): + return super().__new__(cls) + + def __init__(self, *_args, **_kwargs): + # 允许 Driver(...) 传参,不触发 object.__init__ 的 TypeError + return None + + _qt_mod.MetaQObject = type + _qt_mod.SuperQObject = _DummySuperQObject + _sys.modules["lantz.utils.qt"] = _qt_mod + +# Unilab 驱动调试日志 +_unilab_logger = logging.getLogger("unilab.driver.Centrifuge") +_unilab_logger.setLevel(logging.DEBUG) +if not _unilab_logger.handlers: + _handler = logging.StreamHandler() + _handler.setFormatter(logging.Formatter("[%(asctime)s] %(message)s")) + _unilab_logger.addHandler(_handler) + +# Vendored repo packages: pylabrobot, pylabrobot +import sys as _sys +from pathlib import Path as _Path +_vendor_root = _Path(__file__).resolve().parent / "_vendor" +if _vendor_root.exists() and str(_vendor_root) not in _sys.path: + _sys.path.insert(0, str(_vendor_root)) + +import warnings +from typing import Optional, Tuple + +from pylabrobot.centrifuge.backend import CentrifugeBackend, LoaderBackend +from pylabrobot.centrifuge.standard import ( + BucketHasPlateError, + BucketNoPlateError, + CentrifugeDoorError, + LoaderNoPlateError, + NotAtBucketError, +) +from pylabrobot.machines.machine import Machine +from pylabrobot.resources import Coordinate, Resource, ResourceHolder +from pylabrobot.resources.rotation import Rotation +from pylabrobot.serializer import deserialize + + +@device( + id="centrifuge", + category=["Centrifuge"], + description="实验室离心机利用离心力分离液体样品中的不同组分,常用于样品沉降、相分离和前处理。该设备具备舱门控制、转篮/吊篮位置切换,以及按设定离心力和时间执行离心循环的能力,可用于自动化样品处理流程。", + model={ + "type": "device", + "mesh": "centrifuge", + "path": "https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/devices/centrifuge/macro_device.xacro", + }, +) +class Centrifuge(Machine, Resource): + """The front end for centrifuges.""" + + def __init__( + self, + backend: CentrifugeBackend, + name: str, + size_x: float, + size_y: float, + size_z: float, + rotation: Optional[Rotation] = None, + category: Optional[str] = "centrifuge", + model: Optional[str] = None, + buckets: Optional[Tuple[ResourceHolder, ResourceHolder]] = None, + ) -> None: + print("[UNILAB] Centrifuge.__init__() called", flush=True) + Machine.__init__(self, backend=backend) + Resource.__init__( + self, + name=name, + size_x=size_x, + size_y=size_y, + size_z=size_z, + rotation=rotation, + category=category, + model=model, + ) + self.backend: CentrifugeBackend = backend # fix type + self._door_open = False + self._at_bucket: Optional[ResourceHolder] = None + if buckets is None: + self.bucket1 = ResourceHolder( + name=f"{name}_bucket1", + size_x=127.76, + size_y=85.48, + size_z=0, + child_location=Coordinate.zero(), + ) + self.bucket2 = ResourceHolder( + name=f"{name}_bucket2", + size_x=127.76, + size_y=85.48, + size_z=0, + child_location=Coordinate.zero(), + ) + else: + self.bucket1, self.bucket2 = buckets + # TODO: figure out good locations for this. + self.assign_child_resource(self.bucket1, location=Coordinate.zero()) + self.assign_child_resource(self.bucket2, location=Coordinate.zero()) + + @action(auto_prefix=True, description="打开离心机门。") + async def open_door(self) -> None: + _unilab_logger.debug("[UNILAB] Centrifuge.open_door() called") + await self.backend.open_door() + self._door_open = True + + @action(auto_prefix=True, description="关闭离心机门。") + async def close_door(self) -> None: + _unilab_logger.debug("[UNILAB] Centrifuge.close_door() called") + await self.backend.close_door() + self._door_open = False + + @property + @action(auto_prefix=True, description="获取离心机门是否打开。") + def door_open(self) -> bool: + _unilab_logger.debug("[UNILAB] Centrifuge.door_open() called") + return self._door_open + + @action(auto_prefix=True, description="锁定离心机门。") + async def lock_door(self) -> None: + _unilab_logger.debug("[UNILAB] Centrifuge.lock_door() called") + await self.backend.lock_door() + + @action(auto_prefix=True, description="解锁离心机门。") + async def unlock_door(self) -> None: + _unilab_logger.debug("[UNILAB] Centrifuge.unlock_door() called") + await self.backend.unlock_door() + + @action(auto_prefix=True, description="解锁转篮/吊篮。") + async def unlock_bucket(self) -> None: + _unilab_logger.debug("[UNILAB] Centrifuge.unlock_bucket() called") + await self.backend.unlock_bucket() + + @action(auto_prefix=True, description="锁定转篮/吊篮。") + async def lock_bucket(self) -> None: + _unilab_logger.debug("[UNILAB] Centrifuge.lock_bucket() called") + await self.backend.lock_bucket() + + @action(auto_prefix=True, description="将转子定位到 1 号转篮/吊篮位置。") + async def go_to_bucket1(self, **backend_kwargs) -> None: + _unilab_logger.debug("[UNILAB] Centrifuge.go_to_bucket1() called") + await self.backend.go_to_bucket1(**backend_kwargs) + self._at_bucket = self.bucket1 + + @action(auto_prefix=True, description="将转子定位到 2 号转篮/吊篮位置。") + async def go_to_bucket2(self, **backend_kwargs) -> None: + _unilab_logger.debug("[UNILAB] Centrifuge.go_to_bucket2() called") + await self.backend.go_to_bucket2(**backend_kwargs) + self._at_bucket = self.bucket2 + + @action(auto_prefix=True, description="按设定离心力和持续时间启动一次离心循环。") + async def start_spin_cycle(self, g: float, duration: float) -> None: + _unilab_logger.debug("[UNILAB] Centrifuge.start_spin_cycle() called") + """Deprecated: use `spin` instead.""" + warnings.warn( + "`start_spin_cycle` is deprecated and will be removed in a future version. Use `spin` instead.", + DeprecationWarning, + ) + await self.spin(g=g, duration=duration) + + @action(auto_prefix=True, description="按设定离心力和持续时间执行离心。") + async def spin(self, g: float, duration: float, **backend_kwargs) -> None: + _unilab_logger.debug("[UNILAB] Centrifuge.spin() called") + """Starts a spin cycle. + + Args: + g: The g-force to spin at. + duration: The duration of the spin in seconds. Time at speed. + acceleration: The acceleration as a fraction of maximum acceleration (0-1). + """ + await self.backend.spin( + g=g, + duration=duration, + **backend_kwargs, + ) + self._at_bucket = None + + @property + @action(auto_prefix=True, description="获取当前位于操作位置的转篮/吊篮。") + def at_bucket(self) -> Optional[ResourceHolder]: + _unilab_logger.debug("[UNILAB] Centrifuge.at_bucket() called") + """None if not at a bucket or unknown, otherwise the resource representing the bucket.""" + return self._at_bucket + + @action(auto_prefix=True, description="导出设备的序列化信息。") + def serialize(self) -> dict: + _unilab_logger.debug("[UNILAB] Centrifuge.serialize() called") + return { + **Machine.serialize(self), + **Resource.serialize(self), + "buckets": [bucket.serialize() for bucket in [self.bucket1, self.bucket2]], + } + + @classmethod + @action(auto_prefix=True, description="从序列化信息恢复设备表示。") + def deserialize(cls, data: dict, allow_marshal: bool = False): + _unilab_logger.debug("[UNILAB] Centrifuge.deserialize() called") + backend = CentrifugeBackend.deserialize(data["backend"]) + buckets = tuple(ResourceHolder.deserialize(bucket) for bucket in data["buckets"]) + assert len(buckets) == 2 + return cls( + backend=backend, + name=data["name"], + size_x=data["size_x"], + size_y=data["size_y"], + size_z=data["size_z"], + rotation=Rotation.deserialize(data["rotation"]), + category=data["category"], + model=data["model"], + buckets=buckets, + ) + def _loader_proxy(self): + loader = getattr(self, "loader", None) + if loader is not None: + return loader + return self.backend + + @action(auto_prefix=True, description="将样品装入离心机。") + async def load(self) -> None: + return await self._loader_proxy().load() + + @action(auto_prefix=True, description="将样品从离心机中取出。") + async def unload(self) -> None: # DOOR arg? + return await self._loader_proxy().unload() + + +class Loader(Machine, ResourceHolder): + """The front end for centrifuge loaders. + Centrifuge loaders are devices that can load and unload samples from centrifuges.""" + + def __init__( + self, + backend: LoaderBackend, + centrifuge: Centrifuge, + name: str, + size_x: float, + size_y: float, + size_z: float, + child_location: Coordinate, + rotation=None, + category="loader", + model=None, + ) -> None: + Machine.__init__(self, backend=backend) + ResourceHolder.__init__( + self, + name=name, + size_x=size_x, + size_y=size_y, + size_z=size_z, + child_location=child_location, + rotation=rotation, + category=category, + model=model, + ) + self.backend: LoaderBackend = backend # fix type + self.centrifuge = centrifuge + + async def load(self) -> None: + if not self.centrifuge.door_open: + raise CentrifugeDoorError("Centrifuge door must be open to load a plate.") + + if self.centrifuge.at_bucket is None: + raise NotAtBucketError( + "Centrifuge must be at a bucket to load a plate, but current position is unknown or not at " + "a bucket. Use centrifuge.go_to_bucket{1,2}() to move to a bucket." + ) + + if self.resource is None: + raise LoaderNoPlateError("Loader must have a plate to load.") + + if self.centrifuge.at_bucket.resource is not None: + raise BucketHasPlateError("Bucket must be empty to load a plate.") + + await self.backend.load() + + self.centrifuge.at_bucket.assign_child_resource(self.resource, location=Coordinate.zero()) + + async def unload(self) -> None: # DOOR arg? + if not self.centrifuge.door_open: + raise CentrifugeDoorError("Centrifuge door must be open to unload a plate.") + + if self.centrifuge.at_bucket is None: + raise NotAtBucketError( + "Centrifuge must be at a bucket to unload a plate, but current position is unknown or not " + "at a bucket. Use centrifuge.go_to_bucket{1,2}() to move to a bucket." + ) + + if self.centrifuge.at_bucket.resource is None: + raise BucketNoPlateError("Bucket must have a plate to unload.") + + await self.backend.unload() + + self.assign_child_resource(self.centrifuge.at_bucket.resource) + + def serialize(self) -> dict: + return { + "resource": ResourceHolder.serialize(self), + "machine": Machine.serialize(self), + "centrifuge": self.centrifuge.serialize(), + } + + @classmethod + def deserialize(cls, data: dict, allow_marshal: bool = False): + return cls( + backend=LoaderBackend.deserialize(data["machine"]["backend"]), + centrifuge=Centrifuge.deserialize(data["centrifuge"]), + name=data["resource"]["name"], + size_x=data["resource"]["size_x"], + size_y=data["resource"]["size_y"], + size_z=data["resource"]["size_z"], + child_location=deserialize(data["resource"]["child_location"]), + rotation=deserialize(data["resource"]["rotation"]), + category=data["resource"]["category"], + model=data["resource"]["model"], + ) diff --git a/unilabos/devices/_phage_display/clari_ostar_backend.py b/unilabos/devices/_phage_display/clari_ostar_backend.py new file mode 100644 index 000000000..f19a5b35c --- /dev/null +++ b/unilabos/devices/_phage_display/clari_ostar_backend.py @@ -0,0 +1,502 @@ +""" +Auto-generated by Build Agent +Original: pylabrobot/plate_reading/bmg_labtech/clario_star_backend.py + +[UNILAB DEBUG] 此驱动已注入调试日志 +- 每个方法被调用时会打印日志 +- 日志格式: [UNILAB] ClassName.method_name() called +- 查看日志: logging.getLogger("unilab.driver").setLevel(logging.DEBUG) +""" +from unilabos.registry.decorators import action, device +from typing import Dict, Any, Optional +import logging +import warnings as _warnings +_warnings.filterwarnings("ignore", message=r"pkg_resources is deprecated as an API\..*") +import sys as _sys +import types as _types + +# 兼容性:部分上游框架(例如 lantz)在 import-time 强制加载 Qt(PyQt4/PySide1), +# 在 headless/现代 Python 环境中通常不存在这些绑定,会导致导入直接失败。 +# 这里预注入一个最小的 `lantz.utils.qt` shim,避免触发其 Qt loader。 +if "lantz.utils.qt" not in _sys.modules: + _qt_mod = _types.ModuleType("lantz.utils.qt") + + class _DummyQtCore: + class QObject: + def __init__(self, *_args, **_kwargs): + # 兼容 QObject(parent=...) 等调用 + return None + + @staticmethod + def Signal(*_args, **_kwargs): + return object() + + @staticmethod + def Slot(*_args, **_kwargs): + def _decorator(fn): + return fn + + return _decorator + + _qt_mod.QtCore = _DummyQtCore + + class _DummySuperQObject(_DummyQtCore.QObject): + def __new__(cls, *_args, **_kwargs): + return super().__new__(cls) + + def __init__(self, *_args, **_kwargs): + # 允许 Driver(...) 传参,不触发 object.__init__ 的 TypeError + return None + + _qt_mod.MetaQObject = type + _qt_mod.SuperQObject = _DummySuperQObject + _sys.modules["lantz.utils.qt"] = _qt_mod + +# Unilab 驱动调试日志 +_unilab_logger = logging.getLogger("unilab.driver.CLARIOstarBackend") +_unilab_logger.setLevel(logging.DEBUG) +if not _unilab_logger.handlers: + _handler = logging.StreamHandler() + _handler.setFormatter(logging.Formatter("[%(asctime)s] %(message)s")) + _unilab_logger.addHandler(_handler) + +# Vendored repo packages: pylabrobot, pylabrobot +import sys as _sys +from pathlib import Path as _Path +_vendor_root = _Path(__file__).resolve().parent / "_vendor" +if _vendor_root.exists() and str(_vendor_root) not in _sys.path: + _sys.path.insert(0, str(_vendor_root)) + +import asyncio +import logging +import math +import struct +import sys +import time +from typing import Dict, List, Optional, Tuple, Union + +from pylabrobot import utils +from pylabrobot.io.ftdi import FTDI +from pylabrobot.resources.plate import Plate +from pylabrobot.resources.well import Well + +from pylabrobot.plate_reading.backend import PlateReaderBackend + +if sys.version_info >= (3, 8): + from typing import Literal +else: + from typing_extensions import Literal + +logger = logging.getLogger("pylabrobot") + + +@device( + id="clari_ostar_backend", + category=["Microplate Reader"], + description="CLARIOstar 是一款多功能微孔板读板仪,可对微孔板样品进行吸光度、荧光和发光检测,常用于生化分析、细胞实验、药物筛选和其他高通量实验。", + model={ + "type": "device", + "mesh": "clari_ostar_backend", + "path": "https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/devices/clari_ostar_backend/macro_device.xacro", + }, +) +class CLARIOstarBackend(PlateReaderBackend): + """A plate reader backend for the Clario star. Note that this is not a complete implementation + and many commands and parameters are not implemented yet.""" + + def __init__(self, device_id: Optional[str] = None): + print("[UNILAB] CLARIOstarBackend.__init__() called", flush=True) + self.io = FTDI(device_id=device_id, vid=0x0403, pid=0xBB68) + + @action(auto_prefix=True, description="设置并准备酶标仪进行通信和操作。") + async def setup(self): + _unilab_logger.debug("[UNILAB] CLARIOstarBackend.setup() called") + await self.io.setup() + await self.io.set_baudrate(125000) + await self.io.set_line_property(8, 0, 0) # 8N1 + await self.io.set_latency_timer(2) + + await self.initialize() + await self.request_eeprom_data() + + @action(auto_prefix=True, description="停止当前读板或设备操作。") + async def stop(self): + _unilab_logger.debug("[UNILAB] CLARIOstarBackend.stop() called") + await self.io.stop() + + @action(auto_prefix=True, description="获取设备状态。") + async def get_stat(self): + _unilab_logger.debug("[UNILAB] CLARIOstarBackend.get_stat() called") + stat = await self.io.poll_modem_status() + return hex(stat) + + @action(auto_prefix=True, description="读取设备返回的响应数据。") + async def read_resp(self, timeout=20) -> bytes: + _unilab_logger.debug("[UNILAB] CLARIOstarBackend.read_resp() called") + """Read a response from the plate reader. If the timeout is reached, return the data that has + been read so far.""" + + d = b"" + last_read = b"" + end_byte_found = False + t = time.time() + + # Commands are terminated with 0x0d, but this value may also occur as a part of the response. + # Therefore, we read until we read a 0x0d, but if that's the last byte we read in a full packet, + # we keep reading for at least one more cycle. We only check the timeout if the last read was + # unsuccessful (i.e. keep reading if we are still getting data). + while True: + last_read = await self.io.read(25) # 25 is max length observed in pcap + if len(last_read) > 0: + d += last_read + end_byte_found = d[-1] == 0x0D + if ( + len(last_read) < 25 and end_byte_found + ): # if we read less than 25 bytes, we're at the end + break + else: + # If we didn't read any data, check if the last read ended in an end byte. If so, we're done + if end_byte_found: + break + + # Check if we've timed out. + if time.time() - t > timeout: + logger.warning("timed out reading response") + break + + # If we read data, we don't wait and immediately try to read more. + await asyncio.sleep(0.0001) + + logger.debug("read %s", d.hex()) + + return d + + @action(auto_prefix=True, description="向酶标仪发送控制命令。") + async def send(self, cmd: Union[bytearray, bytes], read_timeout=20): + _unilab_logger.debug("[UNILAB] CLARIOstarBackend.send() called") + """Send a command to the plate reader and return the response.""" + + checksum = (sum(cmd) & 0xFFFF).to_bytes(2, byteorder="big") + cmd = cmd + checksum + b"\x0d" + + logger.debug("sending %s", cmd.hex()) + + w = await self.io.write(cmd) + + logger.debug("wrote %s bytes", w) + + assert w == len(cmd) + + resp = await self.read_resp(timeout=read_timeout) + return resp + + async def _wait_for_ready_and_return(self, ret, timeout=150): + _unilab_logger.debug("[UNILAB] CLARIOstarBackend._wait_for_ready_and_return() called") + """Wait for the plate reader to be ready and return the response.""" + last_status = None + t = time.time() + while time.time() - t < timeout: + await asyncio.sleep(0.1) + + command_status = await self.read_command_status() + + if len(command_status) != 24: + logger.warning( + "unexpected response %s. I think a command status response is always 24 bytes", + command_status, + ) + continue + + if command_status != last_status: + logger.info("status changed %s", command_status.hex()) + last_status = command_status + else: + continue + + if command_status[2] != 0x18 or command_status[3] != 0x0C or command_status[4] != 0x01: + logger.warning( + "unexpected response %s. I think 18 0c 01 indicates a command status response", + command_status, + ) + + if command_status[5] not in { + 0x25, + 0x05, + }: # 25 is busy, 05 is ready. probably. + logger.warning("unexpected response %s.", command_status) + + if command_status[5] == 0x05: + logger.debug("status is ready") + return ret + + @action(auto_prefix=True, description="读取当前命令的执行状态。") + async def read_command_status(self): + _unilab_logger.debug("[UNILAB] CLARIOstarBackend.read_command_status() called") + status = await self.send(b"\x02\x00\x09\x0c\x80\x00") + return status + + @action(auto_prefix=True, description="初始化酶标仪并使其进入可操作状态。") + async def initialize(self): + _unilab_logger.debug("[UNILAB] CLARIOstarBackend.initialize() called") + command_response = await self.send(b"\x02\x00\x0d\x0c\x01\x00\x00\x10\x02\x00") + return await self._wait_for_ready_and_return(command_response) + + @action(auto_prefix=True, description="读取设备 EEPROM 中存储的信息。") + async def request_eeprom_data(self): + _unilab_logger.debug("[UNILAB] CLARIOstarBackend.request_eeprom_data() called") + eeprom_response = await self.send(b"\x02\x00\x0f\x0c\x05\x07\x00\x00\x00\x00\x00\x00") + return await self._wait_for_ready_and_return(eeprom_response) + + @action(auto_prefix=True, description="打开微孔板托盘。") + async def open(self): + _unilab_logger.debug("[UNILAB] CLARIOstarBackend.open() called") + open_response = await self.send(b"\x02\x00\x0e\x0c\x03\x01\x00\x00\x00\x00\x00") + return await self._wait_for_ready_and_return(open_response) + + @action(auto_prefix=True, description="关闭微孔板托盘。") + async def close(self, plate: Optional[Plate] = None): + _unilab_logger.debug("[UNILAB] CLARIOstarBackend.close() called") + close_response = await self.send(b"\x02\x00\x0e\x0c\x03\x00\x00\x00\x00\x00\x00") + return await self._wait_for_ready_and_return(close_response) + + async def _mp_and_focus_height_value(self): + _unilab_logger.debug("[UNILAB] CLARIOstarBackend._mp_and_focus_height_value() called") + mp_and_focus_height_value_response = await self.send( + b"\x02\x00\x0f\x0c\x05\17\x00\x00\x00\x00" + b"\x00\x00" + ) + return await self._wait_for_ready_and_return(mp_and_focus_height_value_response) + + async def _run_luminescence(self, focal_height: float): + _unilab_logger.debug("[UNILAB] CLARIOstarBackend._run_luminescence() called") + """Run a plate reader luminescence run.""" + + assert 0 <= focal_height <= 25, "focal height must be between 0 and 25 mm" + + focal_height_data = int(focal_height * 100).to_bytes(2, byteorder="big") + + run_response = await self.send( + b"\x02\x00\x86\x0c\x04\x31\xec\x21\x66\x05\x96\x04\x60\x2c\x56" + b"\x1d\x06\x0c\x08\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\x00\x00\x00\x00\x00\x00" + b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" + b"\x00\x00\x00\x00\x00\x00\x00\x00\x02\x01\x00\x00\x00\x00\x00\x00\x00\x20\x04\x00\x1e\x27" + b"\x0f\x27\x0f\x01" + focal_height_data + b"\x00\x00\x01\x00\x00\x0e\x10\x00\x01\x00\x01\x00" + b"\x01\x00\x01\x00\x01\x00\x06\x00\x00\x00\x00\x00\x00\x00\x00\x00\x02\x00\x00\x00\x00\x01" + b"\x00\x00\x00\x01\x00\x64\x00\x20\x00\x00" + ) + + # TODO: find a prettier way to do this. It's essentially copied from _wait_for_ready_and_return. + last_status = None + while True: + await asyncio.sleep(0.1) + + command_status = await self.read_command_status() + + if command_status != last_status: + last_status = command_status + logger.info("status changed %s", command_status) + continue + + if command_status == bytes( + b"\x02\x00\x18\x0c\x01\x25\x04\x2e\x00\x00\x04\x01\x00\x00\x03\x00" + b"\x00\x00\x00\xc0\x00\x01\x46\x0d" + ): + return run_response + + async def _run_absorbance(self, wavelength: float): + _unilab_logger.debug("[UNILAB] CLARIOstarBackend._run_absorbance() called") + """Run a plate reader absorbance run.""" + wavelength_data = int(wavelength * 10).to_bytes(2, byteorder="big") + + absorbance_command = ( + b"\x02\x00\x7c\x0c\x04\x31\xec\x21\x66\x05\x96\x04\x60\x2c\x56\x1d\x06" + b"\x0c\x08\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00" + b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" + b"\x00\x00\x00\x00\x00\x00\x82\x02\x00\x00\x00\x00\x00\x00\x00\x20\x04\x00\x1e\x27\x0f\x27" + b"\x0f\x19\x01" + wavelength_data + b"\x00\x00\x00\x64\x00\x00\x00\x00\x00\x00\x00\x64\x00" + b"\x00\x00\x00\x00\x02\x00\x00\x00\x00\x01\x00\x00\x00\x01\x00\x16\x00\x01\x00\x00" + ) + run_response = await self.send(absorbance_command) + + # TODO: find a prettier way to do this. It's essentially copied from _wait_for_ready_and_return. + last_status = None + while True: + await asyncio.sleep(0.1) + + command_status = await self.read_command_status() + + if command_status != last_status: + last_status = command_status + logger.info("status changed %s", command_status) + continue + + if command_status == bytes( + b"\x02\x00\x18\x0c\x01\x25\x04\x2e\x00\x00\x04\x01\x00\x00\x03\x00" + b"\x00\x00\x00\xc0\x00\x01\x46\x0d" + ): + return run_response + + async def _read_order_values(self): + _unilab_logger.debug("[UNILAB] CLARIOstarBackend._read_order_values() called") + return await self.send(b"\x02\x00\x0f\x0c\x05\x1d\x00\x00\x00\x00\x00\x00") + + async def _status_hw(self): + _unilab_logger.debug("[UNILAB] CLARIOstarBackend._status_hw() called") + status_hw_response = await self.send(b"\x02\x00\x09\x0c\x81\x00") + return await self._wait_for_ready_and_return(status_hw_response) + + async def _get_measurement_values(self): + _unilab_logger.debug("[UNILAB] CLARIOstarBackend._get_measurement_values() called") + return await self.send(b"\x02\x00\x0f\x0c\x05\x02\x00\x00\x00\x00\x00\x00") + + @action(auto_prefix=True, description="测量微孔板样品的发光信号。") + async def read_luminescence( + self, plate: Plate, wells: List[Well], focal_height: float = 13 + ) -> List[Dict]: + _unilab_logger.debug("[UNILAB] CLARIOstarBackend.read_luminescence() called") + """Read luminescence values from the plate reader.""" + if wells != plate.get_all_items(): + raise NotImplementedError("Only full plate reads are supported for now.") + + await self._mp_and_focus_height_value() + + await self._run_luminescence(focal_height=focal_height) + + await self._read_order_values() + + await self._status_hw() + + vals = await self._get_measurement_values() + + # All 96 values are 32 bit integers. The header is variable length, so we need to find the + # start of the data. In the future, when we understand the protocol better, this can be + # replaced with a more robust solution. + start_idx = vals.index(b"\x00\x00\x00\x00\x00\x00") + len(b"\x00\x00\x00\x00\x00\x00") + data = list(vals)[start_idx : start_idx + 96 * 4] + + # group bytes by 4 + int_bytes = [data[i : i + 4] for i in range(0, len(data), 4)] + + # convert to int + ints = [struct.unpack(">i", bytes(int_data))[0] for int_data in int_bytes] + + # for backend conformity, convert to float, and reshape to 2d array + floats: List[List[Optional[float]]] = utils.reshape_2d( + [float(i) for i in ints], (plate.num_items_y, plate.num_items_x) + ) + + return [ + { + "data": floats, + "temperature": float("nan"), # Temperature not available + "time": time.time(), + } + ] + + @action(auto_prefix=True, description="测量微孔板在指定波长下的吸光度或透过率。") + async def read_absorbance( + self, + plate: Plate, + wells: List[Well], + wavelength: int, + report: Literal["OD", "transmittance"] = "OD", + ) -> List[Dict]: + _unilab_logger.debug("[UNILAB] CLARIOstarBackend.read_absorbance() called") + """Read absorbance values from the device. + + Args: + wavelength: wavelength to read absorbance at, in nanometers. + report: whether to report absorbance as optical depth (OD) or transmittance. Transmittance is + used interchangeably with "transmission" in the CLARIOStar software and documentation. + + Returns: + A list containing a single dictionary, where the key is (wavelength, 0) and the value is + another dictionary containing the data, temperature, and time. + """ + + if wells != plate.get_all_items(): + raise NotImplementedError("Only full plate reads are supported for now.") + + await self._mp_and_focus_height_value() + + await self._run_absorbance(wavelength=wavelength) + + await self._read_order_values() + + await self._status_hw() + + vals = await self._get_measurement_values() + div = b"\x00" * 6 + start_idx = vals.index(div) + len(div) + chromatic_data = vals[start_idx : start_idx + 96 * 4] + ref_data = vals[start_idx + 96 * 4 : start_idx + (96 * 2) * 4] + chromatic_bytes = [bytes(chromatic_data[i : i + 4]) for i in range(0, len(chromatic_data), 4)] + ref_bytes = [bytes(ref_data[i : i + 4]) for i in range(0, len(ref_data), 4)] + chromatic_reading = [struct.unpack(">i", x)[0] for x in chromatic_bytes] + reference_reading = [struct.unpack(">i", x)[0] for x in ref_bytes] + + # c100 is the value of the chromatic at 100% intensity + # c0 is the value of the chromatic at 0% intensity (black reading) + # r100 is the value of the reference at 100% intensity + # r0 is the value of the reference at 0% intensity (black reading) + after_values_idx = start_idx + (96 * 2) * 4 + c100, c0, r100, r0 = struct.unpack(">iiii", vals[after_values_idx : after_values_idx + 4 * 4]) + + # a bit much, but numpy should not be a dependency + real_chromatic_reading = [] + for cr in chromatic_reading: + real_chromatic_reading.append((cr - c0) / c100) + real_reference_reading = [] + for rr in reference_reading: + real_reference_reading.append((rr - r0) / r100) + + transmittance: List[Optional[float]] = [] + for rcr, rrr in zip(real_chromatic_reading, real_reference_reading): + transmittance.append(rcr / rrr * 100) + + data: List[List[Optional[float]]] + if report == "OD": + od: List[Optional[float]] = [] + for t in transmittance: + od.append(math.log10(100 / t) if t is not None and t > 0 else None) + data = utils.reshape_2d(od, (plate.num_items_y, plate.num_items_x)) + elif report == "transmittance": + data = utils.reshape_2d(transmittance, (plate.num_items_y, plate.num_items_x)) + else: + raise ValueError(f"Invalid report type: {report}") + + return [ + { + "wavelength": wavelength, + "data": data, + "temperature": float("nan"), # Temperature not available + "time": time.time(), + } + ] + + @action(auto_prefix=True, description="测量微孔板样品的荧光信号。") + async def read_fluorescence( + self, + plate: Plate, + wells: List[Well], + excitation_wavelength: int, + emission_wavelength: int, + focal_height: float, + ) -> List[Dict[Tuple[int, int], Dict]]: + _unilab_logger.debug("[UNILAB] CLARIOstarBackend.read_fluorescence() called") + raise NotImplementedError("Not implemented yet") + + +# Deprecated alias with warning # TODO: remove mid May 2025 (giving people 1 month to update) +# https://github.com/PyLabRobot/pylabrobot/issues/466 + + +class CLARIOStar: + def __init__(self, *args, **kwargs): + raise RuntimeError("`CLARIOStar` is deprecated. Please use `CLARIOStarBackend` instead.") + + +class CLARIOStarBackend: + def __init__(self, *args, **kwargs): + raise RuntimeError( + "`CLARIOStarBackend` (capital 'S') is deprecated. Please use `CLARIOstarBackend` instead." + ) diff --git a/unilabos/devices/_phage_display/cytomat_backend.py b/unilabos/devices/_phage_display/cytomat_backend.py new file mode 100644 index 000000000..1f60f73fb --- /dev/null +++ b/unilabos/devices/_phage_display/cytomat_backend.py @@ -0,0 +1,640 @@ +""" +Auto-generated by Build Agent +Original: pylabrobot/storage/cytomat/cytomat.py + +[UNILAB DEBUG] 此驱动已注入调试日志 +- 每个方法被调用时会打印日志 +- 日志格式: [UNILAB] ClassName.method_name() called +- 查看日志: logging.getLogger("unilab.driver").setLevel(logging.DEBUG) +""" +from unilabos.registry.decorators import action, device +from typing import Dict, Any, Optional +import logging +import warnings as _warnings +_warnings.filterwarnings("ignore", message=r"pkg_resources is deprecated as an API\..*") +import sys as _sys +import types as _types + +# 兼容性:部分上游框架(例如 lantz)在 import-time 强制加载 Qt(PyQt4/PySide1), +# 在 headless/现代 Python 环境中通常不存在这些绑定,会导致导入直接失败。 +# 这里预注入一个最小的 `lantz.utils.qt` shim,避免触发其 Qt loader。 +if "lantz.utils.qt" not in _sys.modules: + _qt_mod = _types.ModuleType("lantz.utils.qt") + + class _DummyQtCore: + class QObject: + def __init__(self, *_args, **_kwargs): + # 兼容 QObject(parent=...) 等调用 + return None + + @staticmethod + def Signal(*_args, **_kwargs): + return object() + + @staticmethod + def Slot(*_args, **_kwargs): + def _decorator(fn): + return fn + + return _decorator + + _qt_mod.QtCore = _DummyQtCore + + class _DummySuperQObject(_DummyQtCore.QObject): + def __new__(cls, *_args, **_kwargs): + return super().__new__(cls) + + def __init__(self, *_args, **_kwargs): + # 允许 Driver(...) 传参,不触发 object.__init__ 的 TypeError + return None + + _qt_mod.MetaQObject = type + _qt_mod.SuperQObject = _DummySuperQObject + _sys.modules["lantz.utils.qt"] = _qt_mod + +# Unilab 驱动调试日志 +_unilab_logger = logging.getLogger("unilab.driver.CytomatBackend") +_unilab_logger.setLevel(logging.DEBUG) +if not _unilab_logger.handlers: + _handler = logging.StreamHandler() + _handler.setFormatter(logging.Formatter("[%(asctime)s] %(message)s")) + _unilab_logger.addHandler(_handler) + +# Vendored repo packages: pylabrobot, pylabrobot +import sys as _sys +from pathlib import Path as _Path +_vendor_root = _Path(__file__).resolve().parent / "_vendor" +if _vendor_root.exists() and str(_vendor_root) not in _sys.path: + _sys.path.insert(0, str(_vendor_root)) + +import asyncio +import logging +import time +import warnings +from typing import List, Literal, Optional, Union, cast + +import serial + +from pylabrobot.io.serial import Serial +from pylabrobot.resources import Plate, PlateCarrier, PlateHolder +from pylabrobot.storage.backend import IncubatorBackend +from pylabrobot.storage.cytomat.constants import ( + ActionRegister, + ActionType, + CytomatActionResponse, + CytomatIncupationResponse, + CytomatType, + ErrorRegister, + LoadStatusAtProcessor, + LoadStatusFrontOfGate, + SensorRegister, + SwapStationPosition, + WarningRegister, +) +from pylabrobot.storage.cytomat.errors import ( + CytomatBusyError, + CytomatCommandUnknownError, + CytomatTelegramStructureError, + error_map, + error_register_map, +) +from pylabrobot.storage.cytomat.schemas import ( + ActionRegisterState, + OverviewRegisterState, + SensorStates, + SwapStationState, +) +from pylabrobot.storage.cytomat.utils import ( + hex_to_base_twelve, + hex_to_binary, + validate_storage_location_number, +) + +logger = logging.getLogger(__name__) + + +@device( + id="cytomat_backend", + category=["Tissue Culture Chamber", "Automated Microplate Incubator Storage System"], + description="这是一种用于微孔板自动存取、培养和转运的实验室培养存储设备,带有内部存储位、传送位和外部暴露位,可控制温度、CO2、湿度和O2,并支持振荡和条码读取。常用于细胞培养、高通量筛选和自动化工作站中的板管理。", + model={ + "type": "device", + "mesh": "cytomat_backend", + "path": "https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/devices/cytomat_backend/macro_device.xacro", + }, +) +class CytomatBackend(IncubatorBackend): + default_baud = 9600 + serial_message_encoding = "utf-8" + + def __init__(self, model: Union[CytomatType, str] = "C2C_425", port: str = ""): + print("[UNILAB] CytomatBackend.__init__() called", flush=True) + super().__init__() + + supported_models = [ + CytomatType.C6000, + CytomatType.C6002, + CytomatType.C2C_425, + CytomatType.C2C_450_SHAKE, + CytomatType.C5C, + ] + if isinstance(model, str): + try: + model = CytomatType(model) + except ValueError: + raise ValueError(f"Unsupported Cytomat model: '{model}'") + if model not in supported_models: + raise NotImplementedError( + f"Only the following Cytomats are supported: {supported_models}, but got '{model}'" + ) + self.model = model + self._racks: List[PlateCarrier] = [] + + if port: + self.io = Serial( + port=port, + baudrate=self.default_baud, + bytesize=serial.EIGHTBITS, + parity=serial.PARITY_NONE, + stopbits=serial.STOPBITS_ONE, + write_timeout=1, + timeout=1, + ) + else: + self.io = None + warnings.warn( + "[UNILAB] CytomatBackend 未配置串口 (port 为空),未连接真实设备," + "将以虚拟模式运行,所有 IO 操作均返回虚拟成功响应。", + RuntimeWarning, + stacklevel=2, + ) + + def _warn_virtual_io(self, method: str) -> None: + """统一提示:未连接真实设备,当前调用走虚拟成功分支。""" + warnings.warn( + f"[UNILAB] CytomatBackend.{method}: 未连接真实设备 (self.io is None),返回虚拟成功。", + RuntimeWarning, + stacklevel=3, + ) + + @action(auto_prefix=True, description="准备设备以供运行。") + async def setup(self): + _unilab_logger.debug("[UNILAB] CytomatBackend.setup() called") + if self.io is None: + self._warn_virtual_io("setup()") + return + await self.io.setup() + await self.initialize() + await self.wait_for_task_completion() + + @action(auto_prefix=True, description="设置设备中的板架布局。") + async def set_racks(self, racks: List[PlateCarrier]): + _unilab_logger.debug("[UNILAB] CytomatBackend.set_racks() called") + await super().set_racks(racks) + warnings.warn("Cytomat racks need to be configured with the exe software") + + @action(auto_prefix=True, description="停止设备运行。") + async def stop(self): + _unilab_logger.debug("[UNILAB] CytomatBackend.stop() called") + if self.io is None: + self._warn_virtual_io("stop()") + return + await self.io.stop() + + def _assemble_command(self, command_type: str, command: str, params: str): + _unilab_logger.debug("[UNILAB] CytomatBackend._assemble_command() called") + carriage_return = "\r" if self.model == CytomatType.C2C_425 else "\r\n" + command = f"{command_type}:{command} {params}".strip() + carriage_return + return command + + @action(auto_prefix=True, description="向设备发送底层命令。") + async def send_command(self, command_type: str, command: str, params: str) -> str: + _unilab_logger.debug("[UNILAB] CytomatBackend.send_command() called") + if self.io is None: + self._warn_virtual_io( + f"send_command(command_type={command_type!r}, command={command!r}, params={params!r})" + ) + # 虚拟成功响应:十六进制 '00' 经 hex_to_binary 解析为全零,对应 + # OverviewRegisterState 的 busy_bit_set / error_register_set 等全部为 False, + # 可让 wait_for_task_completion 等上层逻辑直接通过。 + return "00" + + async def _send_command(command_str) -> str: + _unilab_logger.debug("[UNILAB] CytomatBackend._send_command() called") + logger.debug(command_str.encode(self.serial_message_encoding)) + await self.io.write(command_str.encode(self.serial_message_encoding)) + resp = (await self.io.read(128)).decode(self.serial_message_encoding) + if len(resp) == 0: + raise RuntimeError("Cytomat did not respond to command, is it turned on?") + key, *values = resp.split() + value = " ".join(values) + + if key == CytomatActionResponse.OK.value or key == command: + # actions return an OK response, while checks return the command at the start of the response + return value + if key == CytomatActionResponse.ERROR.value: + logger.error("Command %s failed with: '%s'", command_str, resp) + if value == "03": + error_register = await self.get_error_register() + await self.reset_error_register() + raise CytomatTelegramStructureError(f"Telegram structure error: {error_register}") + if int(value, base=16) in error_map: + await self.reset_error_register() + raise error_map[int(value, base=16)] + await self.reset_error_register() + raise Exception(f"Unknown cytomat error code in response: {resp}") + + logger.error("Command %s received an unknown response: '%s'", command_str, resp) + await self.reset_error_register() + raise Exception(f"Unknown response from cytomat: {resp}") + + # Cytomats sometimes return a busy or command not recognized error even when the overview + # register says the machine is not busy, or if the command is known. We will retry a few times, + # which costs 1s if there is a true error, but is necessary to avoid false negatives. + command_str = self._assemble_command(command_type=command_type, command=command, params=params) + n_retries = 10 + exc: Optional[BaseException] = None + for _ in range(n_retries): + try: + return await _send_command(command_str) + except (CytomatCommandUnknownError, CytomatBusyError) as e: + exc = e + await asyncio.sleep(0.1) + continue + assert exc is not None + await self.reset_error_register() + raise exc + + @action(auto_prefix=True, description="向设备发送动作命令并等待完成。") + async def send_action( + self, command_type: str, command: str, params: str, timeout: Optional[int] = 60 + ) -> OverviewRegisterState: + _unilab_logger.debug("[UNILAB] CytomatBackend.send_action() called") + """Calls send_command, but has a timeout handler and returns the overview register state. + Args: + timeout: The maximum time to wait for the command to complete. If None, the command will not + wait for completion. + """ + await self.send_command(command_type, command, params) + if timeout is not None: + overview_register = await self.wait_for_task_completion(timeout=timeout) + return overview_register + + def _site_to_firmware_string(self, site: PlateHolder) -> str: + _unilab_logger.debug("[UNILAB] CytomatBackend._site_to_firmware_string() called") + rack = cast(PlateCarrier, site.parent) + rack_idx = [rack.name for rack in self._racks].index( + rack.name + ) # autoreload resistant, should work + site_idx = next(idx for idx, s in rack.sites.items() if s == site) + + if self.model in [CytomatType.C2C_425]: + return f"{str(rack_idx).zfill(2)} {str(site_idx).zfill(2)}" + + # TODO: configure all cytomats to use `rack site` format + if self.model in [ + CytomatType.C6000, + CytomatType.C6002, + CytomatType.C2C_450_SHAKE, + CytomatType.C5C, + ]: + slots_to_skip = sum(r.capacity for r in self._racks[:rack_idx]) + absolute_slot = slots_to_skip + site_idx + 1 # 1-indexed + + return f"{absolute_slot:03}" + + raise ValueError(f"Unsupported Cytomat model: {self.model}") + + @action(auto_prefix=True, description="获取总览寄存器状态。") + async def get_overview_register(self) -> OverviewRegisterState: + _unilab_logger.debug("[UNILAB] CytomatBackend.get_overview_register() called") + # Sometimes this command is not recognized and it is not known why. We will retry a few times + # We don't care if the cytomat is still busy, that is actually what we are often checking for. + # We are just gathering state, so just try a little bit later. + num_tries = 10 + for _ in range(num_tries): + try: + resp = await self.send_command("ch", "bs", "") + except (CytomatCommandUnknownError, CytomatBusyError): + await asyncio.sleep(0.1) + continue + return OverviewRegisterState.from_resp(resp) + await self.reset_error_register() + raise CytomatCommandUnknownError("Could not get overview register") + + @action(auto_prefix=True, description="获取警告寄存器状态。") + async def get_warning_register(self) -> WarningRegister: + _unilab_logger.debug("[UNILAB] CytomatBackend.get_warning_register() called") + hex_value = await self.send_command("ch", "bw", "") + for member in WarningRegister: + if hex_value == member.value: + return member + + await self.reset_error_register() + raise Exception(f"Unknown warning register value: {hex_value}") + + @action(auto_prefix=True, description="获取错误寄存器状态。") + async def get_error_register(self) -> ErrorRegister: + _unilab_logger.debug("[UNILAB] CytomatBackend.get_error_register() called") + hex_value = await self.send_command("ch", "be", "") + for member in ErrorRegister: + if hex_value == member.value: + return member + + await self.reset_error_register() + raise Exception(f"Unknown error register value: {hex_value}") + + @action(auto_prefix=True, description="复位错误寄存器。") + async def reset_error_register(self) -> None: + _unilab_logger.debug("[UNILAB] CytomatBackend.reset_error_register() called") + await self.send_command("rs", "be", "") + + @action(auto_prefix=True, description="初始化设备。") + async def initialize(self) -> None: + _unilab_logger.debug("[UNILAB] CytomatBackend.initialize() called") + await self.send_action("ll", "in", "", timeout=300) # this command sometimes times out + + @action(auto_prefix=True, description="打开设备门。") + async def open_door(self): + _unilab_logger.debug("[UNILAB] CytomatBackend.open_door() called") + return await self.send_action("ll", "gp", "002") + + @action(auto_prefix=True, description="关闭设备门。") + async def close_door(self): + _unilab_logger.debug("[UNILAB] CytomatBackend.close_door() called") + return await self.send_action("ll", "gp", "001") + + @action(auto_prefix=True, description="收回传送铲。") + async def shovel_in(self): + _unilab_logger.debug("[UNILAB] CytomatBackend.shovel_in() called") + return await self.send_action("ll", "sp", "001") + + @action(auto_prefix=True, description="伸出传送铲。") + async def shovel_out(self): + _unilab_logger.debug("[UNILAB] CytomatBackend.shovel_out() called") + return await self.send_action("ll", "sp", "002") + + @action(auto_prefix=True, description="获取动作寄存器状态。") + async def get_action_register(self) -> ActionRegisterState: + _unilab_logger.debug("[UNILAB] CytomatBackend.get_action_register() called") + hex_value = await self.send_command("ch", "ba", "") + binary_repr = hex_to_binary(hex_value) + target, action = binary_repr[:3], binary_repr[3:] + + target_enum = None + for action_type_member in ActionType: + if int(target, 2) == int(action_type_member.value, 16): + target_enum = action_type_member + break + assert target_enum is not None, f"Unknown target value: {target}" + + action_enum = None + for action_register_member in ActionRegister: + if int(action, base=2) == int(action_register_member.value, base=16): + action_enum = action_register_member + break + assert action_enum is not None, f"Unknown value: {action}" + + return ActionRegisterState(target=target_enum, action=action_enum) + + @action(auto_prefix=True, description="获取交换寄存器状态。") + async def get_swap_register(self) -> SwapStationState: + _unilab_logger.debug("[UNILAB] CytomatBackend.get_swap_register() called") + value = await self.send_command("ch", "sw", "") + return SwapStationState( + position=SwapStationPosition(int(value[0])), + load_status_front_of_gate=LoadStatusFrontOfGate(int(value[1])), + load_status_at_processor=LoadStatusAtProcessor(int(value[2])), + ) + + @action(auto_prefix=True, description="获取传感器寄存器状态。") + async def get_sensor_register(self) -> SensorStates: + _unilab_logger.debug("[UNILAB] CytomatBackend.get_sensor_register() called") + hex_value = await self.send_command("ch", "ts", "") + binary_values = hex_to_base_twelve(hex_value) + return SensorStates( + **{member.name: bool(int(binary_values[member.value])) for member in SensorRegister} + ) + + @action(auto_prefix=True, description="将微孔板从传送位移入存储位。") + async def action_transfer_to_storage( # used by insert_plate + self, site: PlateHolder + ) -> OverviewRegisterState: + _unilab_logger.debug("[UNILAB] CytomatBackend.action_transfer_to_storage() called") + """Open lift door, retrieve from transfer, close door, place at storage""" + return await self.send_action("mv", "ts", self._site_to_firmware_string(site), timeout=120) + + @action(auto_prefix=True, description="将微孔板从存储位送到传送位。") + async def action_storage_to_transfer( # used by retrieve_plate + self, site: PlateHolder + ) -> OverviewRegisterState: + _unilab_logger.debug("[UNILAB] CytomatBackend.action_storage_to_transfer() called") + """Retrieve from storage, open door, move to transfer, close door""" + return await self.send_action("mv", "st", self._site_to_firmware_string(site)) + + @action(auto_prefix=True, description="将微孔板从存储位送到等待位。") + async def action_storage_to_wait(self, site: PlateHolder) -> OverviewRegisterState: + _unilab_logger.debug("[UNILAB] CytomatBackend.action_storage_to_wait() called") + """Retrieve from storage, move to wait position""" + return await self.send_action("mv", "sw", self._site_to_firmware_string(site)) + + @action(auto_prefix=True, description="将微孔板从等待位送回存储位。") + async def action_wait_to_storage(self, site: PlateHolder) -> OverviewRegisterState: + _unilab_logger.debug("[UNILAB] CytomatBackend.action_wait_to_storage() called") + """Move from wait to storage, unload, return to wait""" + return await self.send_action("mv", "ws", self._site_to_firmware_string(site)) + + @action(auto_prefix=True, description="将微孔板从等待位送到传送位。") + async def action_wait_to_transfer(self) -> OverviewRegisterState: + _unilab_logger.debug("[UNILAB] CytomatBackend.action_wait_to_transfer() called") + """Open door, place on transfer, return to wait, close door""" + return await self.send_action("mv", "wt", "") + + @action(auto_prefix=True, description="将微孔板从传送位移到等待位。") + async def action_transfer_to_wait(self) -> OverviewRegisterState: + _unilab_logger.debug("[UNILAB] CytomatBackend.action_transfer_to_wait() called") + """Open door, retrieve from transfer, return to wait, close door""" + return await self.send_action("mv", "tw", "") + + @action(auto_prefix=True, description="将微孔板从等待位送到外部暴露位。") + async def action_wait_to_exposed(self) -> OverviewRegisterState: + _unilab_logger.debug("[UNILAB] CytomatBackend.action_wait_to_exposed() called") + """Move from wait to exposed position outside device""" + return await self.send_action("mv", "wh", "") + + @action(auto_prefix=True, description="将微孔板从外部暴露位送回等待位。") + async def action_exposed_to_wait(self) -> OverviewRegisterState: + _unilab_logger.debug("[UNILAB] CytomatBackend.action_exposed_to_wait() called") + """Return to wait from exposed, close door""" + return await self.send_action("mv", "hw", "") + + @action(auto_prefix=True, description="将微孔板从外部暴露位送回存储位。") + async def action_exposed_to_storage(self, site: PlateHolder) -> OverviewRegisterState: + _unilab_logger.debug("[UNILAB] CytomatBackend.action_exposed_to_storage() called") + """Return with MTP from exposed to storage, move to wait, close door""" + return await self.send_action("mv", "hs", self._site_to_firmware_string(site)) + + @action(auto_prefix=True, description="将微孔板从存储位送到外部暴露位。") + async def action_storage_to_exposed(self, site: PlateHolder) -> OverviewRegisterState: + _unilab_logger.debug("[UNILAB] CytomatBackend.action_storage_to_exposed() called") + """Move from wait to storage, load MTP, transport to exposed""" + return await self.send_action("mv", "sh", self._site_to_firmware_string(site)) + + @action(auto_prefix=True, description="读取指定存储位的条码。") + async def action_read_barcode( + self, + site_number_a: str, + site_number_b: str, + ) -> OverviewRegisterState: + _unilab_logger.debug("[UNILAB] CytomatBackend.action_read_barcode() called") + # Read barcode of storage locations + validate_storage_location_number(site_number_a) + validate_storage_location_number(site_number_b) + resp = await self.send_command("mv", "sn", f"{site_number_a} {site_number_b}") + return OverviewRegisterState.from_resp(resp) + + @action(auto_prefix=True, description="等待传送位变为占用或空闲状态。") + async def wait_for_transfer_station(self, occupied: bool = False): + _unilab_logger.debug("[UNILAB] CytomatBackend.wait_for_transfer_station() called") + """Wait for the transfer station to be occupied, or unoccupied.""" + while (await self.get_overview_register()).transfer_station_occupied != occupied: + await asyncio.sleep(1) + + @action(auto_prefix=True, description="等待当前任务完成。") + async def wait_for_task_completion(self, timeout=60) -> OverviewRegisterState: + _unilab_logger.debug("[UNILAB] CytomatBackend.wait_for_task_completion() called") + """ + Wait for the cytomat to finish the current task. This is done by checking the overview register + until the busy bit is not set. If the cytomat is busy for too long, a TimeoutError is raised. + If the error bit is set in the overview register, the error register is read and the corresponding + error is raised. + """ + start = time.time() + while True: + overview_register = await self.get_overview_register() + if not overview_register.busy_bit_set: + # only check for errors once the cytomat is done, so that the user has the chance to + # handle the error and proceed if desired. + if overview_register.error_register_set: + error_register = await self.get_error_register() + await self.reset_error_register() + raise error_register_map[error_register] + return overview_register + await asyncio.sleep(1) + if time.time() - start > timeout: + raise TimeoutError("Cytomat did not complete task in time") + + @action(auto_prefix=True, description="初始化内部振荡模块。") + async def init_shakers(self): + _unilab_logger.debug("[UNILAB] CytomatBackend.init_shakers() called") + return hex_to_binary(await self.send_command("ll", "vi", "")) + + @action(auto_prefix=True, description="按设定频率开始振荡。") + async def start_shaking(self, frequency: float, shakers: Optional[List[int]] = None): + _unilab_logger.debug("[UNILAB] CytomatBackend.start_shaking() called") + if self.model == CytomatType.C5C: + raise NotImplementedError("Shaking is not supported on this model") + await self.set_shaking_frequency(frequency=int(frequency), shakers=shakers) + return hex_to_binary(await self.send_command("ll", "va", "")) + + @action(auto_prefix=True, description="停止振荡。") + async def stop_shaking(self): + _unilab_logger.debug("[UNILAB] CytomatBackend.stop_shaking() called") + if self.model == CytomatType.C5C: + raise NotImplementedError("Shaking is not supported on this model") + return hex_to_binary(await self.send_command("ll", "vd", "")) + + @action(auto_prefix=True, description="设置振荡频率。") + async def set_shaking_frequency( + self, frequency: int, shakers: Optional[List[int]] = None + ) -> List[str]: + _unilab_logger.debug("[UNILAB] CytomatBackend.set_shaking_frequency() called") + shakers = shakers or [1, 2] + assert all(shaker in [1, 2] for shaker in shakers), "Shaker index must be 1 or 2" + return [await self.send_command("se", f"pb 2{idx-1}", f"{frequency:04}") for idx in shakers] + + @action(auto_prefix=True, description="获取指定培养查询值。") + async def get_incubation_query( + self, query: Literal["ic", "ih", "io", "it"] + ) -> CytomatIncupationResponse: + _unilab_logger.debug("[UNILAB] CytomatBackend.get_incubation_query() called") + resp = await self.send_command("ch", query, "") + nominal, actual = resp.split() + return CytomatIncupationResponse( + nominal_value=float(nominal.lstrip("+")), actual_value=float(actual.lstrip("+")) + ) + + @action(auto_prefix=True, description="获取CO2浓度。") + async def get_co2(self) -> CytomatIncupationResponse: + _unilab_logger.debug("[UNILAB] CytomatBackend.get_co2() called") + return await self.get_incubation_query("ic") + + @action(auto_prefix=True, description="获取湿度。") + async def get_humidity(self) -> CytomatIncupationResponse: + _unilab_logger.debug("[UNILAB] CytomatBackend.get_humidity() called") + return await self.get_incubation_query("ih") + + @action(auto_prefix=True, description="获取O2浓度。") + async def get_o2(self) -> CytomatIncupationResponse: + _unilab_logger.debug("[UNILAB] CytomatBackend.get_o2() called") + return await self.get_incubation_query("io") + + @action(auto_prefix=True, description="获取温度。") + async def get_temperature(self) -> float: + _unilab_logger.debug("[UNILAB] CytomatBackend.get_temperature() called") + return (await self.get_incubation_query("it")).actual_value + + @action(auto_prefix=True, description="将指定微孔板取到上样托盘。") + async def fetch_plate_to_loading_tray(self, plate: Plate): + _unilab_logger.debug("[UNILAB] CytomatBackend.fetch_plate_to_loading_tray() called") + site = plate.parent + assert isinstance(site, PlateHolder) + await self.action_storage_to_transfer(site) + + @action(auto_prefix=True, description="将微孔板收入指定存储位。") + async def take_in_plate(self, plate: Plate, site: PlateHolder): + _unilab_logger.debug("[UNILAB] CytomatBackend.take_in_plate() called") + await self.action_transfer_to_storage(site) + + @action(auto_prefix=True, description="设置温度。") + async def set_temperature(self, *args, **kwargs): + _unilab_logger.debug("[UNILAB] CytomatBackend.set_temperature() called") + raise NotImplementedError("Temperature control is not implemented yet") + + @action(auto_prefix=True, description="导出设备配置或状态信息。") + def serialize(self) -> dict: + _unilab_logger.debug("[UNILAB] CytomatBackend.serialize() called") + return { + **IncubatorBackend.serialize(self), + "model": self.model.value, + "port": self.io.port if self.io is not None else "", + } + + +class CytomatChatterbox(CytomatBackend): + async def setup(self): + await self.wait_for_task_completion() + + async def stop(self): + print("closing connection to cytomat") + + async def send_command(self, command_type, command, params): + print( + "cytomat", self._assemble_command(command_type=command_type, command=command, params=params) + ) + if command_type == "ch": + return "0" + return "0" * 8 + + async def wait_for_transfer_station(self, occupied: bool = False): + # send the command, but don't wait when we are in chatting mode. + _ = await self.get_overview_register() + + +# Deprecated alias with warning # TODO: remove mid May 2025 (giving people 1 month to update) +# https://github.com/PyLabRobot/pylabrobot/issues/466 + + +class Cytomat: + def __init__(self, *args, **kwargs): + raise RuntimeError("`Cytomat` is deprecated. Please use `CytomatBackend` instead. ") diff --git a/unilabos/devices/_phage_display/guessed_agilent_biotek_406_fx.py b/unilabos/devices/_phage_display/guessed_agilent_biotek_406_fx.py new file mode 100644 index 000000000..588b522a9 --- /dev/null +++ b/unilabos/devices/_phage_display/guessed_agilent_biotek_406_fx.py @@ -0,0 +1,45 @@ +""" +Guessed driver for Agilent BioTek 406 FX Washer Dispenser. +Generated from deep-search evidence; not tested against real hardware. +""" +from unilabos.registry.decorators import action, device + + +@device( + id="agilent_biotek_406_fx", + category=["plate_washer_dispenser"], + description="一体式微孔板洗涤与批量加液工作站,可用于细胞板、ELISA板等微孔板的洗涤、缓冲液或试剂分配,以及自动化流程中的方法调用与运行状态管理。", + model={ + "type": "device", + "mesh": "agilent_biotek_406_fx", + "path": "https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/devices/agilent_biotek_406_fx/macro_device.xacro", + }, +) +class AgilentBioTek406FX: + def __init__(self, device_id=None, config=None, **kwargs): + self.device_id = device_id or "agilent_biotek_406_fx" + self.config = config or {} + + @action() + def prime_fluidics(self, manifold: str = "dual_action", reagent: str = "wash_buffer") -> dict: + """对洗板/分液流路进行预充液或预冲洗,为后续运行建立稳定液路状态。""" + print(f"[{self.device_id}] Priming manifold={manifold} with reagent='{reagent}' for the next wash/dispense cycle") + return {"success": True} + + @action() + def wash_plate(self, plate_id: str, protocol_name: str = "cell_binding_wash", cycle_count: int = 3) -> dict: + """按指定方法对微孔板执行自动洗涤流程。""" + print(f"[{self.device_id}] Washing plate {plate_id} with protocol '{protocol_name}' for {cycle_count} cycles") + return {"success": True} + + @action() + def dispense_bulk_reagent(self, plate_id: str, reagent: str, volume_ul: float) -> dict: + """向目标微孔板批量分配缓冲液、培养基或其他试剂。""" + print(f"[{self.device_id}] Dispensing reagent '{reagent}' to plate {plate_id} at volume={volume_ul} uL per well") + return {"success": True} + + @action() + def get_status(self) -> dict: + """查询设备就绪状态、当前模块状态和任务执行状态。""" + print(f"[{self.device_id}] Reading washer/dispenser status, manifold readiness, and current run state") + return {"success": True} diff --git a/unilabos/devices/_phage_display/guessed_applied_biosystems_seqstudio_genetic_analyzer.py b/unilabos/devices/_phage_display/guessed_applied_biosystems_seqstudio_genetic_analyzer.py new file mode 100644 index 000000000..605457c19 --- /dev/null +++ b/unilabos/devices/_phage_display/guessed_applied_biosystems_seqstudio_genetic_analyzer.py @@ -0,0 +1,47 @@ +""" +Guessed driver for the Applied Biosystems SeqStudio Genetic Analyzer. +Generated from deep-search evidence; not tested against real hardware. +""" +from typing import Any, Dict, Optional + +from unilabos.registry.decorators import action, device + + +@device( + id="applied_biosystems_seqstudio_genetic_analyzer", + category=["dna_sequencer", "sanger_sequencer"], + description="毛细管电泳型遗传分析仪,可执行样本载具装载、Sanger测序运行、运行状态查询以及序列数据与分析结果导出。", + model={ + "type": "device", + "mesh": "applied_biosystems_seqstudio_genetic_analyzer", + "path": "https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/devices/applied_biosystems_seqstudio_genetic_analyzer/macro_device.xacro", + }, +) +class AppliedBiosystemsSeqStudioGuessed: + def __init__(self, device_id: Optional[str] = None, config: Optional[Dict[str, Any]] = None, **kwargs): + self.device_id = device_id or "applied_biosystems_seqstudio_genetic_analyzer" + self.config = config or {} + + @action() + def load_carrier(self, carrier_id: str, sample_format: str = "96_well_plate") -> dict: + """装载测序板或条带式样本载具。""" + print(f"[{self.device_id}] Carrier accepted: id={carrier_id}, format={sample_format}, capillaries=4") + return {"success": True, "carrier_id": carrier_id} + + @action() + def start_sanger_run(self, run_name: str, chemistry: str = "BigDye") -> dict: + """启动一次Sanger测序运行。""" + print(f"[{self.device_id}] Run started: name={run_name}, chemistry={chemistry}, module=sanger_sequence") + return {"success": True, "run_name": run_name} + + @action() + def get_run_status(self) -> dict: + """查询当前测序运行状态。""" + print(f"[{self.device_id}] Status query: run_state=idle, remote_monitoring=thermo_fisher_connect") + return {"success": True, "state": "idle"} + + @action() + def export_sequence_data(self, run_name: str) -> dict: + """导出序列结果及相关运行数据。""" + print(f"[{self.device_id}] Data export complete: run={run_name}, outputs=sequence_calls+analysis_reports") + return {"success": True, "run_name": run_name} diff --git a/unilabos/devices/_phage_display/guessed_bd_facsmelody.py b/unilabos/devices/_phage_display/guessed_bd_facsmelody.py new file mode 100644 index 000000000..d14bfdaa1 --- /dev/null +++ b/unilabos/devices/_phage_display/guessed_bd_facsmelody.py @@ -0,0 +1,59 @@ +""" +Guessed driver for the BD FACSMelody Cell Sorter. +Generated from deep-search evidence; not tested against real hardware. +""" +from typing import Any, Dict, Optional + +from unilabos.registry.decorators import action, device + + +@device( + id="bd_facsmelody", + category=["flow_cytometer", "cell_sorter"], + description="流式细胞分析与分选仪,可执行仪器初始化、门控设置、样本事件采集、目标群体分选以及实验数据导出。", + model={ + "type": "device", + "mesh": "bd_facsmelody", + "path": "https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/devices/bd_facsmelody/macro_device.xacro", + }, +) +class BDFACSMelodyGuessed: + def __init__(self, device_id: Optional[str] = None, config: Optional[Dict[str, Any]] = None, **kwargs): + self.device_id = device_id or "bd_facsmelody" + self.config = config or {} + + @action() + def initialize_instrument(self, sample_name: str) -> dict: + """初始化光学、流路和分选相关子系统。""" + print(f"[{self.device_id}] Fluidics prime complete: sample={sample_name}, sheath=stable, lasers=ready") + return {"success": True, "sample_name": sample_name} + + @action() + def set_gate(self, gate_name: str, fsc_ssc_region: str, fluorescence_channel: str, threshold: float) -> dict: + """配置目标门控区域及相关荧光阈值参数。""" + print( + f"[{self.device_id}] Gate programmed: name={gate_name}, region={fsc_ssc_region}, " + f"channel={fluorescence_channel}, threshold={threshold:.2f}" + ) + return {"success": True, "gate_name": gate_name} + + @action() + def analyze_sample(self, sample_position: str, max_events: int = 50000) -> dict: + """采集样本事件数据以进行分析或确认门控设置。""" + print(f"[{self.device_id}] Acquisition running: sample={sample_position}, event_limit={max_events}, mode=analysis") + return {"success": True, "sample_position": sample_position} + + @action() + def sort_cells(self, collection_target: str, sort_fraction_percent: float = 5.0) -> dict: + """将目标细胞群分选至指定收集位置。""" + print( + f"[{self.device_id}] Sort in progress: target={collection_target}, " + f"enrichment_fraction={sort_fraction_percent:.2f}%, nozzle=cell_sort" + ) + return {"success": True, "collection_target": collection_target} + + @action() + def export_run_data(self, run_label: str) -> dict: + """导出实验统计结果及事件数据。""" + print(f"[{self.device_id}] Export queued: run={run_label}, outputs=stats+events, destination=laboratory_lims") + return {"success": True, "run_label": run_label} diff --git a/unilabos/devices/_phage_display/guessed_cytiva_akta_pure.py b/unilabos/devices/_phage_display/guessed_cytiva_akta_pure.py new file mode 100644 index 000000000..d91eb5c09 --- /dev/null +++ b/unilabos/devices/_phage_display/guessed_cytiva_akta_pure.py @@ -0,0 +1,51 @@ +""" +Guessed driver for Cytiva AKTA pure — automated protein purification system. +Generated from deep-search evidence; not tested against real hardware. +""" +from unilabos.registry.decorators import device, action + + +@device( + id="cytiva_akta_pure", + category=["protein_purification_system"], + description="自动蛋白纯化系统,可执行色谱柱平衡、样品上样、洗柱、洗脱、分段收集以及纯化结果导出等液相色谱纯化流程。", + model={ + "type": "device", + "mesh": "cytiva_akta_pure", + "path": "https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/devices/cytiva_akta_pure/macro_device.xacro", + }, +) +class CytivaAKTAPure: + def __init__(self, device_id=None, config=None, **kwargs): + self.device_id = device_id or "cytiva_akta_pure" + self.config = config or {} + + @action() + def equilibrate_column(self, method_name: str, buffer_name: str, column_id: str) -> dict: + """按指定方法完成色谱柱平衡与基线准备。""" + print(f"[{self.device_id}] Equilibrating column {column_id} with buffer={buffer_name} using method={method_name}") + return {"success": True} + + @action() + def load_sample(self, sample_id: str, volume_ml: float, target_column: str) -> dict: + """将样本加载到目标色谱柱上。""" + print(f"[{self.device_id}] Loading sample {sample_id}, volume={volume_ml} mL, onto column={target_column}") + return {"success": True} + + @action() + def wash_column(self, target_column: str, wash_buffer: str, wash_volume_ml: float) -> dict: + """执行色谱柱洗涤步骤以去除非目标成分。""" + print(f"[{self.device_id}] Washing column {target_column} with buffer={wash_buffer} for {wash_volume_ml} mL") + return {"success": True} + + @action() + def elute_fraction(self, target_column: str, elution_buffer: str, fraction_volume_ml: float = 1.0) -> dict: + """执行洗脱并按设定体积分段收集样品。""" + print(f"[{self.device_id}] Eluting column {target_column} with buffer={elution_buffer}, fraction_volume={fraction_volume_ml} mL") + return {"success": True} + + @action() + def export_purification_report(self, run_id: str) -> dict: + """导出色谱图、分段信息和纯化运行记录。""" + print(f"[{self.device_id}] Exporting purification report for run {run_id}") + return {"success": True} diff --git a/unilabos/devices/_phage_display/guessed_cytiva_biacore_8k_plus.py b/unilabos/devices/_phage_display/guessed_cytiva_biacore_8k_plus.py new file mode 100644 index 000000000..0d9a7401d --- /dev/null +++ b/unilabos/devices/_phage_display/guessed_cytiva_biacore_8k_plus.py @@ -0,0 +1,51 @@ +""" +Guessed driver for Cytiva Biacore 8K+ — high-throughput SPR affinity analyzer. +Generated from deep-search evidence; not tested against real hardware. +""" +from unilabos.registry.decorators import device, action + + +@device( + id="cytiva_biacore_8k_plus", + category=["spr_affinity_analyzer"], + description="高通量表面等离子共振分析仪,可执行流路预处理、配体固定、样本结合测定、特异性比较以及传感图与动力学结果导出。", + model={ + "type": "device", + "mesh": "cytiva_biacore_8k_plus", + "path": "https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/devices/cytiva_biacore_8k_plus/macro_device.xacro", + }, +) +class CytivaBiacore8KPlus: + def __init__(self, device_id=None, config=None, **kwargs): + self.device_id = device_id or "cytiva_biacore_8k_plus" + self.config = config or {} + + @action() + def prime_system(self, method_name: str = "protein_binding_screen") -> dict: + """对SPR流路进行预处理并加载检测方法。""" + print(f"[{self.device_id}] Priming Biacore fluidics for method={method_name} with fresh running buffer") + return {"success": True} + + @action() + def immobilize_ligand(self, chip_id: str, ligand_name: str, target_ru: float) -> dict: + """在传感芯片表面固定目标配体或对照配体。""" + print(f"[{self.device_id}] Immobilizing ligand {ligand_name} on chip {chip_id} to target_RU={target_ru}") + return {"success": True} + + @action() + def measure_binding(self, analyte_batch_id: str, concentration_series: str, flow_cell: str = "FC2") -> dict: + """执行样本与固定配体之间的结合测定。""" + print(f"[{self.device_id}] Measuring binding for batch {analyte_batch_id} on {flow_cell} with series={concentration_series}") + return {"success": True} + + @action() + def compare_specificity_panel(self, analyte_batch_id: str, panel_name: str) -> dict: + """对指定样本执行特异性或交叉反应比较测试。""" + print(f"[{self.device_id}] Comparing specificity for batch {analyte_batch_id} against panel={panel_name}") + return {"success": True} + + @action() + def export_sensorgram_report(self, run_id: str) -> dict: + """导出传感图以及动力学和亲和力分析结果。""" + print(f"[{self.device_id}] Exporting sensorgram and kinetics report for run {run_id}") + return {"success": True} diff --git a/unilabos/devices/_phage_display/guessed_eppendorf_centrifuge_5910_ri.py b/unilabos/devices/_phage_display/guessed_eppendorf_centrifuge_5910_ri.py new file mode 100644 index 000000000..7758ff562 --- /dev/null +++ b/unilabos/devices/_phage_display/guessed_eppendorf_centrifuge_5910_ri.py @@ -0,0 +1,48 @@ +""" +Guessed driver for Eppendorf Centrifuge 5910 Ri. +Generated from deep-search evidence; not tested against real hardware. +""" +from unilabos.registry.decorators import action, device + + +@device( + id="eppendorf_centrifuge_5910_ri", + category=["refrigerated_high_speed_centrifuge"], + description="冷冻台式离心机,可在受控温度下执行样本离心、转子与适配器配置确认、运行结束解锁以及运行记录导出。", + model={ + "type": "device", + "mesh": "eppendorf_centrifuge_5910_ri", + "path": "https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/devices/eppendorf_centrifuge_5910_ri/macro_device.xacro", + }, +) +class EppendorfCentrifuge5910Ri: + def __init__(self, device_id=None, config=None, **kwargs): + self.device_id = device_id or "eppendorf_centrifuge_5910_ri" + self.config = config or {} + + @action() + def load_rotor_setup(self, rotor_name: str, vessel_type: str) -> dict: + """确认当前批次所需的转子和适配器配置。""" + print(f"[{self.device_id}] Rotor setup confirmed: rotor={rotor_name}, vessel_type={vessel_type}, status=ready") + return {"success": True} + + @action() + def spin_samples(self, batch_id: str, relative_g: float, duration_minutes: float, temperature_c: float = 20.0) -> dict: + """按设定离心力、时间和温度执行离心程序。""" + print( + f"[{self.device_id}] Spin program started: batch={batch_id}, g={relative_g:.0f}, " + f"time={duration_minutes:.1f} min, temp={temperature_c:.1f} C" + ) + return {"success": True} + + @action() + def park_and_unlock(self) -> dict: + """在运行结束后使设备进入可安全取样的状态。""" + print(f"[{self.device_id}] Rotor parked, chamber unlocked, unload state confirmed") + return {"success": True} + + @action() + def export_run_log(self, run_id: str) -> dict: + """导出运行记录、状态信息和完成结果。""" + print(f"[{self.device_id}] Exporting run log for run={run_id}, outputs=history+events+status") + return {"success": True} diff --git a/unilabos/devices/_phage_display/guessed_hettich_rotanta_460_robotic.py b/unilabos/devices/_phage_display/guessed_hettich_rotanta_460_robotic.py new file mode 100644 index 000000000..c7ed33758 --- /dev/null +++ b/unilabos/devices/_phage_display/guessed_hettich_rotanta_460_robotic.py @@ -0,0 +1,90 @@ +""" +Guessed driver for Hettich ROTANTA 460 Robotic. +Generated from deep-search evidence; not tested against real hardware. +""" +from typing import Any, Dict, Optional + +from unilabos.registry.decorators import action, device + + +@device( + id="hettich_rotanta_460_robotic", + category=["robotic_centrifuge", "refrigerated_centrifuge"], + description="面向自动化系统的冷冻机器人离心机,具备机器人取放接口、载具装载、受控温度离心和设备状态反馈能力。", + model={ + "type": "device", + "mesh": "hettich_rotanta_460_robotic", + "path": "https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/devices/hettich_rotanta_460_robotic/macro_device.xacro", + }, +) +class HettichRotanta460RoboticGuessed: + MAX_RCF = 6446.0 + MIN_TEMP_C = -20.0 + MAX_TEMP_C = 40.0 + + def __init__(self, device_id: Optional[str] = None, config: Optional[Dict[str, Any]] = None, **kwargs): + self.device_id = device_id or "hettich_rotanta_460_robotic" + self.config = config or {} + + @action() + def prepare_robotic_hatch(self, hatch_side: str = "front", positioning_mode: str = "fast") -> dict: + """准备机器人取放所需的舱口和转子定位模式。""" + print( + f"[{self.device_id}] Robotic hatch prepared: hatch_side={hatch_side}, " + f"positioning_mode={positioning_mode}, access_state=ready" + ) + return {"success": True, "hatch_side": hatch_side, "positioning_mode": positioning_mode} + + @action() + def load_carrier(self, carrier_id: str, carrier_type: str) -> dict: + """登记并装载通过机器人接口进入设备的样本载具。""" + print( + f"[{self.device_id}] Carrier load acknowledged: carrier={carrier_id}, " + f"type={carrier_type}, rotor_recognition=expected" + ) + return {"success": True, "carrier_id": carrier_id, "carrier_type": carrier_type} + + @action() + def spin_samples( + self, + batch_id: str, + relative_g: float, + duration_minutes: float, + temperature_c: float = 20.0, + ) -> dict: + """在设备允许范围内按设定参数执行离心程序。""" + if relative_g > self.MAX_RCF: + return { + "success": False, + "error": f"requested_rcf_exceeds_documented_max:{self.MAX_RCF:.0f}", + "requested_rcf": relative_g, + } + if temperature_c < self.MIN_TEMP_C or temperature_c > self.MAX_TEMP_C: + return { + "success": False, + "error": f"requested_temperature_out_of_range:{self.MIN_TEMP_C:.0f}_to_{self.MAX_TEMP_C:.0f}", + "requested_temperature_c": temperature_c, + } + + print( + f"[{self.device_id}] Spin program started: batch={batch_id}, g={relative_g:.0f}, " + f"time={duration_minutes:.1f} min, temp={temperature_c:.1f} C, interface=rs232_assumed" + ) + return {"success": True, "batch_id": batch_id} + + @action() + def report_status(self) -> dict: + """返回可供自动化系统使用的设备状态信息。""" + status = { + "success": True, + "door_state": "closed", + "hatch_state": "closed", + "rotor_recognized": True, + "communication": "rs232_assumed", + "manual_backup_available": True, + } + print( + f"[{self.device_id}] Status report: hatch={status['hatch_state']}, " + f"rotor_recognized={status['rotor_recognized']}, communication={status['communication']}" + ) + return status diff --git a/unilabos/devices/_phage_display/guessed_molecular_devices_qpix_420.py b/unilabos/devices/_phage_display/guessed_molecular_devices_qpix_420.py new file mode 100644 index 000000000..bd9565f11 --- /dev/null +++ b/unilabos/devices/_phage_display/guessed_molecular_devices_qpix_420.py @@ -0,0 +1,53 @@ +""" +Guessed driver for the Molecular Devices QPix 420 colony picking system. +Generated from deep-search evidence; not tested against real hardware. +""" +from typing import Any, Dict, Optional + +from unilabos.registry.decorators import action, device + + +@device( + id="molecular_devices_qpix_420", + category=["colony_picker", "microbial_screening"], + description="微生物菌落挑选系统,可执行培养平板成像、菌落识别与筛选、自动挑取接种以及挑菌结果导出。", + model={ + "type": "device", + "mesh": "molecular_devices_qpix_420", + "path": "https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/devices/molecular_devices_qpix_420/macro_device.xacro", + }, +) +class MolecularDevicesQPix420Guessed: + def __init__(self, device_id: Optional[str] = None, config: Optional[Dict[str, Any]] = None, **kwargs): + self.device_id = device_id or "molecular_devices_qpix_420" + self.config = config or {} + + @action() + def image_plate(self, source_plate_id: str) -> dict: + """采集源平板图像以供菌落识别和分析。""" + print(f"[{self.device_id}] Imaging sweep complete: source_plate={source_plate_id}, illumination=colony_scan") + return {"success": True, "source_plate_id": source_plate_id} + + @action() + def select_colonies(self, selection_mode: str, max_colonies: int) -> dict: + """按设定规则筛选符合条件的菌落。""" + print( + f"[{self.device_id}] Selection criteria applied: mode={selection_mode}, " + f"requested_colonies={max_colonies}, contamination_filter=enabled" + ) + return {"success": True, "max_colonies": max_colonies} + + @action() + def pick_colonies(self, source_plate_id: str, destination_plate_id: str, colony_count: int) -> dict: + """将选中的菌落自动转移至目标培养板。""" + print( + f"[{self.device_id}] Pick run active: source={source_plate_id}, destination={destination_plate_id}, " + f"colonies={colony_count}, sterile_cycle=uv+wash+dry" + ) + return {"success": True, "destination_plate_id": destination_plate_id} + + @action() + def export_pick_report(self, run_label: str) -> dict: + """导出挑菌结果、板位映射和相关运行记录。""" + print(f"[{self.device_id}] Report export complete: run={run_label}, outputs=pick_map+image_metadata") + return {"success": True, "run_label": run_label} diff --git a/unilabos/devices/_phage_display/guessed_qiagen_qiacube_connect.py b/unilabos/devices/_phage_display/guessed_qiagen_qiacube_connect.py new file mode 100644 index 000000000..343c2ff09 --- /dev/null +++ b/unilabos/devices/_phage_display/guessed_qiagen_qiacube_connect.py @@ -0,0 +1,50 @@ +""" +Guessed driver for the QIAGEN QIAcube Connect. +Generated from deep-search evidence; not tested against real hardware. +""" +from typing import Any, Dict, Optional + +from unilabos.registry.decorators import action, device + + +@device( + id="qiagen_qiacube_connect", + category=["nucleic_acid_preparation", "plasmid_prep"], + description="核酸样本制备工作站,可执行预置提取流程、裂解/结合/洗涤/洗脱等步骤,并提供运行状态查询与报告导出。", + model={ + "type": "device", + "mesh": "qiagen_qiacube_connect", + "path": "https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/devices/qiagen_qiacube_connect/macro_device.xacro", + }, +) +class QIAGENQIAcubeConnectGuessed: + def __init__(self, device_id: Optional[str] = None, config: Optional[Dict[str, Any]] = None, **kwargs): + self.device_id = device_id or "qiagen_qiacube_connect" + self.config = config or {} + + @action() + def load_protocol(self, protocol_name: str, kit_name: str) -> dict: + """加载预置样本制备流程及所需试剂盒方案。""" + print(f"[{self.device_id}] Protocol loaded: name={protocol_name}, kit={kit_name}, station=ready") + return {"success": True, "protocol_name": protocol_name} + + @action() + def run_plasmid_prep(self, sample_count: int, elution_volume_ul: float) -> dict: + """执行包含裂解、结合、洗涤和洗脱步骤的样本制备流程。""" + print( + f"[{self.device_id}] Prep run started: samples={sample_count}, " + f"workflow=lysis-bind-wash-elute, elution={elution_volume_ul:.1f}uL" + ) + return {"success": True, "sample_count": sample_count} + + @action() + def get_run_status(self) -> dict: + """查询当前制备流程的运行状态。""" + print(f"[{self.device_id}] Status query: protocol_state=idle, consumables=loaded, deck=closed") + return {"success": True, "state": "idle"} + + @action() + def export_run_report(self, report_name: str) -> dict: + """导出运行结果、样本追踪信息和流程报告。""" + print(f"[{self.device_id}] Report generated: report={report_name}, outputs=run_status+sample_traceability") + return {"success": True, "report_name": report_name} diff --git a/unilabos/devices/_phage_display/guessed_tecan_resolvex_a200.py b/unilabos/devices/_phage_display/guessed_tecan_resolvex_a200.py new file mode 100644 index 000000000..ce590eb92 --- /dev/null +++ b/unilabos/devices/_phage_display/guessed_tecan_resolvex_a200.py @@ -0,0 +1,53 @@ +""" +Guessed driver for the Tecan Resolvex A200 positive-pressure workstation. +Generated from deep-search evidence; not tested against real hardware. +""" +from typing import Any, Dict, Optional + +from unilabos.registry.decorators import action, device + + +@device( + id="tecan_resolvex_a200", + category=["filtration_workstation", "positive_pressure_workstation"], + description="正压式过滤处理工作站,可执行滤板装载、压力程序设置、样本过滤以及处理后板件释放等自动化过滤流程。", + model={ + "type": "device", + "mesh": "tecan_resolvex_a200", + "path": "https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/devices/tecan_resolvex_a200/macro_device.xacro", + }, +) +class TecanResolvexA200Guessed: + def __init__(self, device_id: Optional[str] = None, config: Optional[Dict[str, Any]] = None, **kwargs): + self.device_id = device_id or "tecan_resolvex_a200" + self.config = config or {} + + @action() + def load_filter_plate(self, plate_id: str, membrane_pore_um: float = 0.22) -> dict: + """装载滤板并确认所使用的膜孔径参数。""" + print(f"[{self.device_id}] Filter plate clamped: plate={plate_id}, membrane={membrane_pore_um:.2f}um, stage=sealed") + return {"success": True, "plate_id": plate_id} + + @action() + def set_pressure_profile(self, profile_name: str, pressure_mbar: float, duration_seconds: float) -> dict: + """设置过滤流程所需的正压程序参数。""" + print( + f"[{self.device_id}] Pressure profile armed: profile={profile_name}, " + f"pressure={pressure_mbar:.1f}mbar, duration={duration_seconds:.1f}s" + ) + return {"success": True, "profile_name": profile_name} + + @action() + def filter_samples(self, source_map: str, collection_plate_id: str) -> dict: + """将样本通过滤材转移至目标收集板或收集容器。""" + print( + f"[{self.device_id}] Filtration active: sources={source_map}, collection={collection_plate_id}, " + f"air_path=positive_pressure" + ) + return {"success": True, "collection_plate_id": collection_plate_id} + + @action() + def release_plate(self, plate_id: str) -> dict: + """在处理完成后释放滤板或目标板件。""" + print(f"[{self.device_id}] Clamp release complete: plate={plate_id}, manifold=raised, status=ready_for_unload") + return {"success": True, "plate_id": plate_id} diff --git a/unilabos/devices/_phage_display/guessed_telesis_bio_bioxp_3250.py b/unilabos/devices/_phage_display/guessed_telesis_bio_bioxp_3250.py new file mode 100644 index 000000000..30e4c484b --- /dev/null +++ b/unilabos/devices/_phage_display/guessed_telesis_bio_bioxp_3250.py @@ -0,0 +1,45 @@ +""" +Guessed driver for Telesis Bio BioXp 3250 — automated construct assembly workstation. +Generated from deep-search evidence; not tested against real hardware. +""" +from unilabos.registry.decorators import device, action + + +@device( + id="telesis_bio_bioxp_3250", + category=["synthetic_biology_workstation"], + description="自动化合成生物学工作站,可执行DNA构建流程装载、片段组装、转化相关步骤衔接以及构建结果导出。", + model={ + "type": "device", + "mesh": "telesis_bio_bioxp_3250", + "path": "https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/devices/telesis_bio_bioxp_3250/macro_device.xacro", + }, +) +class TelesisBioBioXp3250: + def __init__(self, device_id=None, config=None, **kwargs): + self.device_id = device_id or "telesis_bio_bioxp_3250" + self.config = config or {} + + @action() + def load_build_workflow(self, workflow_name: str = "expression_vector_assembly") -> dict: + """加载自动化构建流程或预设工作方法。""" + print(f"[{self.device_id}] Loading BioXp workflow {workflow_name} for expression-vector assembly") + return {"success": True} + + @action() + def assemble_vector(self, construct_batch_id: str, template_count: int) -> dict: + """执行目标构建或组装流程。""" + print(f"[{self.device_id}] Assembling construct batch {construct_batch_id} from {template_count} validated templates") + return {"success": True} + + @action() + def transform_cells(self, construct_batch_id: str, host_strain: str = "expression_host") -> dict: + """执行与转化相关的流程步骤或输出衔接。""" + print(f"[{self.device_id}] Advancing construct batch {construct_batch_id} into transformation-ready workflow for host={host_strain}") + return {"success": True} + + @action() + def export_construct_report(self, construct_batch_id: str) -> dict: + """导出构建结果、流程记录和相关元数据。""" + print(f"[{self.device_id}] Exporting construct report for batch {construct_batch_id}") + return {"success": True} diff --git a/unilabos/devices/_phage_display/incubator.py b/unilabos/devices/_phage_display/incubator.py new file mode 100644 index 000000000..c5c6dee87 --- /dev/null +++ b/unilabos/devices/_phage_display/incubator.py @@ -0,0 +1,327 @@ +""" +Auto-generated by Build Agent +Original: pylabrobot/storage/incubator.py + +[UNILAB DEBUG] 此驱动已注入调试日志 +- 每个方法被调用时会打印日志 +- 日志格式: [UNILAB] ClassName.method_name() called +- 查看日志: logging.getLogger("unilab.driver").setLevel(logging.DEBUG) +""" +from unilabos.registry.decorators import action, device +from typing import Dict, Any, Optional +import logging +import warnings as _warnings +_warnings.filterwarnings("ignore", message=r"pkg_resources is deprecated as an API\..*") +import sys as _sys +import types as _types + +# 兼容性:部分上游框架(例如 lantz)在 import-time 强制加载 Qt(PyQt4/PySide1), +# 在 headless/现代 Python 环境中通常不存在这些绑定,会导致导入直接失败。 +# 这里预注入一个最小的 `lantz.utils.qt` shim,避免触发其 Qt loader。 +if "lantz.utils.qt" not in _sys.modules: + _qt_mod = _types.ModuleType("lantz.utils.qt") + + class _DummyQtCore: + class QObject: + def __init__(self, *_args, **_kwargs): + # 兼容 QObject(parent=...) 等调用 + return None + + @staticmethod + def Signal(*_args, **_kwargs): + return object() + + @staticmethod + def Slot(*_args, **_kwargs): + def _decorator(fn): + return fn + + return _decorator + + _qt_mod.QtCore = _DummyQtCore + + class _DummySuperQObject(_DummyQtCore.QObject): + def __new__(cls, *_args, **_kwargs): + return super().__new__(cls) + + def __init__(self, *_args, **_kwargs): + # 允许 Driver(...) 传参,不触发 object.__init__ 的 TypeError + return None + + _qt_mod.MetaQObject = type + _qt_mod.SuperQObject = _DummySuperQObject + _sys.modules["lantz.utils.qt"] = _qt_mod + +# Unilab 驱动调试日志 +_unilab_logger = logging.getLogger("unilab.driver.Incubator") +_unilab_logger.setLevel(logging.DEBUG) +if not _unilab_logger.handlers: + _handler = logging.StreamHandler() + _handler.setFormatter(logging.Formatter("[%(asctime)s] %(message)s")) + _unilab_logger.addHandler(_handler) + +# Vendored repo packages: pylabrobot, pylabrobot +import sys as _sys +from pathlib import Path as _Path +_vendor_root = _Path(__file__).resolve().parent / "_vendor" +if _vendor_root.exists() and str(_vendor_root) not in _sys.path: + _sys.path.insert(0, str(_vendor_root)) + +import random +from typing import List, Literal, Optional, Union, cast + +from pylabrobot.machines import Machine +from pylabrobot.resources import ( + Coordinate, + Plate, + PlateCarrier, + PlateHolder, + Resource, + ResourceNotFoundError, + Rotation, +) +from pylabrobot.serializer import deserialize, serialize + +from pylabrobot.storage.backend import IncubatorBackend + + +class NoFreeSiteError(Exception): + pass + + +@device( + id="incubator", + category=["Automated Microplate Incubator Storage System"], + description="一种用于存放并温控培养微孔板或培养板的自动化实验室培养箱,通常具有多个板位、装载托盘和可开闭门机构,可在设定温度下进行样品孵育,并支持板件取放与振荡混匀。常用于细胞培养、酶反应、样品保温和自动化流程中的板式孵育步骤。", + model={ + "type": "device", + "mesh": "incubator", + "path": "https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/devices/incubator/macro_device.xacro", + }, +) +class Incubator(Machine, Resource): + def __init__( + self, + backend: IncubatorBackend, + name: str, + size_x: float, + size_y: float, + size_z: float, + racks: List[PlateCarrier], + loading_tray_location: Coordinate, + rotation: Optional[Rotation] = None, + category: Optional[str] = None, + model: Optional[str] = None, + ): + print("[UNILAB] Incubator.__init__() called", flush=True) + Machine.__init__(self, backend=backend) + self.backend: IncubatorBackend = backend # fix type + Resource.__init__( + self, + name=name, + size_x=size_x, + size_y=size_y, + size_z=size_z, + rotation=rotation, + category=category, + model=model, + ) + self.loading_tray = PlateHolder( + name=self.name + "_tray", size_x=127.76, size_y=85.48, size_z=0, pedestal_size_z=0 + ) + self.assign_child_resource(self.loading_tray, location=loading_tray_location) + + self._racks = racks + for rack in self._racks: + self.assign_child_resource(rack, location=None) + + @property + @action(auto_prefix=True, description="列出培养箱内的板架或板位信息") + def racks(self) -> List[PlateCarrier]: + _unilab_logger.debug("[UNILAB] Incubator.racks() called") + return self._racks + + async def setup(self, **backend_kwargs): + _unilab_logger.debug("[UNILAB] Incubator.setup() called") + await super().setup() + await self.backend.set_racks(self._racks) + + @action(auto_prefix=True, description="获取空闲板位数量") + def get_num_free_sites(self) -> int: + _unilab_logger.debug("[UNILAB] Incubator.get_num_free_sites() called") + return sum(len(rack.get_free_sites()) for rack in self._racks) + + @action(auto_prefix=True, description="根据板名查找所在板位") + def get_site_by_plate_name(self, plate_name: str) -> PlateHolder: + _unilab_logger.debug("[UNILAB] Incubator.get_site_by_plate_name() called") + for rack in self._racks: + for site in rack.sites.values(): + if site.resource is not None and site.resource.name == plate_name: + return site + raise ResourceNotFoundError(f"Plate {plate_name} not found in incubator '{self.name}'") + + @action(auto_prefix=True, description="将指定培养板从培养箱取到装载托盘") + async def fetch_plate_to_loading_tray(self, plate_name: str) -> Plate: + _unilab_logger.debug("[UNILAB] Incubator.fetch_plate_to_loading_tray() called") + """Fetch a plate from the incubator and put it on the loading tray.""" + + site = self.get_site_by_plate_name(plate_name) + plate = site.resource + assert plate is not None + await self.backend.fetch_plate_to_loading_tray(plate) + plate.unassign() + self.loading_tray.assign_child_resource(plate) + return plate + + def _find_available_sites_sorted(self, plate: Plate) -> List[PlateHolder]: + _unilab_logger.debug("[UNILAB] Incubator._find_available_sites_sorted() called") + """Find all sites that are free and fit the plate, sorted by size.""" + + def _plate_height(p: Plate): + _unilab_logger.debug("[UNILAB] Incubator._plate_height() called") + if p.has_lid(): + # TODO: we can use plr nesting height + # lid.location.z + lid.get_anchor(z="t").z + return p.get_size_z() + 3 + return p.get_size_z() + + available = [ + site + for rack in self._racks + for site in rack.get_free_sites() + if site.get_size_z() >= _plate_height(plate) + ] + if len(available) == 0: + raise NoFreeSiteError( + f"No free site found in incubator '{self.name}' for plate '{plate.name}'" + ) + return sorted(available, key=lambda site: site.get_size_z()) + + @action(auto_prefix=True, description="查找可容纳该培养板的最小合适板位") + def find_smallest_site_for_plate(self, plate: Plate) -> PlateHolder: + _unilab_logger.debug("[UNILAB] Incubator.find_smallest_site_for_plate() called") + return self._find_available_sites_sorted(plate)[0] + + @action(auto_prefix=True, description="随机查找一个可用板位") + def find_random_site(self, plate: Plate) -> PlateHolder: + _unilab_logger.debug("[UNILAB] Incubator.find_random_site() called") + return random.choice(self._find_available_sites_sorted(plate)) + + @action(auto_prefix=True, description="将装载托盘上的培养板放入培养箱指定板位") + async def take_in_plate(self, site: Union[PlateHolder, Literal["random", "smallest"]]): + _unilab_logger.debug("[UNILAB] Incubator.take_in_plate() called") + """Take a plate from the loading tray and put it in the incubator.""" + + plate = cast(Plate, self.loading_tray.resource) + if plate is None: + raise ResourceNotFoundError(f"No plate on the loading tray of incubator '{self.name}'") + + if site == "random": + site = self.find_random_site(plate) + elif site == "smallest": + site = self.find_smallest_site_for_plate(plate) + elif isinstance(site, PlateHolder): + if site not in self._find_available_sites_sorted(plate): + raise ValueError(f"Site {site.name} is not available for plate {plate.name}") + else: + raise ValueError(f"Invalid site: {site}") + await self.backend.take_in_plate(plate, site) + plate.unassign() + site.assign_child_resource(plate) + + @action(auto_prefix=True, description="设置培养箱温度") + async def set_temperature(self, temperature: float): + _unilab_logger.debug("[UNILAB] Incubator.set_temperature() called") + """Set the temperature of the incubator in degrees Celsius.""" + return await self.backend.set_temperature(temperature) + + @action(auto_prefix=True, description="获取培养箱温度") + async def get_temperature(self) -> float: + _unilab_logger.debug("[UNILAB] Incubator.get_temperature() called") + return await self.backend.get_temperature() + + @action(auto_prefix=True, description="打开培养箱门") + async def open_door(self): + _unilab_logger.debug("[UNILAB] Incubator.open_door() called") + return await self.backend.open_door() + + @action(auto_prefix=True, description="关闭培养箱门") + async def close_door(self): + _unilab_logger.debug("[UNILAB] Incubator.close_door() called") + return await self.backend.close_door() + + @action(auto_prefix=True, description="以设定频率开始振荡") + async def start_shaking(self, frequency: float = 1.0): + _unilab_logger.debug("[UNILAB] Incubator.start_shaking() called") + await self.backend.start_shaking(frequency=frequency) + + @action(auto_prefix=True, description="停止振荡") + async def stop_shaking(self): + _unilab_logger.debug("[UNILAB] Incubator.stop_shaking() called") + await self.backend.stop_shaking() + + @action(auto_prefix=True, description="汇总培养箱当前状态与板位占用情况") + def summary(self) -> str: + _unilab_logger.debug("[UNILAB] Incubator.summary() called") + def create_pretty_table(header, *columns) -> str: + _unilab_logger.debug("[UNILAB] Incubator.create_pretty_table() called") + col_widths = [ + max(len(str(item)) for item in [header[i]] + list(columns[i])) for i in range(len(header)) + ] + + def format_row(row, border="|") -> str: + _unilab_logger.debug("[UNILAB] Incubator.format_row() called") + return ( + f"{border} " + + " | ".join(f"{str(row[i]).ljust(col_widths[i])}" for i in range(len(row))) + + f" {border}" + ) + + def separator_line(cross: str = "+", line: str = "-") -> str: + _unilab_logger.debug("[UNILAB] Incubator.separator_line() called") + return cross + cross.join(line * (width + 2) for width in col_widths) + cross + + table = [] + table.append(separator_line()) # Top border + table.append(format_row(header)) + table.append(separator_line()) # Header separator + for row in zip(*columns): + table.append(format_row(row)) + table.append(separator_line()) # Bottom border + return "\n".join(table) + + header = [f"Rack {i}" for i in range(len(self._racks))] + sites = [ + [site.resource.name if site.resource else "" for site in reversed(rack.sites.values())] + for rack in self._racks + ] + return create_pretty_table(header, *sites) + + @action(auto_prefix=True, description="导出培养箱配置或当前布局信息") + def serialize(self): + _unilab_logger.debug("[UNILAB] Incubator.serialize() called") + return { + **Machine.serialize(self), + **Resource.serialize(self), + "backend": self.backend.serialize(), + "racks": [rack.serialize() for rack in self._racks], + "loading_tray_location": serialize(self.loading_tray.location), + } + + @classmethod + @action(auto_prefix=True, description="从已保存数据恢复培养箱配置或布局") + def deserialize(cls, data: dict, allow_marshal: bool = False): + _unilab_logger.debug("[UNILAB] Incubator.deserialize() called") + backend = IncubatorBackend.deserialize(data.pop("backend")) + return cls( + backend=backend, + name=data["name"], + size_x=data["size_x"], + size_y=data["size_y"], + size_z=data["size_z"], + racks=[PlateCarrier.deserialize(rack) for rack in data["racks"]], + loading_tray_location=cast(Coordinate, deserialize(data["loading_tray_location"])), + rotation=Rotation.deserialize(data["rotation"]), + category=data["category"], + model=data["model"], + ) diff --git a/unilabos/devices/_phage_display/incubator_shaker_stack.py b/unilabos/devices/_phage_display/incubator_shaker_stack.py new file mode 100644 index 000000000..2f7ded17d --- /dev/null +++ b/unilabos/devices/_phage_display/incubator_shaker_stack.py @@ -0,0 +1,310 @@ +""" +Auto-generated by Build Agent +Original: pylabrobot/storage/inheco/incubator_shaker.py + +[UNILAB DEBUG] 此驱动已注入调试日志 +- 每个方法被调用时会打印日志 +- 日志格式: [UNILAB] ClassName.method_name() called +- 查看日志: logging.getLogger("unilab.driver").setLevel(logging.DEBUG) +""" +from unilabos.registry.decorators import action, device +from typing import Dict, Any, Optional +import logging +import warnings as _warnings +_warnings.filterwarnings("ignore", message=r"pkg_resources is deprecated as an API\..*") +import sys as _sys +import types as _types + +# 兼容性:部分上游框架(例如 lantz)在 import-time 强制加载 Qt(PyQt4/PySide1), +# 在 headless/现代 Python 环境中通常不存在这些绑定,会导致导入直接失败。 +# 这里预注入一个最小的 `lantz.utils.qt` shim,避免触发其 Qt loader。 +if "lantz.utils.qt" not in _sys.modules: + _qt_mod = _types.ModuleType("lantz.utils.qt") + + class _DummyQtCore: + class QObject: + def __init__(self, *_args, **_kwargs): + # 兼容 QObject(parent=...) 等调用 + return None + + @staticmethod + def Signal(*_args, **_kwargs): + return object() + + @staticmethod + def Slot(*_args, **_kwargs): + def _decorator(fn): + return fn + + return _decorator + + _qt_mod.QtCore = _DummyQtCore + + class _DummySuperQObject(_DummyQtCore.QObject): + def __new__(cls, *_args, **_kwargs): + return super().__new__(cls) + + def __init__(self, *_args, **_kwargs): + # 允许 Driver(...) 传参,不触发 object.__init__ 的 TypeError + return None + + _qt_mod.MetaQObject = type + _qt_mod.SuperQObject = _DummySuperQObject + _sys.modules["lantz.utils.qt"] = _qt_mod + +# Unilab 驱动调试日志 +_unilab_logger = logging.getLogger("unilab.driver.IncubatorShakerStack") +_unilab_logger.setLevel(logging.DEBUG) +if not _unilab_logger.handlers: + _handler = logging.StreamHandler() + _handler.setFormatter(logging.Formatter("[%(asctime)s] %(message)s")) + _unilab_logger.addHandler(_handler) + +# Vendored repo packages: pylabrobot, pylabrobot +import sys as _sys +from pathlib import Path as _Path +_vendor_root = _Path(__file__).resolve().parent / "_vendor" +if _vendor_root.exists() and str(_vendor_root) not in _sys.path: + _sys.path.insert(0, str(_vendor_root)) + +from typing import Dict + +from pylabrobot.machines.machine import Machine +from pylabrobot.resources import Coordinate, Resource, ResourceHolder + +from pylabrobot.storage.inheco.incubator_shaker_backend import InhecoIncubatorShakerStackBackend, InhecoIncubatorShakerUnit + + +@device( + id="incubator_shaker_stack", + category=["Microplate Thermoshaker"], + description="由多个可堆叠孵育振荡单元组成的实验室自动化设备,带有装载托盘,可对样品或微孔板进行温度控制孵育与振荡混匀,常用于自动化培养、反应孵育和样品处理流程。", + model={ + "type": "device", + "mesh": "incubator_shaker_stack", + "path": "https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/devices/incubator_shaker_stack/macro_device.xacro", + }, +) +class IncubatorShakerStack(Resource, Machine): + """Frontend for a stack of INHECO Incubator/Shaker units. + + - Combines Carrier (geometric resource) and Machine (device lifecycle). + - Owns a backend instance for serial communication. + - Handles sequential (not concurrent) setup/teardown across all units. + """ + + def __init__(self, backend: InhecoIncubatorShakerStackBackend): + print("[UNILAB] IncubatorShakerStack.__init__() called", flush=True) + Resource.__init__( + self, + name="inheco_incubator_shaker_stack", + size_x=149.0, + size_y=268.5, + size_z=58.0, # MP: 58, Shaker MP: 88.5, DWP: 104, Shaker DWP: 139 mm + category="incubator_shaker_stack", + ) + + Machine.__init__(self, backend=backend) + self.backend: InhecoIncubatorShakerStackBackend = backend + self.units: list[InhecoIncubatorShakerUnit] = [] + self.loading_trays: list[ResourceHolder] = [] + + @property + @action(auto_prefix=True, description="获取堆叠中已连接单元的数量。") + def num_units(self) -> int: + _unilab_logger.debug("[UNILAB] IncubatorShakerStack.num_units() called") + """Return number of connected units in the stack.""" + return len(self.units) + + # ------------------------------------------------------------------------ + # Lifecycle & Resource setup + # ------------------------------------------------------------------------ + + _incubator_size_z_dict = { + "incubator_mp": 58.0, + "incubator_shaker_mp": 88.5, + "incubator_dwp": 104, + "incubator_shaker_dwp": 139, + } + _incubator_loading_tray_location = { # TODO: rough measurements, verify + "incubator_mp": None, # TODO: add when available + "incubator_shaker_mp": Coordinate(x=30.5, y=-150.5, z=51.2), + "incubator_dwp": None, # TODO: add when available + "incubator_shaker_dwp": Coordinate(x=30.5, y=-150.5, z=51.2), + } + + _possible_tray_y_coordinates = { + "open": -150.5, # TODO: verify by careful testing in controlled geometry setup + "closed": +24.0, + } + + _chamber_z_clearance = 2 + + _acceptable_plate_z_dimensions = { + "incubator_mp": 18 - _chamber_z_clearance, + "incubator_shaker_mp": 50 - _chamber_z_clearance, + "incubator_dwp": 18 - _chamber_z_clearance, + "incubator_shaker_dwp": 53 - _chamber_z_clearance, + } + + _incubator_power_credits_per_type = { + "incubator_mp": 1.0, + "incubator_dwp": 1.25, + "incubator_shaker_mp": 1.6, + "incubator_shaker_dwp": 2.5, + } + + async def setup(self, **backend_kwargs) -> None: + _unilab_logger.debug("[UNILAB] IncubatorShakerStack.setup() called") + """Connect to the stack and build per-unit proxies.""" + + await self.backend.setup(**backend_kwargs) + + self.power_credit = 0.0 + + # Calculate true stack size + stack_size_z = 0.0 + + for i in range(self.backend.number_of_connected_units): + # Create unit proxies + unit = InhecoIncubatorShakerUnit(self.backend, index=i) + self.units.append(unit) + + # Create loading tray resources and calculate their locations + unit_type = self.backend.unit_composition[i] + self.power_credit += self._incubator_power_credits_per_type[unit_type] + unit_size_z = self._incubator_size_z_dict[unit_type] + + loading_tray = ResourceHolder( + size_x=127.76, size_y=85.48, size_z=0, name=f"unit-{i}-loading-tray" + ) + self.loading_trays.append(loading_tray) + + loc = self._incubator_loading_tray_location[unit_type] + if loc is None: + raise ValueError( + f"Loading tray location for unit type {unit_type} is not defined. " "Cannot set up stack." + ) + + self.assign_child_resource( + loading_tray, + location=Coordinate( + x=loc.x, + y=self._possible_tray_y_coordinates[ + "closed" + ], # setup finishes with all loading trays closed + z=stack_size_z + loc.z, + ), + ) + stack_size_z += unit_size_z + + self._size_z = stack_size_z + + assert ( + self.power_credit < 5 + ), f"Too many units: unit composition {self.backend.unit_composition} is exceeding 5 power credit limit. Reduce number of units." + + async def stop(self): + _unilab_logger.debug("[UNILAB] IncubatorShakerStack.stop() called") + """Gracefully stop backend communication.""" + await self.backend.stop() + + @action(auto_prefix=True, description="查询所有单元的装载托盘状态。") + async def request_loading_tray_states(self) -> dict: + _unilab_logger.debug("[UNILAB] IncubatorShakerStack.request_loading_tray_states() called") + """Request loading tray states for all units.""" + + return { + unit_index: await self.backend.request_drawer_status(stack_index=unit_index) + for unit_index in range(self.num_units) + } + + @action(auto_prefix=True, description="查询所有单元的温度控制状态。") + async def request_temperature_control_states(self) -> dict: + _unilab_logger.debug("[UNILAB] IncubatorShakerStack.request_temperature_control_states() called") + """Request temperature control states for all units.""" + + return { + unit_index: await self.backend.is_temperature_control_enabled(stack_index=unit_index) + for unit_index in range(self.num_units) + } + + @action(auto_prefix=True, description="查询所有单元的振荡状态。") + async def request_shaking_states(self) -> dict: + _unilab_logger.debug("[UNILAB] IncubatorShakerStack.request_shaking_states() called") + """Request shaking states for all units.""" + + return { + unit_index: await self.backend.is_shaking_enabled(stack_index=unit_index) + for unit_index in range(self.num_units) + } + + # ------------------------------------------------------------------------ + # Stack to unit master commands + # ------------------------------------------------------------------------ + + @action(auto_prefix=True, description="打开堆叠中的所有单元。") + async def open_all(self) -> None: + _unilab_logger.debug("[UNILAB] IncubatorShakerStack.open_all() called") + """Open all units in the stack.""" + for i in range(self.num_units): + await self.units[i].open() + + @action(auto_prefix=True, description="关闭堆叠中的所有单元。") + async def close_all(self) -> None: + _unilab_logger.debug("[UNILAB] IncubatorShakerStack.close_all() called") + """Close all units in the stack.""" + for i in range(self.num_units): + await self.units[i].close() + + @action(auto_prefix=True, description="启动所有单元的温度控制并设定目标温度。") + async def start_all_temperature_control(self, target_temperature: float) -> None: + _unilab_logger.debug("[UNILAB] IncubatorShakerStack.start_all_temperature_control() called") + """Start temperature control for all units in the stack.""" + for i in range(self.num_units): + await self.units[i].start_temperature_control(target_temperature) + + @action(auto_prefix=True, description="获取所有单元的当前温度。") + async def get_all_temperatures(self) -> Dict[int, float]: + _unilab_logger.debug("[UNILAB] IncubatorShakerStack.get_all_temperatures() called") + """Get current temperature for all units in the stack.""" + temperatures = {} + for i in range(self.num_units): + temp = await self.units[i].get_temperature() + temperatures[i] = temp + return temperatures + + @action(auto_prefix=True, description="停止所有单元的温度控制。") + async def stop_all_temperature_control(self) -> None: + _unilab_logger.debug("[UNILAB] IncubatorShakerStack.stop_all_temperature_control() called") + """Stop temperature control for all units in the stack.""" + for i in range(self.num_units): + await self.units[i].stop_temperature_control() + + @action(auto_prefix=True, description="启动所有单元的振荡。") + async def shake(self, *args, **kwargs) -> None: + _unilab_logger.debug("[UNILAB] IncubatorShakerStack.shake() called") + """Start shaking for all units in the stack.""" + for i in range(self.num_units): + await self.units[i].shake(*args, **kwargs) + + @action(auto_prefix=True, description="停止所有单元的振荡。") + async def stop_all_shaking(self) -> None: + _unilab_logger.debug("[UNILAB] IncubatorShakerStack.stop_all_shaking() called") + """Stop shaking for all units in the stack.""" + for i in range(self.num_units): + await self.units[i].stop_shaking() + + # ------------------------------------------------------------------------ + # Unit accessors + # ------------------------------------------------------------------------ + + def __getitem__(self, index: int) -> InhecoIncubatorShakerUnit: + _unilab_logger.debug("[UNILAB] IncubatorShakerStack.__getitem__() called") + """Access a unit proxy via stack[index].""" + return self.units[index] + + def __len__(self): + _unilab_logger.debug("[UNILAB] IncubatorShakerStack.__len__() called") + """Return number of connected units.""" + return len(self.units) diff --git a/unilabos/devices/_phage_display/li_ha.py b/unilabos/devices/_phage_display/li_ha.py new file mode 100644 index 000000000..4b9dba764 --- /dev/null +++ b/unilabos/devices/_phage_display/li_ha.py @@ -0,0 +1,1706 @@ +""" +Auto-generated by Build Agent +Original: pylabrobot/liquid_handling/backends/tecan/EVO_backend.py + +[UNILAB DEBUG] 此驱动已注入调试日志 +- 每个方法被调用时会打印日志 +- 日志格式: [UNILAB] ClassName.method_name() called +- 查看日志: logging.getLogger("unilab.driver").setLevel(logging.DEBUG) +""" +from unilabos.registry.decorators import action, device +from typing import Dict, Any, Optional +import logging +import warnings as _warnings +_warnings.filterwarnings("ignore", message=r"pkg_resources is deprecated as an API\..*") +import sys as _sys +import types as _types + +# 兼容性:部分上游框架(例如 lantz)在 import-time 强制加载 Qt(PyQt4/PySide1), +# 在 headless/现代 Python 环境中通常不存在这些绑定,会导致导入直接失败。 +# 这里预注入一个最小的 `lantz.utils.qt` shim,避免触发其 Qt loader。 +if "lantz.utils.qt" not in _sys.modules: + _qt_mod = _types.ModuleType("lantz.utils.qt") + + class _DummyQtCore: + class QObject: + def __init__(self, *_args, **_kwargs): + # 兼容 QObject(parent=...) 等调用 + return None + + @staticmethod + def Signal(*_args, **_kwargs): + return object() + + @staticmethod + def Slot(*_args, **_kwargs): + def _decorator(fn): + return fn + + return _decorator + + _qt_mod.QtCore = _DummyQtCore + + class _DummySuperQObject(_DummyQtCore.QObject): + def __new__(cls, *_args, **_kwargs): + return super().__new__(cls) + + def __init__(self, *_args, **_kwargs): + # 允许 Driver(...) 传参,不触发 object.__init__ 的 TypeError + return None + + _qt_mod.MetaQObject = type + _qt_mod.SuperQObject = _DummySuperQObject + _sys.modules["lantz.utils.qt"] = _qt_mod + +# Unilab 驱动调试日志 +_unilab_logger = logging.getLogger("unilab.driver.LiHa") +_unilab_logger.setLevel(logging.DEBUG) +if not _unilab_logger.handlers: + _handler = logging.StreamHandler() + _handler.setFormatter(logging.Formatter("[%(asctime)s] %(message)s")) + _unilab_logger.addHandler(_handler) + +# Vendored repo packages: pylabrobot, pylabrobot +import sys as _sys +from pathlib import Path as _Path +_vendor_root = _Path(__file__).resolve().parent / "_vendor" +if _vendor_root.exists() and str(_vendor_root) not in _sys.path: + _sys.path.insert(0, str(_vendor_root)) + +import asyncio +from abc import ABCMeta, abstractmethod +from typing import ( + Dict, + List, + Optional, + Sequence, + Tuple, + TypeVar, + Union, +) + +from pylabrobot.io.usb import USB +from pylabrobot.liquid_handling.backends.backend import ( + LiquidHandlerBackend, +) +from pylabrobot.liquid_handling.backends.tecan.errors import ( + TecanError, + error_code_to_exception, +) +from pylabrobot.liquid_handling.liquid_classes.tecan import ( + TecanLiquidClass, + get_liquid_class, +) +from pylabrobot.liquid_handling.standard import ( + Drop, + DropTipRack, + MultiHeadAspirationContainer, + MultiHeadAspirationPlate, + MultiHeadDispenseContainer, + MultiHeadDispensePlate, + Pickup, + PickupTipRack, + ResourceDrop, + ResourceMove, + ResourcePickup, + SingleChannelAspiration, + SingleChannelDispense, +) +from pylabrobot.resources import ( + Coordinate, + Liquid, + Resource, + TecanPlate, + TecanPlateCarrier, + TecanTip, + TecanTipRack, + Tip, + TipSpot, + Trash, +) + +T = TypeVar("T") + + +class TecanLiquidHandler(LiquidHandlerBackend, metaclass=ABCMeta): + """ + Abstract base class for Tecan liquid handling robot backends. + """ + + @abstractmethod + def __init__( + self, + packet_read_timeout: int = 120, + read_timeout: int = 300, + write_timeout: int = 300, + ): + """ + + Args: + packet_read_timeout: The timeout for reading packets from the Tecan machine in seconds. + read_timeout: The timeout for reading from the Tecan machine in seconds. + """ + + super().__init__() + self.io = USB( + packet_read_timeout=packet_read_timeout, + read_timeout=read_timeout, + write_timeout=write_timeout, + id_vendor=0x0C47, + id_product=0x4000, + ) + + self._cache: Dict[str, List[Optional[int]]] = {} + + def _assemble_command(self, module: str, command: str, params: List[Optional[int]]) -> str: + """Assemble a firmware command to the Tecan machine. + + Args: + module: 2 character module identifier (C5 for LiHa, ...) + command: 3 character command identifier + params: list of integer parameters + + Returns: + A string containing the assembled command. + """ + + cmd = module + command + ",".join(str(a) if a is not None else "" for a in params) + return f"\02{cmd}\00" + + def parse_response(self, resp: bytes) -> Dict[str, Union[str, int, List[Union[int, str]]]]: + """Parse a machine response string + + Args: + resp: The response string to parse. + + Returns: + A dictionary containing the parsed values. + """ + + s = resp.decode("utf-8", "ignore") + module = s[1:3] + ret = int(resp[3]) ^ (1 << 7) + if ret != 0: + raise error_code_to_exception(module, ret) + + # below line changed to revie int and str. + data: List[Union[int, str]] = [] + for x in s[3:-1].split(","): + if len(x) == 0: # Skip empty values + continue + # RoMa(C1) and LiHa(C5) should only have integer values + # MCA can return both integers and strings + data.append(int(x) if x.lstrip("-").isdigit() else x) + + return {"module": module, "data": data} + + async def send_command( + self, + module: str, + command: str, + params: Optional[List[Optional[int]]] = None, + write_timeout: Optional[int] = None, + read_timeout: Optional[int] = None, + wait=True, + ): + """Send a firmware command to the Tecan machine. Caches `set` commands and ignores if + redundant. + + Args: + module: 2 character module identifier (C5 for LiHa, ...) + command: 3 character command identifier + params: list of integer parameters + write_timeout: write timeout in seconds. If None, `self.write_timeout` is used. + read_timeout: read timeout in seconds. If None, `self.read_timeout` is used. + wait: If True, wait for a response. If False, return `None` immediately after sending. + + Returns: + A dictionary containing the parsed response, or None if no response was read within `timeout`. + """ + + if command[0] == "S" and params is not None: + k = module + command + if k in self._cache and self._cache[k] == params: + return + self._cache[k] = params + + cmd = self._assemble_command(module, command, [] if params is None else params) + + await self.io.write(cmd.encode(), timeout=write_timeout) + if not wait: + return None + + resp = await self.io.read(timeout=read_timeout) + return self.parse_response(resp) + + async def setup(self): + await super().setup() + await self.io.setup() + + async def stop(self): + await self.io.stop() + + +class EVOBackend(TecanLiquidHandler): + """ + Interface for the Tecan Freedom EVO series + """ + + LIHA = "C5" + ROMA = "C1" + MCA = "W1" + PNP = "W2" + + def __init__( + self, + diti_count: int = 0, + packet_read_timeout: int = 12, + read_timeout: int = 60, + write_timeout: int = 60, + ): + """Create a new EVO interface. + + Args: + packet_read_timeout: timeout in seconds for reading a single packet. + read_timeout: timeout in seconds for reading a full response. + write_timeout: timeout in seconds for writing a command. + """ + + super().__init__( + packet_read_timeout=packet_read_timeout, + read_timeout=read_timeout, + write_timeout=write_timeout, + ) + + self._num_channels: Optional[int] = None + self.diti_count = diti_count + # channels [num_channels - diti_count, num_channels) configured for disposable tips + + self._liha_connected: Optional[bool] = None + self._roma_connected: Optional[bool] = None + self._pnp_connected: Optional[bool] = None + self._mca_connected: Optional[bool] = None + + self._z_traversal_height = 210 # mm, the default value for SHZ command + self._z_roma_traversal_height = ( + 68.7 # mm, is what was used to develop this but possibly too low + ) + + @property + def num_channels(self) -> int: + """The number of pipette channels present on the robot.""" + + if self._num_channels is None: + raise RuntimeError("has not loaded num_channels, forgot to call `setup`?") + return self._num_channels + + @property + def liha_connected(self) -> bool: + """Whether LiHa arm is present on the robot.""" + + if self._liha_connected is None: + raise RuntimeError("liha_connected not set, forgot to call `setup`?") + return self._liha_connected + + @property + def roma_connected(self) -> bool: + """Whether RoMa arm is present on the robot.""" + + if self._roma_connected is None: + raise RuntimeError("roma_connected not set, forgot to call `setup`?") + return self._roma_connected + + @property + def pnp_connected(self) -> bool: + """Whether PnP arm is present on the robot.""" + + if self._pnp_connected is None: + raise RuntimeError("pnp_connected not set, forgot to call `setup`?") + return self._pnp_connected + + @property + def mca_connected(self) -> bool: + """Whether MCA arm is present on the robot.""" + + if self._mca_connected is None: + raise RuntimeError("mca_connected not set, forgot to call `setup`?") + return self._mca_connected + + def serialize(self) -> dict: + return {**super().serialize(), **self.io.serialize()} + + async def setup(self): + """Setup + + Creates a USB connection and finds read/write interfaces. + """ + + await super().setup() + + self._liha_connected = await self.setup_arm(EVOBackend.LIHA) + self._mca_connected = await self.setup_arm(EVOBackend.MCA) + self._roma_connected = await self.setup_arm(EVOBackend.ROMA) + + if self.roma_connected: # position_initialization_x in reverse order from setup_arm + self.roma = RoMa(self, EVOBackend.ROMA) + await self.roma.position_initialization_x() + # move to home position (TBD) after initialization + await self._park_roma() + if self.mca_connected: + self.mca = Mca(self, EVO.MCA) + # await self.mca.position_initialization_x() # function does not work for mca. + await self._park_mca() + + if self.liha_connected: + self.liha = LiHa(self, EVOBackend.LIHA) + await self.liha.position_initialization_x() + + self._num_channels = await self.liha.report_number_tips() + self._x_range = await self.liha.report_x_param(5) + self._y_range = (await self.liha.report_y_param(5))[0] + self._z_range = (await self.liha.report_z_param(5))[0] + + # Initialize plungers. Assumes wash station assigned at rail 1. + await self.liha.set_z_travel_height([self._z_range] * self.num_channels) + await self.liha.position_absolute_all_axis(45, 1031, 90, [1200] * self.num_channels) + await self.liha.initialize_plunger(self._bin_use_channels(list(range(self.num_channels)))) + await self.liha.position_valve_logical([1] * self.num_channels) + await self.liha.move_plunger_relative([100] * self.num_channels) + await self.liha.position_valve_logical([0] * self.num_channels) + await self.liha.set_end_speed_plunger([1800] * self.num_channels) + await self.liha.move_plunger_relative([-100] * self.num_channels) + await self.liha.position_absolute_all_axis(45, 1031, 90, [self._z_range] * self.num_channels) + + async def setup_arm(self, module): + try: + if module == EVO.MCA: + await self.send_command(module, command="PIB") + + await self.send_command(module, command="PIA") + except TecanError as e: + if e.error_code == 5: + return False + raise e + + if module != EVO.MCA: + await self.send_command(module, command="BMX", params=[2]) + + return True + + async def _park_liha(self): + await self.liha.set_z_travel_height([self._z_range] * self.num_channels) + await self.liha.position_absolute_all_axis(45, 1031, 90, [self._z_range] * self.num_channels) + + async def _park_roma(self): + await self.roma.set_vector_coordinate_position(1, 9000, 2000, 2464, 1800, None, 1, 0) + await self.roma.action_move_vector_coordinate_position() + + async def _park_mca(self): + """Moves the MCA arm to a safe park position to prevent collision.""" + # TODO CHANGE TO USE CORRECT FUNCTIONS + + # Ensure MCA is initialized before moving + await self.send_command(EVO.MCA, command="PIA") + await asyncio.sleep(0.5) + + # Raise MCA Z-axis first to avoid collision + await self.send_command(EVO.MCA, command="PAA", params=[None, None, 2000]) # Raise Z-axis + await asyncio.sleep(1) + + # Move MCA to parking position (adjust X, Y as needed) + await self.send_command(EVO.MCA, command="PAA", params=[6000, 1000, None]) + await asyncio.sleep(1) + + # Stop movement to prevent drifting + await self.send_command(EVO.MCA, command="BMA", params=[0, 0, 0]) + await asyncio.sleep(0.5) + + # ============== LiquidHandlerBackend methods ============== + + async def aspirate( + self, ops: List[SingleChannelAspiration], use_channels: List[int] + ): # TODO: pass in operation parameters to override TecanLiquidClass defaults + """Aspirate liquid from the specified channels. + + Args: + ops: The aspiration operations to perform. + use_channels: The channels to use for the operations. + """ + + # Get positions including offsets + x_positions, y_positions, z_positions = self._liha_positions(ops, use_channels) + + tecan_liquid_classes = [ + get_liquid_class( + target_volume=op.volume, + liquid_class=Liquid.WATER, + tip_type=op.tip.tip_type, + ) + if isinstance(op.tip, TecanTip) + else None + for op in ops + ] + + ys = int(ops[0].resource.get_absolute_size_y() * 10) + zadd: List[Optional[int]] = [0] * self.num_channels + for i, channel in enumerate(use_channels): + par = ops[i].resource.parent + if par is None: + continue + if not isinstance(par, TecanPlate): + raise ValueError(f"Operation is not supported by resource {par}.") + # TODO: calculate defaults when area is not specified + zadd[channel] = round(ops[i].volume / par.area * 10) + + # moves such that first channel is over first location + x, _ = self._first_valid(x_positions) + y, yi = self._first_valid(y_positions) + assert x is not None and y is not None + await self.liha.set_z_travel_height([self._z_range] * self.num_channels) + await self.liha.position_absolute_all_axis( + x, + y - yi * ys, + ys, + [z if z else self._z_range for z in z_positions["travel"]], + ) + # TODO check channel positions match resource positions + + # aspirate airgap + pvl, sep, ppr = self._aspirate_airgap(use_channels, tecan_liquid_classes, "lag") + if any(ppr): + await self.liha.position_valve_logical(pvl) + await self.liha.set_end_speed_plunger(sep) + await self.liha.move_plunger_relative(ppr) + + # perform liquid level detection + # TODO: verify for other liquid detection modes + if any(tlc.aspirate_lld if tlc is not None else None for tlc in tecan_liquid_classes): + tlc, _ = self._first_valid(tecan_liquid_classes) + assert tlc is not None + detproc = tlc.lld_mode # must be same for all channels? + sense = tlc.lld_conductivity + await self.liha.set_detection_mode(detproc, sense) + ssl, sdl, sbl = self._liquid_detection(use_channels, tecan_liquid_classes) + await self.liha.set_search_speed(ssl) + await self.liha.set_search_retract_distance(sdl) + await self.liha.set_search_z_start(z_positions["start"]) + await self.liha.set_search_z_max(list(z if z else self._z_range for z in z_positions["max"])) + await self.liha.set_search_submerge(sbl) + shz = [min(z for z in z_positions["travel"] if z)] * self.num_channels # TODO: max? + await self.liha.set_z_travel_height(shz) + await self.liha.move_detect_liquid(self._bin_use_channels(use_channels), zadd) + await self.liha.set_z_travel_height([self._z_range] * self.num_channels) + + # aspirate + retract + # SSZ: z_add / (vol / asp_speed) + zadd = [min(z, 32) if z else None for z in zadd] + ssz, sep, stz, mtr, ssz_r = self._aspirate_action(ops, use_channels, tecan_liquid_classes, zadd) + await self.liha.set_slow_speed_z(ssz) + await self.liha.set_end_speed_plunger(sep) + await self.liha.set_tracking_distance_z(stz) + await self.liha.move_tracking_relative(mtr) + await self.liha.set_slow_speed_z(ssz_r) + await self.liha.move_absolute_z(z_positions["start"]) # TODO: use retract_position and offset + + # aspirate airgap + pvl, sep, ppr = self._aspirate_airgap(use_channels, tecan_liquid_classes, "tag") + await self.liha.position_valve_logical(pvl) + await self.liha.set_end_speed_plunger(sep) + await self.liha.move_plunger_relative(ppr) + + async def dispense(self, ops: List[SingleChannelDispense], use_channels: List[int]): + """Dispense liquid from the specified channels. + + Args: + ops: The dispense operations to perform. + use_channels: The channels to use for the dispense operations. + """ + + x_positions, y_positions, z_positions = self._liha_positions(ops, use_channels) + ys = int(ops[0].resource.get_absolute_size_y() * 10) + + tecan_liquid_classes = [ + get_liquid_class( + target_volume=op.volume, + liquid_class=Liquid.WATER, + tip_type=op.tip.tip_type, + ) + if isinstance(op.tip, TecanTip) + else None + for op in ops + ] + + x, _ = self._first_valid(x_positions) + y, yi = self._first_valid(y_positions) + assert x is not None and y is not None + await self.liha.set_z_travel_height([z if z else self._z_range for z in z_positions["travel"]]) + await self.liha.position_absolute_all_axis( + x, + y - yi * ys, + ys, + [z if z else self._z_range for z in z_positions["dispense"]], + ) + + sep, spp, stz, mtr = self._dispense_action(ops, use_channels, tecan_liquid_classes) + await self.liha.set_end_speed_plunger(sep) + await self.liha.set_stop_speed_plunger(spp) + await self.liha.set_tracking_distance_z(stz) + await self.liha.move_tracking_relative(mtr) + + async def pick_up_tips(self, ops: List[Pickup], use_channels: List[int]): + """Pick up tips from a resource. + + Args: + ops: The pickup operations to perform. + use_channels: The channels to use for the pickup operations. + """ + + assert ( + min(use_channels) >= self.num_channels - self.diti_count + ), f"DiTis can only be configured for the last {self.diti_count} channels" + + # Get positions including offsets + x_positions, y_positions, z_positions = self._liha_positions(ops, use_channels) + + # move channels + ys = int(ops[0].resource.get_absolute_size_y() * 10) + x, _ = self._first_valid(x_positions) + y, yi = self._first_valid(y_positions) + assert x is not None and y is not None + await self.liha.set_z_travel_height([self._z_range] * self.num_channels) + await self.liha.position_absolute_all_axis( + x, y - yi * ys, ys, [self._z_range] * self.num_channels + ) + + # aspirate airgap + pvl: List[Optional[int]] = [None] * self.num_channels + sep: List[Optional[int]] = [None] * self.num_channels + ppr: List[Optional[int]] = [None] * self.num_channels + for channel in use_channels: + pvl[channel] = 0 + sep[channel] = 70 * 6 # ? 12, always 70? + ppr[channel] = 10 * 3 # ? 6 + await self.liha.position_valve_logical(pvl) + await self.liha.set_end_speed_plunger(sep) + await self.liha.move_plunger_relative(ppr) + + # get tips + first_z_start, _ = self._first_valid(z_positions["start"]) + assert first_z_start is not None, "Could not find a valid z_start position" + await self.liha.get_disposable_tip( + self._bin_use_channels(use_channels), first_z_start - 227, 210 + ) + + async def drop_tips(self, ops: List[Drop], use_channels: List[int]): + """Drops tips. + + Args: + ops: The drop operations to perform. + use_channels: The channels to use for the drop operations. + """ + + assert ( + min(use_channels) >= self.num_channels - self.diti_count + ), f"DiTis can only be configured for the last {self.diti_count} channels" + assert all( + isinstance(op.resource, (Trash, TipSpot)) for op in ops + ), "Must drop in waste container or tip rack" + + # Get positions including offsets + x_positions, y_positions, _ = self._liha_positions(ops, use_channels) + + # move channels + ys = int(ops[0].resource.get_absolute_size_y() * 10) # was 90 + x, _ = self._first_valid(x_positions) + y, yi = self._first_valid(y_positions) + assert x is not None and y is not None + await self.liha.set_z_travel_height([self._z_range] * self.num_channels) + await self.liha.position_absolute_all_axis( + x, + y - yi * ys, + ys, + [self._z_range] * self.num_channels, + ) + + # TODO check channel positions match resource positions for z-axis + await self.liha._drop_disposable_tip(self._bin_use_channels(use_channels), discard_height=0) + + async def pick_up_tips96(self, pickup: PickupTipRack): + raise NotImplementedError("MCA not implemented yet") + + async def drop_tips96(self, drop: DropTipRack): + raise NotImplementedError("MCA not implemented yet") + + async def aspirate96( + self, aspiration: Union[MultiHeadAspirationPlate, MultiHeadAspirationContainer] + ): + raise NotImplementedError("MCA not implemented yet") + + async def dispense96(self, dispense: Union[MultiHeadDispensePlate, MultiHeadDispenseContainer]): + raise NotImplementedError("MCA not implemented yet") + + async def pick_up_resource(self, pickup: ResourcePickup): + # TODO: implement PnP for moving tubes + assert self.roma_connected + + z_range = await self.roma.report_z_param(5) + x, y, z = self._roma_positions( + pickup.resource, pickup.resource.get_location_wrt(self.deck), z_range + ) + h = int(pickup.resource.get_absolute_size_y() * 10) + + # move to resource + await self.roma.set_smooth_move_x(1) + await self.roma.set_fast_speed_x(10000) + await self.roma.set_fast_speed_y(5000, 1500) + await self.roma.set_fast_speed_z(1300) + await self.roma.set_fast_speed_r(5000, 1500) + await self.roma.set_vector_coordinate_position(1, x, y, z["safe"], 900, None, 1, 0) + await self.roma.action_move_vector_coordinate_position() + await self.roma.set_smooth_move_x(0) + + # pick up resource + await self.roma.position_absolute_g(900) # TODO: verify + await self.roma.set_target_window_class(1, 0, 0, 0, 135, 0) + await self.roma.set_vector_coordinate_position(1, x, y, z["travel"], 900, None, 1, 1) + # TODO verify z param + await self.roma.set_vector_coordinate_position(1, x, y, z["end"], 900, None, 1, 0) + await self.roma.action_move_vector_coordinate_position() + await self.roma.set_fast_speed_y(3500, 1000) + await self.roma.set_fast_speed_r(2000, 600) + await self.roma.set_gripper_params(100, 75) + await self.roma.grip_plate(h - 100) + + async def move_picked_up_resource(self, move: ResourceMove): + raise NotImplementedError() + + async def drop_resource(self, drop: ResourceDrop): + """Drop a resource like a plate or a lid using the integrated robotic arm.""" + + z_range = await self.roma.report_z_param(5) + x, y, z = self._roma_positions( + drop.resource, drop.resource.get_location_wrt(self.deck), z_range + ) + xt, yt, zt = self._roma_positions(drop.resource, drop.destination, z_range) + + # move to target + await self.roma.set_target_window_class(1, 0, 0, 0, 135, 0) + await self.roma.set_target_window_class(2, 0, 0, 0, 53, 0) + await self.roma.set_target_window_class(3, 0, 0, 0, 55, 0) + await self.roma.set_target_window_class(4, 45, 0, 0, 0, 0) + await self.roma.set_vector_coordinate_position(1, x, y, z["end"], 900, None, 1, 1) + await self.roma.set_vector_coordinate_position(2, x, y, z["travel"], 900, None, 1, 2) + await self.roma.set_vector_coordinate_position(3, x, y, z["safe"], 900, None, 1, 3) + await self.roma.set_vector_coordinate_position(4, xt, yt, zt["safe"], 900, None, 1, 4) + await self.roma.set_vector_coordinate_position(5, xt, yt, zt["travel"], 900, None, 1, 3) + await self.roma.set_vector_coordinate_position(6, xt, yt, zt["end"], 900, None, 1, 0) + await self.roma.action_move_vector_coordinate_position() + + # release resource + await self.roma.position_absolute_g(900) + await self.roma.set_fast_speed_y(5000, 1500) + await self.roma.set_fast_speed_r(5000, 1500) + await self.roma.set_vector_coordinate_position(1, xt, yt, zt["end"], 900, None, 1, 1) + await self.roma.set_vector_coordinate_position(2, xt, yt, zt["travel"], 900, None, 1, 2) + await self.roma.set_vector_coordinate_position(3, xt, yt, zt["safe"], 900, None, 1, 0) + await self.roma.action_move_vector_coordinate_position() + await self.roma.set_fast_speed_y(3500, 1000) + await self.roma.set_fast_speed_r(2000, 600) + + def can_pick_up_tip(self, channel_idx: int, tip: Tip) -> bool: + if not isinstance(tip, TecanTip): + return False + return True + + def _first_valid(self, lst: List[Optional[T]]) -> Tuple[Optional[T], int]: + """Returns first item in list that is not None""" + + for i, v in enumerate(lst): + if v is not None: + return v, i + return None, -1 + + def _bin_use_channels(self, use_channels: List[int]) -> int: + """Converts use_channels to a binary coded tip representation.""" + + b = 0 + for channel in use_channels: + b += 1 << channel + return b + + def _liha_positions( + self, + ops: Sequence[Union[SingleChannelAspiration, SingleChannelDispense, Pickup, Drop]], + use_channels: List[int], + ) -> Tuple[ + List[Optional[int]], + List[Optional[int]], + Dict[str, List[Optional[int]]], + ]: + """Creates lists of x, y, and z positions used by LiHa ops.""" + + x_positions: List[Optional[int]] = [None] * self.num_channels + y_positions: List[Optional[int]] = [None] * self.num_channels + z_positions: Dict[str, List[Optional[int]]] = { + "travel": [None] * self.num_channels, + "start": [None] * self.num_channels, + "dispense": [None] * self.num_channels, + "max": [None] * self.num_channels, + } + + def get_z_position(z, z_off, tip_length): + # TODO: simplify z units + return int(self._z_range - z + z_off * 10 + tip_length) # TODO: verify z formula + + for i, (op, channel) in enumerate(zip(ops, use_channels)): + location = ops[i].resource.get_location_wrt(self.deck) + op.resource.center() + x_positions[channel] = int((location.x - 100 + op.offset.x) * 10) + y_positions[channel] = int((346.5 - location.y + op.offset.y) * 10) # TODO: verify + + par = ops[i].resource.parent + if not isinstance(par, (TecanPlate, TecanTipRack)): + raise ValueError(f"Operation is not supported by resource {par}.") + # TODO: calculate defaults when z-attribs are not specified + tip_length = int(ops[i].tip.total_tip_length * 10) + # z travel seems to only be used for aspiration and dispense right now + if isinstance(op, (SingleChannelAspiration, SingleChannelDispense)): + z_positions["travel"][channel] = round(self._z_traversal_height * 10) + z_positions["start"][channel] = get_z_position( + par.z_start, par.get_location_wrt(self.deck).z + op.offset.z, tip_length + ) + z_positions["dispense"][channel] = get_z_position( + par.z_dispense, par.get_location_wrt(self.deck).z + op.offset.z, tip_length + ) + z_positions["max"][channel] = get_z_position( + par.z_max, par.get_location_wrt(self.deck).z + op.offset.z, tip_length + ) + + return x_positions, y_positions, z_positions + + def _aspirate_airgap( + self, + use_channels: List[int], + tecan_liquid_classes: List[Optional[TecanLiquidClass]], + airgap: str, + ) -> Tuple[List[Optional[int]], List[Optional[int]], List[Optional[int]]]: + """Creates parameters used to aspirate airgaps. + + Args: + airgap: `lag` for leading airgap, `tag` for trailing airgap. + + Returns: + pvl: position_valve_logical + sep: set_end_speed_plunger + ppr: move_plunger_relative + """ + + pvl: List[Optional[int]] = [None] * self.num_channels + sep: List[Optional[int]] = [None] * self.num_channels + ppr: List[Optional[int]] = [None] * self.num_channels + + for i, channel in enumerate(use_channels): + tlc = tecan_liquid_classes[i] + assert tlc is not None + pvl[channel] = 0 + if airgap == "lag": + sep[channel] = int( + tlc.aspirate_lag_speed * 6 + ) # 6? TODO: verify step unit (half step per second) + ppr[channel] = int(tlc.aspirate_lag_volume * 3) # 3? (Relative position in full steps) + elif airgap == "tag": + sep[channel] = int(tlc.aspirate_tag_speed * 6) # 6? + ppr[channel] = int(tlc.aspirate_tag_volume * 3) # 3? + + return pvl, sep, ppr + + def _liquid_detection( + self, + use_channels: List[int], + tecan_liquid_classes: List[Optional[TecanLiquidClass]], + ) -> Tuple[List[Optional[int]], List[Optional[int]], List[Optional[int]]]: + """Creates parameters use for liquid detection. + + Returns: + ssl: set_search_speed + sdl: set_search_retract_distance + sbl: set_search_submerge + """ + + ssl: List[Optional[int]] = [None] * self.num_channels + sdl: List[Optional[int]] = [None] * self.num_channels + sbl: List[Optional[int]] = [None] * self.num_channels + + for i, channel in enumerate(use_channels): + tlc = tecan_liquid_classes[i] + assert tlc is not None + ssl[channel] = int(tlc.lld_speed * 10) + sdl[channel] = int(tlc.lld_distance * 10) + sbl[channel] = int(tlc.aspirate_lld_offset * 10) + + return ssl, sdl, sbl + + def _aspirate_action( + self, + ops: Sequence[Union[SingleChannelAspiration, SingleChannelDispense]], + use_channels: List[int], + tecan_liquid_classes: List[Optional[TecanLiquidClass]], + zadd: List[Optional[int]], + ) -> Tuple[ + List[Optional[int]], + List[Optional[int]], + List[Optional[int]], + List[Optional[int]], + List[Optional[int]], + ]: + """Creates parameters used for aspiration action. + + Args: + zadd: distance moved while aspirating + + Returns: + ssz: set_slow_speed_z + sep: set_end_speed_plunger + stz: set_tracking_distance_z + mtr: move_tracking_relative + ssz_r: set_slow_speed_z retract + """ + + ssz: List[Optional[int]] = [None] * self.num_channels + sep: List[Optional[int]] = [None] * self.num_channels + stz: List[Optional[int]] = [-z if z else None for z in zadd] # TODO: verify max cutoff + mtr: List[Optional[int]] = [None] * self.num_channels + ssz_r: List[Optional[int]] = [None] * self.num_channels + + for i, channel in enumerate(use_channels): + tlc = tecan_liquid_classes[i] + z = zadd[channel] + assert tlc is not None and z is not None + flow_rate = ops[i].flow_rate or tlc.aspirate_speed + sep[channel] = int(flow_rate * 6) # 6? + ssz[channel] = round(z * flow_rate / ops[i].volume) + volume = tlc.compute_corrected_volume(ops[i].volume) + mtr[channel] = round(volume * 3) # 3? # Relative position in full steps + ssz_r[channel] = int(tlc.aspirate_retract_speed * 10) + + return ssz, sep, stz, mtr, ssz_r + + def _dispense_action( + self, + ops: Sequence[Union[SingleChannelAspiration, SingleChannelDispense]], + use_channels: List[int], + tecan_liquid_classes: List[Optional[TecanLiquidClass]], + ) -> Tuple[ + List[Optional[int]], + List[Optional[int]], + List[Optional[int]], + List[Optional[int]], + ]: + """Creates parameters used for dispense action. + + Returns: + sep: set_end_speed_plunger + spp: set_stop_speed_plunger + stz: set_tracking_distance_z + mtr: move_tracking_relative + """ + + sep: List[Optional[int]] = [None] * self.num_channels + spp: List[Optional[int]] = [None] * self.num_channels + stz: List[Optional[int]] = [None] * self.num_channels + mtr: List[Optional[int]] = [None] * self.num_channels + + for i, channel in enumerate(use_channels): + tlc = tecan_liquid_classes[i] + assert tlc is not None + flow_rate = ops[i].flow_rate or tlc.dispense_speed + sep[channel] = int(flow_rate * 6) # 6? + spp[channel] = int(tlc.dispense_breakoff * 6) # 6? half step per second + stz[channel] = 0 + volume = ( + tlc.compute_corrected_volume(ops[i].volume) + + tlc.aspirate_lag_volume + + tlc.aspirate_tag_volume + ) + mtr[channel] = -round(volume * 3) # 3? + + return sep, spp, stz, mtr + + def _roma_positions( + self, resource: Resource, offset: Coordinate, z_range: int + ) -> Tuple[int, int, Dict[str, int]]: + """Creates x, y, and z positions used by RoMa ops.""" + + parent = resource.parent # PlateHolder + if parent is None: + raise ValueError(f"Operation is not supported by resource {resource}.") + parent = parent.parent # PlateCarrier + # TODO: this is probably the current plate carrier, not the destination. + # Also, we should just support any coordinate as the destination. + if not isinstance(parent, TecanPlateCarrier): + raise ValueError(f"Operation is not supported by resource {parent}.") + + if ( + parent.roma_x is None + or parent.roma_y is None + or parent.roma_z_safe is None + or parent.roma_z_end is None + ): + raise ValueError(f"Operation is not supported by resource {parent}.") + x_position = int((offset.x - 100) * 10 + parent.roma_x) + y_position = int((347.1 - (offset.y + resource.get_absolute_size_y())) * 10 + parent.roma_y) + z_positions = { + "safe": z_range - int(parent.roma_z_safe), + "travel": int(self._z_roma_traversal_height * 10), + "end": z_range - int(parent.roma_z_end - offset.z * 10), + } + + return x_position, y_position, z_positions + + +class EVOArm: + """ + Provides firmware commands for EVO arms. Caches arm positions. + """ + + _pos_cache: Dict[str, int] = {} + + def __init__(self, backend: EVOBackend, module: str): + self.backend = backend + self.module = module + + async def position_initialization_x(self): + """Reinitializes X-axis of the arm.""" + + await self.backend.send_command(module=self.module, command="PIX") + + async def report_x_param(self, param: int) -> int: + """Report current parameter for x-axis. + + Args: + param: 0 - current position, 5 - actual machine range + """ + + resp: List[int] = ( + await self.backend.send_command(module=self.module, command="RPX", params=[param]) + )["data"] + return resp[0] + + async def report_y_param(self, param: int) -> List[int]: + """Report current parameters for y-axis. + + Args: + param: 0 - current position, 5 - actual machine range + """ + + resp: List[int] = ( + await self.backend.send_command(module=self.module, command="RPY", params=[param]) + )["data"] + return resp + + +@device( + id="li_ha", + category=["Liquid Handling Workstation"], + description="一款用于实验室自动移液与板件搬运的台式自动化工作站,通常配备多通道 LiHa 液体处理臂,可完成吸液、分液、液面探测、一次性吸头装卸及多轴定位,并可结合 96 通道或板搬运模块执行样品制备、微孔板加样和高通量实验流程。", + model={ + "type": "device", + "mesh": "li_ha", + "path": "https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/devices/li_ha/macro_device.xacro", + }, +) +class LiHa(EVOArm): + """ + Provides firmware commands for the LiHa + """ + + def __init__(self, *args, **kwargs): + print("[UNILAB] LiHa.__init__() called", flush=True) + super().__init__(*args, **kwargs) + @action(auto_prefix=True, description="初始化柱塞和阀驱动。") + async def initialize_plunger(self, tips): + _unilab_logger.debug("[UNILAB] LiHa.initialize_plunger() called") + """Initializes plunger and valve drive + + Args: + tips: binary coded tip select + """ + await self.backend.send_command(module=self.module, command="PID", params=[tips]) + + @action(auto_prefix=True, description="获取 Z 轴参数。") + async def report_z_param(self, param: int) -> List[int]: + _unilab_logger.debug("[UNILAB] LiHa.report_z_param() called") + """Report current parameters for z-axis. + + Param: + 0 Report current position in 1/10 mm. + 1 Report acceleration in 1/10 mm/s². + 2 Report fast speed in 1/10 mm/s. + 3 Report initialization speed in 1/10 mm. + 4 Report initialization offset in 1/10 mm. + 5 Report actual machine range in 1/10 mm. + 6 Report deviation in encoder increments. + 7 Report every time 0. + 8 Report scale adjust factor. + 9 Report slow speed in 1/10 mm/s. + 10 Report axis scale factor. + 11 Report target position in 1/10 mm. + 12 Report travel position in 1/10 mm. + """ + + resp: List[int] = ( + await self.backend.send_command(module=self.module, command="RPZ", params=[param]) + )["data"] + return resp + + @action(auto_prefix=True, description="获取机械臂上的吸头数量。") + async def report_number_tips(self) -> int: + _unilab_logger.debug("[UNILAB] LiHa.report_number_tips() called") + """Report number of tips on arm.""" + + resp: List[int] = ( + await self.backend.send_command(module=self.module, command="RNT", params=[1]) + )["data"] + return resp[0] + + @action(auto_prefix=True, description="将各轴移动到绝对位置。") + async def position_absolute_all_axis(self, x: int, y: int, ys: int, z: List[int]): + _unilab_logger.debug("[UNILAB] LiHa.position_absolute_all_axis() called") + """Position absolute for all LiHa axes. + + Args: + x: absolute x position in 1/10 mm, must be in allowed machine range + y: absolute y position in 1/10 mm, must be in allowed machine range + ys: absolute y spacing in 1/10 mm, must be between 90 and 380 + z: absolute z position in 1/10 mm for each channel, must be in + allowed machine range + + Raises: + TecanError: if moving to the target position causes a collision + """ + + cur_x = EVOArm._pos_cache.setdefault(self.module, await self.report_x_param(0)) + for module, pos in EVOArm._pos_cache.items(): + if module == self.module: + continue + if cur_x < x and cur_x < pos < x: # moving right + raise TecanError("Invalid command (collision)", self.module, 2) + if cur_x > x and cur_x > pos > x: + raise TecanError("Invalid command (collision)", self.module, 2) + if abs(pos - x) < 1500: + raise TecanError("Invalid command (collision)", self.module, 2) + + await self.backend.send_command(module=self.module, command="PAA", params=list([x, y, ys] + z)) + + EVOArm._pos_cache[self.module] = x + + @action(auto_prefix=True, description="设置各通道阀的逻辑位置。") + async def position_valve_logical(self, param: List[Optional[int]]): + _unilab_logger.debug("[UNILAB] LiHa.position_valve_logical() called") + """Position valve logical for each channel. + + Args: + param: 0 - outlet, 1 - inlet, 2 - bypass + """ + + await self.backend.send_command(module=self.module, command="PVL", params=param) + + @action(auto_prefix=True, description="设置柱塞末端速度。") + async def set_end_speed_plunger(self, speed: List[Optional[int]]): + _unilab_logger.debug("[UNILAB] LiHa.set_end_speed_plunger() called") + """Set end speed for plungers. + + Args: + speed: speed for each plunger in half step per second, must be between + 5 and 6000 + """ + + await self.backend.send_command(module=self.module, command="SEP", params=speed) + + @action(auto_prefix=True, description="相对移动柱塞以吸液或分液。") + async def move_plunger_relative(self, rel: List[Optional[int]]): + _unilab_logger.debug("[UNILAB] LiHa.move_plunger_relative() called") + """Move plunger relative upwards (dispense) or downards (aspirate). + + Args: + rel: relative position for each plunger in full steps, must be between + -3150 and 3150 + """ + + await self.backend.send_command(module=self.module, command="PPR", params=rel) + + @action(auto_prefix=True, description="设置液面检测模式。") + async def set_detection_mode(self, proc: int, sense: int): + _unilab_logger.debug("[UNILAB] LiHa.set_detection_mode() called") + """Set liquid detection mode. + + Args: + proc: detection procedure (7 for double detection sequential with + retract and extra submerge) + sense: conductivity (1 for high) + """ + + await self.backend.send_command(module=self.module, command="SDM", params=[proc, sense]) + + @action(auto_prefix=True, description="设置液面搜索速度。") + async def set_search_speed(self, speed: List[Optional[int]]): + _unilab_logger.debug("[UNILAB] LiHa.set_search_speed() called") + """Set search speed for liquid search commands. + + Args: + speed: speed for each channel in 1/10 mm/s, must be between 1 and 1500 + """ + + await self.backend.send_command(module=self.module, command="SSL", params=speed) + + @action(auto_prefix=True, description="设置液面搜索回撤距离。") + async def set_search_retract_distance(self, dist: List[Optional[int]]): + _unilab_logger.debug("[UNILAB] LiHa.set_search_retract_distance() called") + """Set z-axis retract distance for liquid search commands. + + Args: + dist: retract distance for each channel in 1/10 mm, must be in allowed + machine range + """ + + await self.backend.send_command(module=self.module, command="SDL", params=dist) + + @action(auto_prefix=True, description="设置液面搜索后的下探深度。") + async def set_search_submerge(self, dist: List[Optional[int]]): + _unilab_logger.debug("[UNILAB] LiHa.set_search_submerge() called") + """Set submerge for liquid search commands. + + Args: + dist: submerge distance for each channel in 1/10 mm, must be between + -1000 and max z range + """ + + await self.backend.send_command(module=self.module, command="SBL", params=dist) + + @action(auto_prefix=True, description="设置液面搜索起始高度。") + async def set_search_z_start(self, z: List[Optional[int]]): + _unilab_logger.debug("[UNILAB] LiHa.set_search_z_start() called") + """Set z-start for liquid search commands. + + Args: + z: start height for each channel in 1/10 mm, must be in allowed machine range + """ + + await self.backend.send_command(module=self.module, command="STL", params=z) + + @action(auto_prefix=True, description="设置液面搜索最大 Z 高度。") + async def set_search_z_max(self, z: List[Optional[int]]): + _unilab_logger.debug("[UNILAB] LiHa.set_search_z_max() called") + """Set z-max for liquid search commands. + + Args: + z: max for each channel in 1/10 mm, must be in allowed machine range + """ + + await self.backend.send_command(module=self.module, command="SML", params=z) + + @action(auto_prefix=True, description="设置 Z 轴行进高度。") + async def set_z_travel_height(self, z): + _unilab_logger.debug("[UNILAB] LiHa.set_z_travel_height() called") + """Set z-travel height. + + Args: + z: travel heights in absolute 1/10 mm for each channel, must be in allowed + machine range + 20 + """ + await self.backend.send_command(module=self.module, command="SHZ", params=z) + + @action(auto_prefix=True, description="下降探针以检测液面并下探。") + async def move_detect_liquid(self, channels: int, zadd: List[Optional[int]]): + _unilab_logger.debug("[UNILAB] LiHa.move_detect_liquid() called") + """Move tip, detect liquid, submerge. + + Args: + channels: binary coded tip select + zadd: required distance to travel downwards in 1/10 mm for each channel, + must be between 0 and z-start - z-max + """ + + await self.backend.send_command( + module=self.module, + command="MDT", + params=[channels] + [None] * 3 + zadd, + ) + + @action(auto_prefix=True, description="设置 Z 轴慢速速度。") + async def set_slow_speed_z(self, speed: List[Optional[int]]): + _unilab_logger.debug("[UNILAB] LiHa.set_slow_speed_z() called") + """Set slow speed for z. + + Args: + speed: speed in 1/10 mm/s for each channel, must be between 1 and 4000 + """ + + await self.backend.send_command(module=self.module, command="SSZ", params=speed) + + @action(auto_prefix=True, description="设置吸液或分液时的 Z 轴跟踪距离。") + async def set_tracking_distance_z(self, rel: List[Optional[int]]): + _unilab_logger.debug("[UNILAB] LiHa.set_tracking_distance_z() called") + """Set z-axis relative tracking distance used by dispense and aspirate. + + Args: + rel: relative value in 1/10 mm for each channel, must be between + -2100 and 2100 + """ + + await self.backend.send_command(module=self.module, command="STZ", params=rel) + + @action(auto_prefix=True, description="执行同步跟踪相对运动。") + async def move_tracking_relative(self, rel: List[Optional[int]]): + _unilab_logger.debug("[UNILAB] LiHa.move_tracking_relative() called") + """Move tracking relative. Starts the z-drives and dilutors simultaneously + to achieve a synchronous tracking movement. + + Args: + rel: relative position for each plunger in full steps, must be between + -3150 and 3150 + """ + + await self.backend.send_command(module=self.module, command="MTR", params=rel) + + @action(auto_prefix=True, description="以慢速将 Z 轴移动到绝对位置。") + async def move_absolute_z(self, z: List[Optional[int]]): + _unilab_logger.debug("[UNILAB] LiHa.move_absolute_z() called") + """Position absolute with slow speed z-axis + + Args: + z: absolute position in 1/10 mm for each channel, must be in + allowed machine range + """ + + await self.backend.send_command(module=self.module, command="MAZ", params=z) + + @action(auto_prefix=True, description="设置柱塞停止速度。") + async def set_stop_speed_plunger(self, speed: List[Optional[int]]): + _unilab_logger.debug("[UNILAB] LiHa.set_stop_speed_plunger() called") + """Set stop speed for plungers + + Args: + speed: speed for each plunger in half step per second, must be between + 50 and 2700 + """ + + await self.backend.send_command(module=self.module, command="SPP", params=speed) + + @action(auto_prefix=True, description="拾取一次性吸头。") + async def get_disposable_tip(self, tips, z_start, z_search): + _unilab_logger.debug("[UNILAB] LiHa.get_disposable_tip() called") + """Picks up tips + + Args: + tips: binary coded tip select + z_start: position in 1/10 mm where searching begins + z_search: search distance in 1/10 mm, range within a tip must be found + """ + + await self.backend.send_command( + module=self.module, + command="AGT", + params=[tips, z_start, z_search, 0], + ) + + @action(auto_prefix=True, description="在高位丢弃一次性吸头。") + async def discard_disposable_tip_high(self, tips): + _unilab_logger.debug("[UNILAB] LiHa.discard_disposable_tip_high() called") + """Drops tips + Discards at the Z-axes initialization height + Args: + tips: binary coded tip select + """ + + await self.backend.send_command(module=self.module, command="ADT", params=[tips]) + + async def _drop_disposable_tip(self, tips, discard_height): + _unilab_logger.debug("[UNILAB] LiHa._drop_disposable_tip() called") + """Drops tips + Discards at a variable Z-axis initialization height + + Args: + tips: binary coded tip select + discard_height: binary. 0 above tip rack, 1 in tip rack + """ + + await self.backend.send_command( + module=self.module, command="AST", params=[tips, discard_height] + ) + def _ensure_roma_helper(self): + helper = getattr(self, "_roma_helper_instance", None) + if helper is None: + helper = RoMa(self.backend, getattr(self.backend, "ROMA", "C1")) + self._roma_helper_instance = helper + return helper + + @action(auto_prefix=True, description="解析仪器返回数据。") + def parse_response(self, resp: bytes) -> Dict[str, Union[str, int, List[Union[int, str]]]]: + return self.backend.parse_response(resp) + + @action(auto_prefix=True, description="向仪器发送控制命令。") + async def send_command( + self, + module: str, + command: str, + params: Optional[List[Optional[int]]] = None, + write_timeout: Optional[int] = None, + read_timeout: Optional[int] = None, + wait=True, + ): + return await self.backend.send_command(module, command, params, write_timeout, read_timeout, wait) + + @action(auto_prefix=True, description="获取液体处理通道数量。") + def num_channels(self) -> int: + return self.backend.num_channels + + @action(auto_prefix=True, description="获取 LiHa 液体处理臂是否已连接。") + def liha_connected(self) -> bool: + return self.backend.liha_connected + + @action(auto_prefix=True, description="获取 RoMa 板搬运模块是否已连接。") + def roma_connected(self) -> bool: + return self.backend.roma_connected + + @action(auto_prefix=True, description="获取 PnP 模块是否已连接。") + def pnp_connected(self) -> bool: + return self.backend.pnp_connected + + @action(auto_prefix=True, description="获取 MCA 多通道模块是否已连接。") + def mca_connected(self) -> bool: + return self.backend.mca_connected + + @action(auto_prefix=True, description="导出设备配置或状态表示。") + def serialize(self) -> dict: + return self.backend.serialize() + + @action(auto_prefix=True, description="设置并初始化机械臂。") + async def setup_arm(self, module): + return await self.backend.setup_arm(module) + + @action(auto_prefix=True, description="通过选定通道吸取液体。") + async def aspirate( + self, ops: List[SingleChannelAspiration], use_channels: List[int] + ): # TODO: pass in operation parameters to override TecanLiquidClass defaults + return await self.backend.aspirate(ops, use_channels) + + @action(auto_prefix=True, description="通过选定通道分配液体。") + async def dispense(self, ops: List[SingleChannelDispense], use_channels: List[int]): + return await self.backend.dispense(ops, use_channels) + + @action(auto_prefix=True, description="拾取一次性吸头。") + async def pick_up_tips(self, ops: List[Pickup], use_channels: List[int]): + return await self.backend.pick_up_tips(ops, use_channels) + + @action(auto_prefix=True, description="丢弃一次性吸头。") + async def drop_tips(self, ops: List[Drop], use_channels: List[int]): + return await self.backend.drop_tips(ops, use_channels) + + @action(auto_prefix=True, description="拾取 96 通道吸头。") + async def pick_up_tips96(self, pickup: PickupTipRack): + return await self.backend.pick_up_tips96(pickup) + + @action(auto_prefix=True, description="丢弃 96 通道吸头。") + async def drop_tips96(self, drop: DropTipRack): + return await self.backend.drop_tips96(drop) + + @action(auto_prefix=True, description="执行 96 通道吸液。") + async def aspirate96( + self, aspiration: Union[MultiHeadAspirationPlate, MultiHeadAspirationContainer] + ): + return await self.backend.aspirate96(aspiration) + + @action(auto_prefix=True, description="执行 96 通道分液。") + async def dispense96(self, dispense: Union[MultiHeadDispensePlate, MultiHeadDispenseContainer]): + return await self.backend.dispense96(dispense) + + @action(auto_prefix=True, description="拾取实验资源或板件。") + async def pick_up_resource(self, pickup: ResourcePickup): + # TODO: implement PnP for moving tubes + return await self.backend.pick_up_resource(pickup) + + @action(auto_prefix=True, description="移动已拾取的实验资源或板件。") + async def move_picked_up_resource(self, move: ResourceMove): + return await self.backend.move_picked_up_resource(move) + + @action(auto_prefix=True, description="放下已搬运的实验资源或板件。") + async def drop_resource(self, drop: ResourceDrop): + return await self.backend.drop_resource(drop) + + @action(auto_prefix=True, description="获取是否可以拾取吸头。") + def can_pick_up_tip(self, channel_idx: int, tip: Tip) -> bool: + return self.backend.can_pick_up_tip(channel_idx, tip) + + @action(auto_prefix=True, description="执行 X 轴初始化定位。") + async def position_initialization_x(self): + return await super().position_initialization_x() + + @action(auto_prefix=True, description="获取 X 轴参数。") + async def report_x_param(self, param: int) -> int: + return await super().report_x_param(param) + + @action(auto_prefix=True, description="获取 Y 轴参数。") + async def report_y_param(self, param: int) -> List[int]: + return await super().report_y_param(param) + + @action(auto_prefix=True, description="获取 R 轴参数。") + async def report_r_param(self, param: int) -> int: + return await self._ensure_roma_helper().report_r_param(param) + + @action(auto_prefix=True, description="获取 G 轴参数。") + async def report_g_param(self, param: int) -> int: + return await self._ensure_roma_helper().report_g_param(param) + + @action(auto_prefix=True, description="设置 X 轴平滑移动参数。") + async def set_smooth_move_x(self, mode: int): + return await self._ensure_roma_helper().set_smooth_move_x(mode) + + @action(auto_prefix=True, description="设置 X 轴快速速度。") + async def set_fast_speed_x(self, speed: Optional[int], accel: Optional[int] = None): + return await self._ensure_roma_helper().set_fast_speed_x(speed, accel) + + @action(auto_prefix=True, description="设置 Y 轴快速速度。") + async def set_fast_speed_y(self, speed: Optional[int], accel: Optional[int] = None): + return await self._ensure_roma_helper().set_fast_speed_y(speed, accel) + + @action(auto_prefix=True, description="设置 Z 轴快速速度。") + async def set_fast_speed_z(self, speed: Optional[int], accel: Optional[int] = None): + return await self._ensure_roma_helper().set_fast_speed_z(speed, accel) + + @action(auto_prefix=True, description="设置 R 轴快速速度。") + async def set_fast_speed_r(self, speed: Optional[int], accel: Optional[int] = None): + return await self._ensure_roma_helper().set_fast_speed_r(speed, accel) + + @action(auto_prefix=True, description="设置矢量坐标位置。") + async def set_vector_coordinate_position( + self, + v: int, + x: int, + y: int, + z: int, + r: int, + g: Optional[int], + speed: int, + tw: int = 0, + ): + return await self._ensure_roma_helper().set_vector_coordinate_position(v, x, y, z, r, g, speed, tw) + + @action(auto_prefix=True, description="移动到矢量坐标位置。") + async def action_move_vector_coordinate_position(self): + return await self._ensure_roma_helper().action_move_vector_coordinate_position() + + @action(auto_prefix=True, description="将 G 轴移动到绝对位置。") + async def position_absolute_g(self, g: int): + return await self._ensure_roma_helper().position_absolute_g(g) + + @action(auto_prefix=True, description="设置夹爪参数。") + async def set_gripper_params(self, speed: int, pwm: int, cur: Optional[int] = None): + return await self._ensure_roma_helper().set_gripper_params(speed, pwm, cur) + + @action(auto_prefix=True, description="夹取微孔板或实验板件。") + async def grip_plate(self, pos: int): + return await self._ensure_roma_helper().grip_plate(pos) + + @action(auto_prefix=True, description="设置与设备控制通信使用的目标窗口类别。") + async def set_target_window_class(self, wc: int, x: int, y: int, z: int, r: int, g: int): + return await self._ensure_roma_helper().set_target_window_class(wc, x, y, z, r, g) + + +class Mca(EVOArm): + pass + + +class RoMa(EVOArm): + """ + Provides firmware commands for the RoMa plate robot + """ + + async def report_z_param(self, param: int) -> int: + """Report current parameter for z-axis. + + Args: + param: 0 - current position, 5 - actual machine range + """ + + resp: List[int] = ( + await self.backend.send_command(module=self.module, command="RPZ", params=[param]) + )["data"] + return resp[0] + + async def report_r_param(self, param: int) -> int: + """Report current parameter for r-axis. + + Args: + param: 0 - current position, 5 - actual machine range + """ + + resp: List[int] = ( + await self.backend.send_command(module=self.module, command="RPR", params=[param]) + )["data"] + return resp[0] + + async def report_g_param(self, param: int) -> int: + """Report current parameter for g-axis. + + Args: + param: 0 - current position, 5 - actual machine range + """ + + resp: List[int] = ( + await self.backend.send_command(module=self.module, command="RPG", params=[param]) + )["data"] + return resp[0] + + async def set_smooth_move_x(self, mode: int): + """Sets X-axis smooth move. + + Args: + mode: 0 - active, 1 - normal acceleration and speed used + """ + + await self.backend.send_command(module=self.module, command="SSM", params=[mode]) + + async def set_fast_speed_x(self, speed: Optional[int], accel: Optional[int] = None): + """Set fast speed and acceleration for X-axis. + + Args: + speed: fast speed in 1/10 mm/s + accel: acceleration in 1/10 mm/s^2 + """ + + await self.backend.send_command(module=self.module, command="SFX", params=[speed, accel]) + + async def set_fast_speed_y(self, speed: Optional[int], accel: Optional[int] = None): + """Set fast speed and acceleration for Y-axis. + + Args: + speed: fast speed in 1/10 mm/s + accel: acceleration in 1/10 mm/s^2 + """ + + await self.backend.send_command(module=self.module, command="SFY", params=[speed, accel]) + + async def set_fast_speed_z(self, speed: Optional[int], accel: Optional[int] = None): + """Set fast speed and acceleration for Z-axis. + + Args: + speed: fast speed in 1/10 mm/s + accel: acceleration in 1/10 mm/s^2 + """ + + await self.backend.send_command(module=self.module, command="SFZ", params=[speed, accel]) + + async def set_fast_speed_r(self, speed: Optional[int], accel: Optional[int] = None): + """Set fast speed and acceleration for R-axis. + + Args: + speed: fast speed in 1/10 dg/s + accel: acceleration in 1/10 dg/s^2 + """ + + await self.backend.send_command(module=self.module, command="SFR", params=[speed, accel]) + + async def set_vector_coordinate_position( + self, + v: int, + x: int, + y: int, + z: int, + r: int, + g: Optional[int], + speed: int, + tw: int = 0, + ): + """Sets vector coordinate positions into table. + + Args: + v: vector to be defined, must be between 1 and 100 + x: absolute x position in 1/10 mm + y: absolute y position in 1/10 mm + z: absolute z position in 1/10 mm + r: absolute r position in 1/10 mm + g: absolute g position in 1/10 mm + speed: speed select, 0 - slow, 1 - fast + tw: target window class, set with STW + + Raises: + TecanError: if moving to the target position causes a collision + """ + + cur_x = EVOArm._pos_cache.setdefault(self.module, await self.report_x_param(0)) + for module, pos in EVOArm._pos_cache.items(): + if module == self.module: + continue + if cur_x < x and cur_x < pos < x: # moving right + raise TecanError("Invalid command (collision)", self.module, 2) + if cur_x > x and cur_x > pos > x: # moving left + raise TecanError("Invalid command (collision)", self.module, 2) + if abs(pos - x) < 1500: + raise TecanError("Invalid command (collision)", self.module, 2) + + await self.backend.send_command( + module=self.module, + command="SAA", + params=[v, x, y, z, r, g, speed, 0, tw], + ) + + async def action_move_vector_coordinate_position(self): + """Starts coordinate movement, built by vector coordinate table.""" + + await self.backend.send_command(module=self.module, command="AAC") + + EVOArm._pos_cache[self.module] = await self.report_x_param(0) + + async def position_absolute_g(self, g: int): + """Position absolute for G-axis + + Args: + g: absolute position in 1/10 mm + """ + + await self.backend.send_command(module=self.module, command="PAG", params=[g]) + + async def set_gripper_params(self, speed: int, pwm: int, cur: Optional[int] = None): + """Set gripper parameters + + Args: + speed: gripper search speed in 1/10 mm/s + pwm: pulse width modification limit + cur: maximal allowed current + """ + + await self.backend.send_command(module=self.module, command="SGG", params=[speed, pwm, cur]) + + async def grip_plate(self, pos: int): + """Grips plate at current X/Y/Z/R-position + + Args: + pos: target position, plate must be fetched within current and target position + """ + + await self.backend.send_command(module=self.module, command="AGR", params=[pos]) + + async def set_target_window_class(self, wc: int, x: int, y: int, z: int, r: int, g: int): + """Sets drive parameters for the AAC command. + + Args: + wc: window class, must be between 1 and 100 + x: target window for x-axis in 1/10 mm + y: target window for y-axis in 1/10 mm + z: target window for z-axis in 1/10 mm + r: target window for r-axis in 1/10 deg + g: target window for g-axis in 1/10 mm + """ + + await self.backend.send_command(module=self.module, command="STW", params=[wc, x, y, z, r, g]) + + +# Deprecated alias with warning # TODO: remove mid May 2025 (giving people 1 month to update) +# https://github.com/PyLabRobot/pylabrobot/issues/466 + + +class EVO(EVOBackend): + def __init__(self, *args, **kwargs): + raise RuntimeError( + "`EVO` is deprecated. Please use `EVOBackend` instead.", + ) diff --git a/unilabos/devices/_phage_display/molecular_devices_backend.py b/unilabos/devices/_phage_display/molecular_devices_backend.py new file mode 100644 index 000000000..cc915a0de --- /dev/null +++ b/unilabos/devices/_phage_display/molecular_devices_backend.py @@ -0,0 +1,1125 @@ +""" +Auto-generated by Build Agent +Original: pylabrobot/plate_reading/molecular_devices/backend.py + +[UNILAB DEBUG] 此驱动已注入调试日志 +- 每个方法被调用时会打印日志 +- 日志格式: [UNILAB] ClassName.method_name() called +- 查看日志: logging.getLogger("unilab.driver").setLevel(logging.DEBUG) +""" +from unilabos.registry.decorators import action, device +from typing import Dict, Any, Optional +import logging +import warnings as _warnings +_warnings.filterwarnings("ignore", message=r"pkg_resources is deprecated as an API\..*") +import sys as _sys +import types as _types + +# 兼容性:部分上游框架(例如 lantz)在 import-time 强制加载 Qt(PyQt4/PySide1), +# 在 headless/现代 Python 环境中通常不存在这些绑定,会导致导入直接失败。 +# 这里预注入一个最小的 `lantz.utils.qt` shim,避免触发其 Qt loader。 +if "lantz.utils.qt" not in _sys.modules: + _qt_mod = _types.ModuleType("lantz.utils.qt") + + class _DummyQtCore: + class QObject: + def __init__(self, *_args, **_kwargs): + # 兼容 QObject(parent=...) 等调用 + return None + + @staticmethod + def Signal(*_args, **_kwargs): + return object() + + @staticmethod + def Slot(*_args, **_kwargs): + def _decorator(fn): + return fn + + return _decorator + + _qt_mod.QtCore = _DummyQtCore + + class _DummySuperQObject(_DummyQtCore.QObject): + def __new__(cls, *_args, **_kwargs): + return super().__new__(cls) + + def __init__(self, *_args, **_kwargs): + # 允许 Driver(...) 传参,不触发 object.__init__ 的 TypeError + return None + + _qt_mod.MetaQObject = type + _qt_mod.SuperQObject = _DummySuperQObject + _sys.modules["lantz.utils.qt"] = _qt_mod + +# Unilab 驱动调试日志 +_unilab_logger = logging.getLogger("unilab.driver.MolecularDevicesBackend") +_unilab_logger.setLevel(logging.DEBUG) +if not _unilab_logger.handlers: + _handler = logging.StreamHandler() + _handler.setFormatter(logging.Formatter("[%(asctime)s] %(message)s")) + _unilab_logger.addHandler(_handler) + +# Vendored repo packages: pylabrobot, pylabrobot +import sys as _sys +from pathlib import Path as _Path +_vendor_root = _Path(__file__).resolve().parent / "_vendor" +if _vendor_root.exists() and str(_vendor_root) not in _sys.path: + _sys.path.insert(0, str(_vendor_root)) + +import asyncio +import logging +import re +import time +from abc import ABCMeta +from dataclasses import dataclass, field +from enum import Enum +from typing import Dict, List, Literal, Optional, Tuple, Union + +from pylabrobot.io.serial import Serial +from pylabrobot.plate_reading.backend import PlateReaderBackend +from pylabrobot.resources.plate import Plate + +logger = logging.getLogger("pylabrobot") + +RES_TERM_CHAR = b">" +COMMAND_TERMINATORS: Dict[str, int] = { + "!AUTOFILTER": 1, + "!AUTOPMT": 1, + "!BAUD": 1, + "!CALIBRATE": 1, + "!CANCEL": 1, + "!CLEAR": 1, + "!CLOSE": 1, + "!CSPEED": 1, + "!REFERENCE": 1, + "!EMFILTER": 1, + "!EMWAVELENGTH": 1, + "!ERROR": 2, + "!EXWAVELENGTH": 1, + "!FPW": 1, + "!INIT": 1, + "!MODE": 1, + "!NVRAM": 1, + "!OPEN": 1, + "!ORDER": 1, + "OPTION": 2, + "!AIR_CAL": 1, + "!PMT": 1, + "!PMTCAL": 1, + "!QUEUE": 2, + "!READ": 1, + "!TOP": 1, + "!READSTAGE": 2, + "!READTYPE": 2, + "!RESEND": 1, + "!RESET": 1, + "!SHAKE": 1, + "!SPEED": 2, + "!STATUS": 2, + "!STRIP": 1, + "!TAG": 1, + "!TEMP": 2, + "!TRANSFER": 2, + "!USER_NUMBER": 2, + "!XPOS": 1, + "!YPOS": 1, + "!WAVELENGTH": 1, + "!WELLSCANMODE": 2, + "!PATHCAL": 2, + "!COUNTTIME": 1, + "!COUNTTIMEDELAY": 1, +} + + +class MolecularDevicesError(Exception): + """Exceptions raised by a Molecular Devices plate reader.""" + + +class MolecularDevicesUnrecognizedCommandError(MolecularDevicesError): + """Unrecognized command errors sent from the computer.""" + + +class MolecularDevicesFirmwareError(MolecularDevicesError): + """Firmware errors.""" + + +class MolecularDevicesHardwareError(MolecularDevicesError): + """Hardware errors.""" + + +class MolecularDevicesMotionError(MolecularDevicesError): + """Motion errors.""" + + +class MolecularDevicesNVRAMError(MolecularDevicesError): + """NVRAM errors.""" + + +ERROR_CODES: Dict[int, Tuple[str, type]] = { + 100: ("command not found", MolecularDevicesUnrecognizedCommandError), + 101: ("invalid argument", MolecularDevicesUnrecognizedCommandError), + 102: ("too many arguments", MolecularDevicesUnrecognizedCommandError), + 103: ("not enough arguments", MolecularDevicesUnrecognizedCommandError), + 104: ("input line too long", MolecularDevicesUnrecognizedCommandError), + 105: ("command invalid, system busy", MolecularDevicesUnrecognizedCommandError), + 106: ("command invalid, measurement in progress", MolecularDevicesUnrecognizedCommandError), + 107: ("no data to transfer", MolecularDevicesUnrecognizedCommandError), + 108: ("data buffer full", MolecularDevicesUnrecognizedCommandError), + 109: ("error buffer overflow", MolecularDevicesUnrecognizedCommandError), + 110: ("stray light cuvette, door open?", MolecularDevicesUnrecognizedCommandError), + 111: ("invalid read settings", MolecularDevicesUnrecognizedCommandError), + 200: ("assert failed", MolecularDevicesFirmwareError), + 201: ("bad error number", MolecularDevicesFirmwareError), + 202: ("receive queue overflow", MolecularDevicesFirmwareError), + 203: ("serial port parity error", MolecularDevicesFirmwareError), + 204: ("serial port overrun error", MolecularDevicesFirmwareError), + 205: ("serial port framing error", MolecularDevicesFirmwareError), + 206: ("cmd generated too much output", MolecularDevicesFirmwareError), + 207: ("fatal trap", MolecularDevicesFirmwareError), + 208: ("RTOS error", MolecularDevicesFirmwareError), + 209: ("stack overflow", MolecularDevicesFirmwareError), + 210: ("unknown interrupt", MolecularDevicesFirmwareError), + 300: ("thermistor faulty", MolecularDevicesHardwareError), + 301: ("safe temperature limit exceeded", MolecularDevicesHardwareError), + 302: ("low light", MolecularDevicesHardwareError), + 303: ("unable to cal dark current", MolecularDevicesHardwareError), + 304: ("signal level saturation", MolecularDevicesHardwareError), + 305: ("reference level saturation", MolecularDevicesHardwareError), + 306: ("plate air cal fail, low light", MolecularDevicesHardwareError), + 307: ("cuv air ref fail", MolecularDevicesHardwareError), + 308: ("stray light", MolecularDevicesHardwareError), + 312: ("gain calibration failed", MolecularDevicesHardwareError), + 313: ("reference gain check fail", MolecularDevicesHardwareError), + 314: ("low lamp level warning", MolecularDevicesHardwareError), + 315: ("can't find zero order", MolecularDevicesHardwareError), + 316: ("grating motor driver faulty", MolecularDevicesHardwareError), + 317: ("monitor ADC faulty", MolecularDevicesHardwareError), + 400: ("carriage motion error", MolecularDevicesMotionError), + 401: ("filter wheel error", MolecularDevicesMotionError), + 402: ("grating error", MolecularDevicesMotionError), + 403: ("stage error", MolecularDevicesMotionError), + 500: ("NVRAM CRC corrupt", MolecularDevicesNVRAMError), + 501: ("NVRAM Grating cal data bad", MolecularDevicesNVRAMError), + 502: ("NVRAM Cuvette air cal data error", MolecularDevicesNVRAMError), + 503: ("NVRAM Plate air cal data error", MolecularDevicesNVRAMError), + 504: ("NVRAM Carriage offset error", MolecularDevicesNVRAMError), + 505: ("NVRAM Stage offset error", MolecularDevicesNVRAMError), +} + + +MolecularDevicesResponse = List[str] + + +class ReadMode(Enum): + """The read mode of the plate reader (e.g., Absorbance, Fluorescence).""" + + ABS = "ABS" + FLU = "FLU" + LUM = "LUM" + POLAR = "POLAR" + TIME = "TIME" + + +class ReadType(Enum): + """The type of read to perform (e.g., Endpoint, Kinetic).""" + + ENDPOINT = "ENDPOINT" + KINETIC = "KINETIC" + SPECTRUM = "SPECTRUM" + WELL_SCAN = "WELLSCAN" + + +class ReadOrder(Enum): + """The order in which to read the plate wells.""" + + COLUMN = "COLUMN" + WAVELENGTH = "WAVELENGTH" + + +class Calibrate(Enum): + """The calibration mode for the read.""" + + ON = "ON" + ONCE = "ONCE" + OFF = "OFF" + + +class CarriageSpeed(Enum): + """The speed of the plate carriage.""" + + NORMAL = "8" + SLOW = "1" + + +class PmtGain(Enum): + """The photomultiplier tube gain setting.""" + + AUTO = "ON" + HIGH = "HIGH" + MEDIUM = "MED" + LOW = "LOW" + + +@dataclass +class ShakeSettings: + """Settings for shaking the plate during a read.""" + + before_read: bool = False + before_read_duration: int = 0 + between_reads: bool = False + between_reads_duration: int = 0 + + +@dataclass +class KineticSettings: + """Settings for kinetic reads.""" + + interval: int + num_readings: int + + +@dataclass +class SpectrumSettings: + """Settings for spectrum reads.""" + + start_wavelength: int + step: int + num_steps: int + excitation_emission_type: Optional[Literal["EXSPECTRUM", "EMSPECTRUM"]] = None + + +@dataclass +class MolecularDevicesSettings: + """A comprehensive, internal container for all plate reader settings.""" + + plate: Plate = field(repr=False) + read_mode: ReadMode + read_type: ReadType + read_order: ReadOrder + calibrate: Calibrate + shake_settings: Optional[ShakeSettings] + carriage_speed: CarriageSpeed + speed_read: bool + kinetic_settings: Optional[KineticSettings] + spectrum_settings: Optional[SpectrumSettings] + wavelengths: List[Union[int, Tuple[int, bool]]] = field(default_factory=list) + excitation_wavelengths: List[int] = field(default_factory=list) + emission_wavelengths: List[int] = field(default_factory=list) + cutoff_filters: List[int] = field(default_factory=list) + path_check: bool = False + read_from_bottom: bool = False + pmt_gain: Union[PmtGain, int] = PmtGain.AUTO + flashes_per_well: int = 1 + cuvette: bool = False + settling_time: int = 0 + + +@device( + id="molecular_devices_backend", + category=["Microplate Reader"], + description="这是一种用于读取微孔板样品信号的多功能酶标仪,可进行吸光度、荧光、化学发光、荧光偏振和时间分辨荧光检测。设备通常用于生化分析、细胞实验、免疫检测、酶活性测定和高通量筛选,并支持控温与振荡等板上实验条件控制。", + model={ + "type": "device", + "mesh": "molecular_devices_backend", + "path": "https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/devices/molecular_devices_backend/macro_device.xacro", + }, +) +class MolecularDevicesBackend(PlateReaderBackend, metaclass=ABCMeta): + """Backend for Molecular Devices plate readers.""" + + def __init__(self, port: str) -> None: + print("[UNILAB] MolecularDevicesBackend.__init__() called", flush=True) + self.port = port + self.io = Serial(self.port, baudrate=9600, timeout=0.2) + + @action(auto_prefix=True, description="初始化并准备酶标仪进行操作。") + async def setup(self) -> None: + _unilab_logger.debug("[UNILAB] MolecularDevicesBackend.setup() called") + await self.io.setup() + await self.send_command("!") + + @action(auto_prefix=True, description="停止当前仪器操作。") + async def stop(self) -> None: + _unilab_logger.debug("[UNILAB] MolecularDevicesBackend.stop() called") + await self.io.stop() + + @action(auto_prefix=True, description="导出当前设备配置与设置信息。") + def serialize(self) -> dict: + _unilab_logger.debug("[UNILAB] MolecularDevicesBackend.serialize() called") + return {**super().serialize(), "port": self.port} + + @action(auto_prefix=True, description="向酶标仪发送设备命令。") + async def send_command( + self, command: str, timeout: int = 60, num_res_fields=None + ) -> MolecularDevicesResponse: + _unilab_logger.debug("[UNILAB] MolecularDevicesBackend.send_command() called") + """Send a command and receive the response, automatically determining the number of + response fields. + """ + base_command = command.split(" ")[0] + if num_res_fields is None: + num_res_fields = COMMAND_TERMINATORS.get(base_command, 1) + else: + num_res_fields = max(1, num_res_fields) + + await self.io.write(command.encode() + b"\r") + raw_response = b"" + timeout_time = time.time() + timeout + while True: + raw_response += await self.io.readline() + await asyncio.sleep(0.001) + if time.time() > timeout_time: + raise TimeoutError(f"Timeout waiting for response to command: {command}") + if raw_response.count(RES_TERM_CHAR) >= num_res_fields: + break + logger.debug("[plate reader] Command: %s, Response: %s", command, raw_response) + response = raw_response.decode("utf-8").strip().split(RES_TERM_CHAR.decode()) + response = [r.strip() for r in response if r.strip() != ""] + self._parse_basic_errors(response, command) + return response + + def _parse_basic_errors(self, response: List[str], command: str) -> None: + _unilab_logger.debug("[UNILAB] MolecularDevicesBackend._parse_basic_errors() called") + if not response: + raise MolecularDevicesError(f"Command '{command}' failed with empty response.") + + # Check for FAIL in the response + error_code_msg = response[0] if "FAIL" in response[0] else response[-1] + if "FAIL" in error_code_msg: + parts = error_code_msg.split("\t") + try: + error_code_str = parts[-1] + error_code = int(error_code_str.strip()) + if error_code in ERROR_CODES: + message, err_class = ERROR_CODES[error_code] + raise err_class(f"Command '{command}' failed with error {error_code}: {message}") + raise MolecularDevicesError( + f"Command '{command}' failed with unknown error code: {error_code}" + ) + except (ValueError, IndexError): + raise MolecularDevicesError( + f"Command '{command}' failed with unparsable error: {response[0]}" + ) + + if "OK" not in response[0]: + raise MolecularDevicesError(f"Command '{command}' failed with response: {response}") + if "warning" in response[0].lower(): + logger.warning("Warning for command '%s': %s", command, response) + + @action(auto_prefix=True, description="打开酶标仪的载板抽屉或托盘。") + async def open(self) -> None: + _unilab_logger.debug("[UNILAB] MolecularDevicesBackend.open() called") + await self.send_command("!OPEN") + + @action(auto_prefix=True, description="关闭酶标仪的载板抽屉或托盘。") + async def close(self, plate: Optional[Plate] = None) -> None: + _unilab_logger.debug("[UNILAB] MolecularDevicesBackend.close() called") + await self.send_command("!CLOSE") + + @action(auto_prefix=True, description="获取状态。") + async def get_status(self) -> List[str]: + _unilab_logger.debug("[UNILAB] MolecularDevicesBackend.get_status() called") + res = await self.send_command("!STATUS") + return res[1].split() + + @action(auto_prefix=True, description="读取仪器错误日志。") + async def read_error_log(self) -> str: + _unilab_logger.debug("[UNILAB] MolecularDevicesBackend.read_error_log() called") + res = await self.send_command("!ERROR") + return res[1] + + @action(auto_prefix=True, description="清除仪器错误日志。") + async def clear_error_log(self) -> None: + _unilab_logger.debug("[UNILAB] MolecularDevicesBackend.clear_error_log() called") + await self.send_command("!CLEAR ERROR") + + @action(auto_prefix=True, description="获取温度。") + async def get_temperature(self) -> Tuple[float, float]: + _unilab_logger.debug("[UNILAB] MolecularDevicesBackend.get_temperature() called") + res = await self.send_command("!TEMP") + parts = res[1].split() + return (float(parts[1]), float(parts[0])) # current, set_point + + @action(auto_prefix=True, description="设置温度。") + async def set_temperature(self, temperature: float) -> None: + _unilab_logger.debug("[UNILAB] MolecularDevicesBackend.set_temperature() called") + if not (0 <= temperature <= 45): + raise ValueError("Temperature must be between 0 and 45°C.") + await self.send_command(f"!TEMP {temperature}") + + @action(auto_prefix=True, description="获取固件版本。") + async def get_firmware_version(self) -> List[str]: + _unilab_logger.debug("[UNILAB] MolecularDevicesBackend.get_firmware_version() called") + res = await self.send_command("!OPTION") + return res[1].split() + + @action(auto_prefix=True, description="启动微孔板振荡。") + async def start_shake(self) -> None: + _unilab_logger.debug("[UNILAB] MolecularDevicesBackend.start_shake() called") + await self.send_command("!SHAKE NOW") + + @action(auto_prefix=True, description="停止微孔板振荡。") + async def stop_shake(self) -> None: + _unilab_logger.debug("[UNILAB] MolecularDevicesBackend.stop_shake() called") + await self.send_command("!SHAKE STOP") + + async def _read_now(self) -> None: + _unilab_logger.debug("[UNILAB] MolecularDevicesBackend._read_now() called") + await self.send_command("!READ") + + async def _transfer_data(self, settings: MolecularDevicesSettings) -> List[Dict]: + _unilab_logger.debug("[UNILAB] MolecularDevicesBackend._transfer_data() called") + """Transfer data from the plate reader. For kinetic/spectrum reads, this will transfer data for each + reading and combine them into a single collection. + """ + + if (settings.read_type == ReadType.KINETIC and settings.kinetic_settings) or ( + settings.read_type == ReadType.SPECTRUM and settings.spectrum_settings + ): + if settings.kinetic_settings: + num_readings = settings.kinetic_settings.num_readings + elif settings.spectrum_settings: + num_readings = settings.spectrum_settings.num_steps + else: + raise ValueError("Kinetic or Spectrum settings must be provided for this read type.") + + all_reads = [] + for _ in range(num_readings): + res = await self.send_command("!TRANSFER") + data_str = res[1] + read_data = self._parse_data(data_str, settings) + all_reads.extend(read_data) # Unpack the list + return all_reads + + # For ENDPOINT + res = await self.send_command("!TRANSFER") + data_str = res[1] + return self._parse_data(data_str, settings) + + def _parse_data(self, data_str: str, settings: MolecularDevicesSettings) -> List[Dict]: + _unilab_logger.debug("[UNILAB] MolecularDevicesBackend._parse_data() called") + lines = re.split(r"\r\n|\n", data_str.strip()) + lines = [line.strip() for line in lines if line.strip()] + + # 1. Parse header + header_parts = lines[0].split("\t") + measurement_time = float(header_parts[0]) + temperature = float(header_parts[1]) + + # 2. Parse wavelengths + line_idx = 1 + while line_idx < len(lines): + line = lines[line_idx] + if line.startswith("L:") and line_idx > 1: + # Data section started + break + line_idx += 1 + + data_collection = [] + cur_read_wavelengths = [] + # 3. Parse data + data_columns: List[List[float]] = [] + # The data section starts at line_idx + for i in range(line_idx, len(lines)): + line = lines[i] + if line.startswith("L:"): + # start of a new data with different wavelength + cur_read_wavelengths.append(line.split("\t")[1:]) + if i > line_idx and data_columns: + data_collection.append(data_columns) + data_columns = [] + match = re.match(r"^\s*(\d+):\s*(.*)", line) + if match: + values_str = re.split(r"\s+", match.group(2).strip()) + values = [] + for v in values_str: + if v.strip().replace(".", "", 1).isdigit(): + values.append(float(v.strip())) + elif v.strip() == "#SAT": + values.append(float("inf")) + else: + values.append(float("nan")) + data_columns.append(values) + if data_columns: + data_collection.append(data_columns) + + # 4. Transpose data to be row-major + data_collection_transposed = [] + for data_columns in data_collection: + data_rows = [] + if data_columns: + num_rows = len(data_columns[0]) + num_cols = len(data_columns) + for i in range(num_rows): + row = [data_columns[j][i] for j in range(num_cols)] + data_rows.append(row) + data_collection_transposed.append(data_rows) + + measurements = [] + read_mode = settings.read_mode + for i, data_rows in enumerate(data_collection_transposed): + measurement = { + "data": data_rows, + "temperature": temperature, + "time": measurement_time, + } + if read_mode == ReadMode.ABS: + wl = int(cur_read_wavelengths[i][0]) + measurement["wavelength"] = wl + elif read_mode == ReadMode.FLU or read_mode == ReadMode.POLAR or read_mode == ReadMode.TIME: + ex_wl = int(cur_read_wavelengths[i][0]) + em_wl = int(cur_read_wavelengths[i][1]) + measurement["ex_wavelength"] = ex_wl + measurement["em_wavelength"] = em_wl + elif read_mode == ReadMode.LUM: + em_wl = int(cur_read_wavelengths[i][1]) + measurement["em_wavelength"] = em_wl + measurements.append(measurement) + + return measurements + + async def _set_clear(self) -> None: + _unilab_logger.debug("[UNILAB] MolecularDevicesBackend._set_clear() called") + await self.send_command("!CLEAR DATA") + + async def _set_mode(self, settings: MolecularDevicesSettings) -> None: + _unilab_logger.debug("[UNILAB] MolecularDevicesBackend._set_mode() called") + cmd = f"!MODE {settings.read_type.value}" + if settings.read_type == ReadType.KINETIC and settings.kinetic_settings: + ks = settings.kinetic_settings + cmd += f" {ks.interval} {ks.num_readings}" + elif settings.read_type == ReadType.SPECTRUM and settings.spectrum_settings: + ss = settings.spectrum_settings + cmd = "!MODE" + scan_type = ss.excitation_emission_type or "SPECTRUM" + cmd += f" {scan_type} {ss.start_wavelength} {ss.step} {ss.num_steps}" + await self.send_command(cmd) + + async def _set_wavelengths(self, settings: MolecularDevicesSettings) -> None: + _unilab_logger.debug("[UNILAB] MolecularDevicesBackend._set_wavelengths() called") + if settings.read_mode == ReadMode.ABS: + wl_parts = [] + for wl in settings.wavelengths: + wl_parts.append(f"F{wl[0]}" if isinstance(wl, tuple) and wl[1] else str(wl)) + wl_str = " ".join(wl_parts) + if settings.path_check: + wl_str += " 900 998" + await self.send_command(f"!WAVELENGTH {wl_str}") + elif settings.read_mode in (ReadMode.FLU, ReadMode.POLAR, ReadMode.TIME): + ex_wl_str = " ".join(map(str, settings.excitation_wavelengths)) + em_wl_str = " ".join(map(str, settings.emission_wavelengths)) + await self.send_command(f"!EXWAVELENGTH {ex_wl_str}") + await self.send_command(f"!EMWAVELENGTH {em_wl_str}") + elif settings.read_mode == ReadMode.LUM: + wl_str = " ".join(map(str, settings.emission_wavelengths)) + await self.send_command(f"!EMWAVELENGTH {wl_str}") + else: + raise NotImplementedError("f{settings.read_mode} not supported") + + async def _set_plate_position(self, settings: MolecularDevicesSettings) -> None: + _unilab_logger.debug("[UNILAB] MolecularDevicesBackend._set_plate_position() called") + plate = settings.plate + num_cols, num_rows, size_y = plate.num_items_x, plate.num_items_y, plate.get_size_y() + if num_cols < 2 or num_rows < 2: + raise ValueError("Plate must have at least 2 rows and 2 columns to calculate well spacing.") + top_left_well = plate.get_item(0) + if top_left_well.location is None: + raise ValueError("Top left well location is not set.") + top_left_well_center = top_left_well.location + top_left_well.get_anchor(x="c", y="c") + loc_A1 = plate.get_item("A1").location + loc_A2 = plate.get_item("A2").location + loc_B1 = plate.get_item("B1").location + if loc_A1 is None or loc_A2 is None or loc_B1 is None: + raise ValueError("Well locations for A1, A2, or B1 are not set.") + dx = loc_A2.x - loc_A1.x + dy = loc_A1.y - loc_B1.y + + x_pos_cmd = f"!XPOS {top_left_well_center.x:.3f} {dx:.3f} {num_cols}" + y_pos_cmd = f"!YPOS {size_y - top_left_well_center.y:.3f} {dy:.3f} {num_rows}" + await self.send_command(x_pos_cmd) + await self.send_command(y_pos_cmd) + + async def _set_strip(self, settings: MolecularDevicesSettings) -> None: + _unilab_logger.debug("[UNILAB] MolecularDevicesBackend._set_strip() called") + await self.send_command(f"!STRIP 1 {settings.plate.num_items_x}") + + async def _set_shake(self, settings: MolecularDevicesSettings) -> None: + _unilab_logger.debug("[UNILAB] MolecularDevicesBackend._set_shake() called") + if not settings.shake_settings: + await self.send_command("!SHAKE OFF") + return + ss = settings.shake_settings + shake_mode = "ON" if ss.before_read or ss.between_reads else "OFF" + before_duration = ss.before_read_duration if ss.before_read else 0 + ki = settings.kinetic_settings.interval if settings.kinetic_settings else 0 + if ss.between_reads and ki > 0: + between_duration = ss.between_reads_duration + wait_duration = ki - between_duration + else: + between_duration = 0 + wait_duration = 0 + await self.send_command(f"!SHAKE {shake_mode}") + await self.send_command(f"!SHAKE {before_duration} {ki} {wait_duration} {between_duration} 0") + + async def _set_carriage_speed(self, settings: MolecularDevicesSettings) -> None: + _unilab_logger.debug("[UNILAB] MolecularDevicesBackend._set_carriage_speed() called") + await self.send_command(f"!CSPEED {settings.carriage_speed.value}") + + async def _set_read_stage(self, settings: MolecularDevicesSettings) -> None: + _unilab_logger.debug("[UNILAB] MolecularDevicesBackend._set_read_stage() called") + if settings.read_mode in (ReadMode.FLU, ReadMode.LUM, ReadMode.POLAR, ReadMode.TIME): + stage = "BOT" if settings.read_from_bottom else "TOP" + await self.send_command(f"!READSTAGE {stage}") + + async def _set_flashes_per_well(self, settings: MolecularDevicesSettings) -> None: + _unilab_logger.debug("[UNILAB] MolecularDevicesBackend._set_flashes_per_well() called") + if settings.read_mode in (ReadMode.FLU, ReadMode.LUM, ReadMode.POLAR, ReadMode.TIME): + await self.send_command(f"!FPW {settings.flashes_per_well}") + + async def _set_pmt(self, settings: MolecularDevicesSettings) -> None: + _unilab_logger.debug("[UNILAB] MolecularDevicesBackend._set_pmt() called") + if settings.read_mode not in (ReadMode.FLU, ReadMode.LUM, ReadMode.POLAR, ReadMode.TIME): + return + gain = settings.pmt_gain + if gain == PmtGain.AUTO: + await self.send_command("!AUTOPMT ON") + else: + gain_val = gain.value if isinstance(gain, PmtGain) else gain + await self.send_command("!AUTOPMT OFF") + await self.send_command(f"!PMT {gain_val}") + + async def _set_filter(self, settings: MolecularDevicesSettings) -> None: + _unilab_logger.debug("[UNILAB] MolecularDevicesBackend._set_filter() called") + if ( + settings.read_mode in (ReadMode.FLU, ReadMode.POLAR, ReadMode.TIME) + and settings.cutoff_filters + ): + cf_str = " ".join(map(str, settings.cutoff_filters)) + await self.send_command("!AUTOFILTER OFF") + await self.send_command(f"!EMFILTER {cf_str}") + else: + await self.send_command("!AUTOFILTER ON") + + async def _set_calibrate(self, settings: MolecularDevicesSettings) -> None: + _unilab_logger.debug("[UNILAB] MolecularDevicesBackend._set_calibrate() called") + if settings.read_mode == ReadMode.ABS: + await self.send_command(f"!CALIBRATE {settings.calibrate.value}") + else: + await self.send_command(f"!PMTCAL {settings.calibrate.value}") + + async def _set_order(self, settings: MolecularDevicesSettings) -> None: + _unilab_logger.debug("[UNILAB] MolecularDevicesBackend._set_order() called") + await self.send_command(f"!ORDER {settings.read_order.value}") + + async def _set_speed(self, settings: MolecularDevicesSettings) -> None: + _unilab_logger.debug("[UNILAB] MolecularDevicesBackend._set_speed() called") + if settings.read_mode == ReadMode.ABS: + mode = "ON" if settings.speed_read else "OFF" + await self.send_command(f"!SPEED {mode}") + + async def _set_nvram(self, settings: MolecularDevicesSettings) -> None: + _unilab_logger.debug("[UNILAB] MolecularDevicesBackend._set_nvram() called") + if settings.read_mode == ReadMode.POLAR: + command = "FPSETTLETIME" + value = settings.settling_time + else: + command = "CARCOL" + value = settings.settling_time if settings.settling_time > 100 else 100 + await self.send_command(f"!NVRAM {command} {value}") + + async def _set_tag(self, settings: MolecularDevicesSettings) -> None: + _unilab_logger.debug("[UNILAB] MolecularDevicesBackend._set_tag() called") + if settings.read_mode == ReadMode.POLAR and settings.read_type == ReadType.KINETIC: + await self.send_command("!TAG ON") + else: + await self.send_command("!TAG OFF") + + async def _set_readtype(self, settings: MolecularDevicesSettings) -> None: + _unilab_logger.debug("[UNILAB] MolecularDevicesBackend._set_readtype() called") + """Set the READTYPE command and the expected number of response fields.""" + cuvette = settings.cuvette + num_res_fields = COMMAND_TERMINATORS.get("!READTYPE", 2) + + if settings.read_mode == ReadMode.ABS: + cmd = f"!READTYPE ABS{'CUV' if cuvette else 'PLA'}" + elif settings.read_mode == ReadMode.FLU: + cmd = f"!READTYPE FLU{'CUV' if cuvette else ''}" + num_res_fields = 2 if cuvette else 1 + elif settings.read_mode == ReadMode.LUM: + cmd = f"!READTYPE LUM{'CUV' if cuvette else ''}" + num_res_fields = 2 if cuvette else 1 + elif settings.read_mode == ReadMode.POLAR: + cmd = "!READTYPE POLAR" + num_res_fields = 1 + elif settings.read_mode == ReadMode.TIME: + cmd = "!READTYPE TIME 0 250" + num_res_fields = 1 + else: + raise ValueError(f"Unsupported read mode: {settings.read_mode}") + + await self.send_command(cmd, num_res_fields=num_res_fields) + + async def _set_integration_time( + self, settings: MolecularDevicesSettings, delay_time: int, integration_time: int + ) -> None: + _unilab_logger.debug("[UNILAB] MolecularDevicesBackend._set_integration_time() called") + if settings.read_mode == ReadMode.TIME: + await self.send_command(f"!COUNTTIMEDELAY {delay_time}") + await self.send_command(f"!COUNTTIME {integration_time * 0.001}") + + def _get_cutoff_filter_index_from_wavelength(self, wavelength: int) -> int: + _unilab_logger.debug("[UNILAB] MolecularDevicesBackend._get_cutoff_filter_index_from_wavelength() called") + """Converts a wavelength to a cutoff filter index.""" + # This map is a direct translation of the `EmissionCutoff.CutoffFilter` in MaxlineModel.cs + # (min_wavelength, max_wavelength, cutoff_filter_index) + FILTERS = [ + (0, 322, 1), + (325, 415, 16), + (420, 435, 2), + (435, 455, 3), + (455, 475, 4), + (475, 495, 5), + (495, 515, 6), + (515, 530, 7), + (530, 550, 8), + (550, 570, 9), + (570, 590, 10), + (590, 610, 11), + (610, 630, 12), + (630, 665, 13), + (665, 695, 14), + (695, 900, 15), + ] + for min_wl, max_wl, cutoff_filter_index in FILTERS: + if min_wl <= wavelength < max_wl: + return cutoff_filter_index + raise ValueError(f"No cutoff filter found for wavelength {wavelength}") + + async def _wait_for_idle(self, timeout: int = 600): + _unilab_logger.debug("[UNILAB] MolecularDevicesBackend._wait_for_idle() called") + """Wait for the plate reader to become idle.""" + start_time = time.time() + while True: + if time.time() - start_time > timeout: + raise TimeoutError("Timeout waiting for plate reader to become idle.") + status = await self.get_status() + if status and status[1] == "IDLE": + break + await asyncio.sleep(1) + + @action(auto_prefix=True, description="对微孔板执行吸光度读数。") + async def read_absorbance( # type: ignore[override] + self, + plate: Plate, + wavelengths: List[Union[int, Tuple[int, bool]]], + read_type: ReadType = ReadType.ENDPOINT, + read_order: ReadOrder = ReadOrder.COLUMN, + calibrate: Calibrate = Calibrate.ONCE, + shake_settings: Optional[ShakeSettings] = None, + carriage_speed: CarriageSpeed = CarriageSpeed.NORMAL, + speed_read: bool = False, + path_check: bool = False, + kinetic_settings: Optional[KineticSettings] = None, + spectrum_settings: Optional[SpectrumSettings] = None, + cuvette: bool = False, + settling_time: int = 0, + timeout: int = 600, + ) -> List[Dict]: + _unilab_logger.debug("[UNILAB] MolecularDevicesBackend.read_absorbance() called") + settings = MolecularDevicesSettings( + plate=plate, + read_mode=ReadMode.ABS, + read_type=read_type, + read_order=read_order, + calibrate=calibrate, + shake_settings=shake_settings, + carriage_speed=carriage_speed, + speed_read=speed_read, + path_check=path_check, + kinetic_settings=kinetic_settings, + spectrum_settings=spectrum_settings, + wavelengths=wavelengths, + cuvette=cuvette, + settling_time=settling_time, + ) + await self._set_clear() + if not cuvette: + await self._set_plate_position(settings) + await self._set_strip(settings) + await self._set_carriage_speed(settings) + + await self._set_shake(settings) + await self._set_wavelengths(settings) + await self._set_calibrate(settings) + await self._set_mode(settings) + await self._set_order(settings) + await self._set_speed(settings) + await self._set_tag(settings) + await self._set_nvram(settings) + await self._set_readtype(settings) + + await self._read_now() + await self._wait_for_idle(timeout=timeout) + return await self._transfer_data(settings) + + @action(auto_prefix=True, description="对微孔板执行荧光读数。") + async def read_fluorescence( # type: ignore[override] + self, + plate: Plate, + excitation_wavelengths: List[int], + emission_wavelengths: List[int], + cutoff_filters: List[int], + read_type: ReadType = ReadType.ENDPOINT, + read_order: ReadOrder = ReadOrder.COLUMN, + calibrate: Calibrate = Calibrate.ONCE, + shake_settings: Optional[ShakeSettings] = None, + carriage_speed: CarriageSpeed = CarriageSpeed.NORMAL, + read_from_bottom: bool = False, + pmt_gain: Union[PmtGain, int] = PmtGain.AUTO, + flashes_per_well: int = 10, + kinetic_settings: Optional[KineticSettings] = None, + spectrum_settings: Optional[SpectrumSettings] = None, + cuvette: bool = False, + settling_time: int = 0, + timeout: int = 600, + ) -> List[Dict]: + _unilab_logger.debug("[UNILAB] MolecularDevicesBackend.read_fluorescence() called") + """use _get_cutoff_filter_index_from_wavelength for cutoff_filters""" + settings = MolecularDevicesSettings( + plate=plate, + read_mode=ReadMode.FLU, + read_type=read_type, + read_order=read_order, + calibrate=calibrate, + shake_settings=shake_settings, + carriage_speed=carriage_speed, + read_from_bottom=read_from_bottom, + pmt_gain=pmt_gain, + flashes_per_well=flashes_per_well, + kinetic_settings=kinetic_settings, + spectrum_settings=spectrum_settings, + excitation_wavelengths=excitation_wavelengths, + emission_wavelengths=emission_wavelengths, + cutoff_filters=cutoff_filters, + cuvette=cuvette, + speed_read=False, + settling_time=settling_time, + ) + await self._set_clear() + if not cuvette: + await self._set_plate_position(settings) + await self._set_strip(settings) + await self._set_carriage_speed(settings) + + await self._set_shake(settings) + await self._set_flashes_per_well(settings) + await self._set_pmt(settings) + await self._set_wavelengths(settings) + await self._set_filter(settings) + await self._set_read_stage(settings) + await self._set_calibrate(settings) + await self._set_mode(settings) + await self._set_order(settings) + await self._set_tag(settings) + await self._set_nvram(settings) + await self._set_readtype(settings) + + await self._read_now() + await self._wait_for_idle(timeout=timeout) + return await self._transfer_data(settings) + + @action(auto_prefix=True, description="对微孔板执行发光读数。") + async def read_luminescence( # type: ignore[override] + self, + plate: Plate, + emission_wavelengths: List[int], + read_type: ReadType = ReadType.ENDPOINT, + read_order: ReadOrder = ReadOrder.COLUMN, + calibrate: Calibrate = Calibrate.ONCE, + shake_settings: Optional[ShakeSettings] = None, + carriage_speed: CarriageSpeed = CarriageSpeed.NORMAL, + read_from_bottom: bool = False, + pmt_gain: Union[PmtGain, int] = PmtGain.AUTO, + flashes_per_well: int = 0, + kinetic_settings: Optional[KineticSettings] = None, + spectrum_settings: Optional[SpectrumSettings] = None, + cuvette: bool = False, + settling_time: int = 0, + timeout: int = 600, + ) -> List[Dict]: + _unilab_logger.debug("[UNILAB] MolecularDevicesBackend.read_luminescence() called") + settings = MolecularDevicesSettings( + plate=plate, + read_mode=ReadMode.LUM, + read_type=read_type, + read_order=read_order, + calibrate=calibrate, + shake_settings=shake_settings, + carriage_speed=carriage_speed, + read_from_bottom=read_from_bottom, + pmt_gain=pmt_gain, + flashes_per_well=flashes_per_well, + kinetic_settings=kinetic_settings, + spectrum_settings=spectrum_settings, + emission_wavelengths=emission_wavelengths, + cuvette=cuvette, + speed_read=False, + settling_time=settling_time, + ) + await self._set_clear() + await self._set_read_stage(settings) + + if not cuvette: + await self._set_plate_position(settings) + await self._set_strip(settings) + await self._set_carriage_speed(settings) + + await self._set_shake(settings) + await self._set_pmt(settings) + await self._set_wavelengths(settings) + await self._set_read_stage(settings) + await self._set_calibrate(settings) + await self._set_mode(settings) + await self._set_order(settings) + await self._set_tag(settings) + await self._set_nvram(settings) + await self._set_readtype(settings) + + await self._read_now() + await self._wait_for_idle(timeout=timeout) + return await self._transfer_data(settings) + + @action(auto_prefix=True, description="对微孔板执行荧光偏振读数。") + async def read_fluorescence_polarization( + self, + plate: Plate, + excitation_wavelengths: List[int], + emission_wavelengths: List[int], + cutoff_filters: List[int], + read_type: ReadType = ReadType.ENDPOINT, + read_order: ReadOrder = ReadOrder.COLUMN, + calibrate: Calibrate = Calibrate.ONCE, + shake_settings: Optional[ShakeSettings] = None, + carriage_speed: CarriageSpeed = CarriageSpeed.NORMAL, + read_from_bottom: bool = False, + pmt_gain: Union[PmtGain, int] = PmtGain.AUTO, + flashes_per_well: int = 10, + kinetic_settings: Optional[KineticSettings] = None, + spectrum_settings: Optional[SpectrumSettings] = None, + cuvette: bool = False, + settling_time: int = 0, + timeout: int = 600, + ) -> List[Dict]: + _unilab_logger.debug("[UNILAB] MolecularDevicesBackend.read_fluorescence_polarization() called") + settings = MolecularDevicesSettings( + plate=plate, + read_mode=ReadMode.POLAR, + read_type=read_type, + read_order=read_order, + calibrate=calibrate, + shake_settings=shake_settings, + carriage_speed=carriage_speed, + read_from_bottom=read_from_bottom, + pmt_gain=pmt_gain, + flashes_per_well=flashes_per_well, + kinetic_settings=kinetic_settings, + spectrum_settings=spectrum_settings, + excitation_wavelengths=excitation_wavelengths, + emission_wavelengths=emission_wavelengths, + cutoff_filters=cutoff_filters, + cuvette=cuvette, + speed_read=False, + settling_time=settling_time, + ) + await self._set_clear() + if not cuvette: + await self._set_plate_position(settings) + await self._set_strip(settings) + await self._set_carriage_speed(settings) + + await self._set_shake(settings) + await self._set_flashes_per_well(settings) + await self._set_pmt(settings) + await self._set_wavelengths(settings) + await self._set_filter(settings) + await self._set_read_stage(settings) + await self._set_calibrate(settings) + await self._set_mode(settings) + await self._set_order(settings) + await self._set_tag(settings) + await self._set_nvram(settings) + await self._set_readtype(settings) + + await self._read_now() + await self._wait_for_idle(timeout=timeout) + return await self._transfer_data(settings) + + @action(auto_prefix=True, description="对微孔板执行时间分辨荧光读数。") + async def read_time_resolved_fluorescence( + self, + plate: Plate, + excitation_wavelengths: List[int], + emission_wavelengths: List[int], + cutoff_filters: List[int], + delay_time: int, + integration_time: int, + read_type: ReadType = ReadType.ENDPOINT, + read_order: ReadOrder = ReadOrder.COLUMN, + calibrate: Calibrate = Calibrate.ONCE, + shake_settings: Optional[ShakeSettings] = None, + carriage_speed: CarriageSpeed = CarriageSpeed.NORMAL, + read_from_bottom: bool = False, + pmt_gain: Union[PmtGain, int] = PmtGain.AUTO, + flashes_per_well: int = 50, + kinetic_settings: Optional[KineticSettings] = None, + spectrum_settings: Optional[SpectrumSettings] = None, + cuvette: bool = False, + settling_time: int = 0, + timeout: int = 600, + ) -> List[Dict]: + _unilab_logger.debug("[UNILAB] MolecularDevicesBackend.read_time_resolved_fluorescence() called") + settings = MolecularDevicesSettings( + plate=plate, + read_mode=ReadMode.TIME, + read_type=read_type, + read_order=read_order, + calibrate=calibrate, + shake_settings=shake_settings, + carriage_speed=carriage_speed, + read_from_bottom=read_from_bottom, + pmt_gain=pmt_gain, + flashes_per_well=flashes_per_well, + kinetic_settings=kinetic_settings, + spectrum_settings=spectrum_settings, + excitation_wavelengths=excitation_wavelengths, + emission_wavelengths=emission_wavelengths, + cutoff_filters=cutoff_filters, + cuvette=cuvette, + speed_read=False, + settling_time=settling_time, + ) + await self._set_clear() + await self._set_readtype(settings) + await self._set_integration_time(settings, delay_time, integration_time) + + if not cuvette: + await self._set_plate_position(settings) + await self._set_strip(settings) + await self._set_carriage_speed(settings) + + await self._set_shake(settings) + await self._set_flashes_per_well(settings) + await self._set_pmt(settings) + await self._set_wavelengths(settings) + await self._set_filter(settings) + await self._set_calibrate(settings) + await self._set_read_stage(settings) + await self._set_mode(settings) + await self._set_order(settings) + await self._set_tag(settings) + await self._set_nvram(settings) + + await self._read_now() + await self._wait_for_idle(timeout=timeout) + return await self._transfer_data(settings) diff --git a/unilabos/devices/_phage_display/peeler.py b/unilabos/devices/_phage_display/peeler.py new file mode 100644 index 000000000..2f30ea104 --- /dev/null +++ b/unilabos/devices/_phage_display/peeler.py @@ -0,0 +1,126 @@ +""" +Auto-generated by Build Agent +Original: pylabrobot/peeling/peeler.py + +[UNILAB DEBUG] 此驱动已注入调试日志 +- 每个方法被调用时会打印日志 +- 日志格式: [UNILAB] ClassName.method_name() called +- 查看日志: logging.getLogger("unilab.driver").setLevel(logging.DEBUG) +""" +from unilabos.registry.decorators import action, device +from typing import Dict, Any, Optional +import logging +import warnings as _warnings +_warnings.filterwarnings("ignore", message=r"pkg_resources is deprecated as an API\..*") +import sys as _sys +import types as _types + +# 兼容性:部分上游框架(例如 lantz)在 import-time 强制加载 Qt(PyQt4/PySide1), +# 在 headless/现代 Python 环境中通常不存在这些绑定,会导致导入直接失败。 +# 这里预注入一个最小的 `lantz.utils.qt` shim,避免触发其 Qt loader。 +if "lantz.utils.qt" not in _sys.modules: + _qt_mod = _types.ModuleType("lantz.utils.qt") + + class _DummyQtCore: + class QObject: + def __init__(self, *_args, **_kwargs): + # 兼容 QObject(parent=...) 等调用 + return None + + @staticmethod + def Signal(*_args, **_kwargs): + return object() + + @staticmethod + def Slot(*_args, **_kwargs): + def _decorator(fn): + return fn + + return _decorator + + _qt_mod.QtCore = _DummyQtCore + + class _DummySuperQObject(_DummyQtCore.QObject): + def __new__(cls, *_args, **_kwargs): + return super().__new__(cls) + + def __init__(self, *_args, **_kwargs): + # 允许 Driver(...) 传参,不触发 object.__init__ 的 TypeError + return None + + _qt_mod.MetaQObject = type + _qt_mod.SuperQObject = _DummySuperQObject + _sys.modules["lantz.utils.qt"] = _qt_mod + +# Unilab 驱动调试日志 +_unilab_logger = logging.getLogger("unilab.driver.Peeler") +_unilab_logger.setLevel(logging.DEBUG) +if not _unilab_logger.handlers: + _handler = logging.StreamHandler() + _handler.setFormatter(logging.Formatter("[%(asctime)s] %(message)s")) + _unilab_logger.addHandler(_handler) + +# Vendored repo packages: pylabrobot, pylabrobot +import sys as _sys +from pathlib import Path as _Path +_vendor_root = _Path(__file__).resolve().parent / "_vendor" +if _vendor_root.exists() and str(_vendor_root) not in _sys.path: + _sys.path.insert(0, str(_vendor_root)) + +from pylabrobot.machines import Machine + +from pylabrobot.peeling.backend import PeelerBackend + + +class MockPeelerBackend(PeelerBackend): + """揭膜机模拟后端,用于无真实硬件时的测试。""" + + def __init__(self): + super().__init__() + self._peel_count: int = 0 + + async def setup(self): + _unilab_logger.debug("[UNILAB] MockPeelerBackend.setup() called") + + async def stop(self): + _unilab_logger.debug("[UNILAB] MockPeelerBackend.stop() called") + + async def peel(self): + _unilab_logger.debug("[UNILAB] MockPeelerBackend.peel() called") + self._peel_count += 1 + + async def restart(self): + _unilab_logger.debug("[UNILAB] MockPeelerBackend.restart() called") + self._peel_count = 0 + + +@device( + id="peeler", + category=["Plate Desealer"], + description="用于自动剥离微孔板顶部封膜或粘性封板膜的实验室设备,常用于样品处理和自动化工作流中,在后续移液、读板或分析前打开微孔板。", + model={ + "type": "device", + "mesh": "peeler", + "path": "https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/devices/peeler/macro_device.xacro", + }, +) +class Peeler(Machine): + """A microplate peeler""" + + def __init__(self, backend: PeelerBackend): + print("[UNILAB] Peeler.__init__() called", flush=True) + super().__init__(backend=backend) + if isinstance(backend, PeelerBackend): + self.backend: PeelerBackend = backend + else: + self.backend = MockPeelerBackend() + + @action(auto_prefix=True, description="自动剥离微孔板封膜。") + async def peel(self, **backend_kwargs): + _unilab_logger.debug("[UNILAB] Peeler.peel() called") + return await self.backend.peel(**backend_kwargs) + + @action(auto_prefix=True, description="重启揭膜机。") + async def restart(self, **backend_kwargs): + _unilab_logger.debug("[UNILAB] Peeler.restart() called") + return await self.backend.restart(**backend_kwargs) diff --git a/unilabos/devices/_phage_display/plate_reader.py b/unilabos/devices/_phage_display/plate_reader.py new file mode 100644 index 000000000..f085f1e12 --- /dev/null +++ b/unilabos/devices/_phage_display/plate_reader.py @@ -0,0 +1,298 @@ +""" +Auto-generated by Build Agent +Original: pylabrobot/plate_reading/plate_reader.py + +[UNILAB DEBUG] 此驱动已注入调试日志 +- 每个方法被调用时会打印日志 +- 日志格式: [UNILAB] ClassName.method_name() called +- 查看日志: logging.getLogger("unilab.driver").setLevel(logging.DEBUG) +""" +from unilabos.registry.decorators import action, device +from typing import Dict, Any, Optional +import logging +import warnings as _warnings +_warnings.filterwarnings("ignore", message=r"pkg_resources is deprecated as an API\..*") +import sys as _sys +import types as _types + +# 兼容性:部分上游框架(例如 lantz)在 import-time 强制加载 Qt(PyQt4/PySide1), +# 在 headless/现代 Python 环境中通常不存在这些绑定,会导致导入直接失败。 +# 这里预注入一个最小的 `lantz.utils.qt` shim,避免触发其 Qt loader。 +if "lantz.utils.qt" not in _sys.modules: + _qt_mod = _types.ModuleType("lantz.utils.qt") + + class _DummyQtCore: + class QObject: + def __init__(self, *_args, **_kwargs): + # 兼容 QObject(parent=...) 等调用 + return None + + @staticmethod + def Signal(*_args, **_kwargs): + return object() + + @staticmethod + def Slot(*_args, **_kwargs): + def _decorator(fn): + return fn + + return _decorator + + _qt_mod.QtCore = _DummyQtCore + + class _DummySuperQObject(_DummyQtCore.QObject): + def __new__(cls, *_args, **_kwargs): + return super().__new__(cls) + + def __init__(self, *_args, **_kwargs): + # 允许 Driver(...) 传参,不触发 object.__init__ 的 TypeError + return None + + _qt_mod.MetaQObject = type + _qt_mod.SuperQObject = _DummySuperQObject + _sys.modules["lantz.utils.qt"] = _qt_mod + +# Unilab 驱动调试日志 +_unilab_logger = logging.getLogger("unilab.driver.PlateReader") +_unilab_logger.setLevel(logging.DEBUG) +if not _unilab_logger.handlers: + _handler = logging.StreamHandler() + _handler.setFormatter(logging.Formatter("[%(asctime)s] %(message)s")) + _unilab_logger.addHandler(_handler) + +# Vendored repo packages: pylabrobot, pylabrobot +import sys as _sys +from pathlib import Path as _Path +_vendor_root = _Path(__file__).resolve().parent / "_vendor" +if _vendor_root.exists() and str(_vendor_root) not in _sys.path: + _sys.path.insert(0, str(_vendor_root)) + +import logging +from typing import Dict, List, Optional, cast + +from pylabrobot.machines.machine import Machine, need_setup_finished +from pylabrobot.plate_reading.backend import PlateReaderBackend +from pylabrobot.plate_reading.standard import NoPlateError +from pylabrobot.resources import Coordinate, Plate, Resource, ResourceHolder, Well + +logger = logging.getLogger(__name__) + + +@device( + id="plate_reader", + category=["Microplate Reader"], + description="用于读取微孔板中各孔的吸光度、荧光和发光信号的实验仪器,常用于生化分析、细胞实验和板式检测流程。", + model={ + "type": "device", + "mesh": "plate_reader", + "path": "https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/devices/plate_reader/macro_device.xacro", + }, +) +class PlateReader(ResourceHolder, Machine): + """The front end for plate readers. Plate readers are devices that can read luminescence, + absorbance, or fluorescence from a plate. + + Plate readers are asynchronous, meaning that their methods will return immediately and + will not block. + + Here's an example of how to use this class in a Jupyter Notebook: + + >>> from pylabrobot.plate_reading.clario_star import CLARIOStarBackend + >>> pr = PlateReader(backend=CLARIOStarBackend()) + >>> pr.setup() + >>> await pr.read_luminescence() + [[value1, value2, value3, ...], [value1, value2, value3, ...], ... + """ + + def __init__( + self, + name: str, + size_x: float, + size_y: float, + size_z: float, + backend: PlateReaderBackend, + category: Optional[str] = "plate_reader", + model: Optional[str] = None, + child_location: Coordinate = Coordinate.zero(), + preferred_pickup_location: Optional[Coordinate] = None, + ) -> None: + print("[UNILAB] PlateReader.__init__() called", flush=True) + ResourceHolder.__init__( + self, + name=name, + size_x=size_x, + size_y=size_y, + size_z=size_z, + category=category, + model=model, + child_location=child_location, + preferred_pickup_location=preferred_pickup_location, + ) + Machine.__init__(self, backend=backend) + self.backend: PlateReaderBackend = backend # fix type + + @action(auto_prefix=True, description="将微孔板资源分配并装载到酶标仪中。") + def assign_child_resource( + self, + resource: Resource, + location: Optional[Coordinate] = None, + reassign: bool = True, + ): + _unilab_logger.debug("[UNILAB] PlateReader.assign_child_resource() called") + if len([c for c in self.children if isinstance(c, Plate)]) >= 1: + raise ValueError("There already is a plate in the plate reader.") + + super().assign_child_resource(resource, location=location, reassign=reassign) + + @action(auto_prefix=True, description="获取当前装载的微孔板。") + def get_plate(self) -> Plate: + _unilab_logger.debug("[UNILAB] PlateReader.get_plate() called") + plate_children = [c for c in self.children if isinstance(c, Plate)] + if len(plate_children) == 0: + raise NoPlateError("There is no plate in the plate reader.") + return cast(Plate, plate_children[0]) + + @need_setup_finished + @action(auto_prefix=True, description="打开酶标仪的舱门或托盘,以便放入或取出微孔板。") + async def open(self, **backend_kwargs) -> None: + _unilab_logger.debug("[UNILAB] PlateReader.open() called") + await self.backend.open(**backend_kwargs) + + @need_setup_finished + @action(auto_prefix=True, description="关闭酶标仪的舱门或托盘,以准备检测。") + async def close(self, **backend_kwargs) -> None: + _unilab_logger.debug("[UNILAB] PlateReader.close() called") + plate = self.get_plate() if len(self.children) > 0 else None + await self.backend.close(plate=plate, **backend_kwargs) + + @need_setup_finished + @action(auto_prefix=True, description="在指定焦高条件下读取微孔板各孔的发光信号。") + async def read_luminescence( + self, + focal_height: float, + wells: Optional[List[Well]] = None, + use_new_return_type: bool = False, + **backend_kwargs, + ) -> List[Dict]: + _unilab_logger.debug("[UNILAB] PlateReader.read_luminescence() called") + """Read the luminescence from the plate reader. + + Args: + focal_height: The focal height to read the luminescence at, in micrometers. + use_new_return_type: Whether to return the new return type, which is a list of dictionaries. + + Returns: + A list of dictionaries, one for each measurement. Each dictionary contains: + "time": float, + "temperature": float, + "data": List[List[float]] + """ + + result = await self.backend.read_luminescence( + plate=self.get_plate(), + wells=wells or self.get_plate().get_all_items(), + focal_height=focal_height, + **backend_kwargs, + ) + + if not use_new_return_type: + logger.warning( + "The return type of read_luminescence will change in a future version. Please set " + "use_new_return_type=True to use the new return type." + ) + return result[0]["data"] # type: ignore[no-any-return] + return result + + @need_setup_finished + @action(auto_prefix=True, description="在指定波长下读取微孔板各孔的吸光度。") + async def read_absorbance( + self, + wavelength: int, + wells: Optional[List[Well]] = None, + use_new_return_type: bool = False, + **backend_kwargs, + ) -> List[Dict]: + _unilab_logger.debug("[UNILAB] PlateReader.read_absorbance() called") + """Read the absorbance from the plate reader. + + Args: + wavelength: The wavelength to read the absorbance at, in nanometers. + use_new_return_type: Whether to return the new return type, which is a list of dictionaries. + + Returns: + A list of dictionaries, one for each measurement. Each dictionary contains: + "wavelength": int, + "time": float, + "temperature": float, + "data": List[List[float]] + """ + + result = await self.backend.read_absorbance( + plate=self.get_plate(), + wells=wells or self.get_plate().get_all_items(), + wavelength=wavelength, + **backend_kwargs, + ) + + if not use_new_return_type: + logger.warning( + "The return type of read_absorbance will change in a future version. Please set " + "use_new_return_type=True to use the new return type." + ) + return result[0]["data"] # type: ignore[no-any-return] + return result + + @need_setup_finished + @action(auto_prefix=True, description="在指定激发波长、发射波长和焦高条件下读取微孔板各孔的荧光信号。") + async def read_fluorescence( + self, + excitation_wavelength: int, + emission_wavelength: int, + focal_height: float, + wells: Optional[List[Well]] = None, + use_new_return_type: bool = False, + **backend_kwargs, + ) -> List[Dict]: + _unilab_logger.debug("[UNILAB] PlateReader.read_fluorescence() called") + """Read the fluorescence from the plate reader. + + Args: + excitation_wavelength: The excitation wavelength to read the fluorescence at, in nanometers. + emission_wavelength: The emission wavelength to read the fluorescence at, in nanometers. + focal_height: The focal height to read the fluorescence at, in micrometers. + use_new_return_type: Whether to return the new return type, which is a list of dictionaries. + + Returns: + A list of dictionaries, one for each measurement. Each dictionary contains: + "ex_wavelength": int, + "em_wavelength": int, + "time": float, + "temperature": float, + "data": List[List[float]] + """ + + if excitation_wavelength > emission_wavelength: + logger.warning( + "Excitation wavelength is greater than emission wavelength. This is unusual and may indicate an error." + ) + + result = await self.backend.read_fluorescence( + plate=self.get_plate(), + wells=wells or self.get_plate().get_all_items(), + excitation_wavelength=excitation_wavelength, + emission_wavelength=emission_wavelength, + focal_height=focal_height, + **backend_kwargs, + ) + if not use_new_return_type: + logger.warning( + "The return type of read_fluorescence will change in a future version. Please set " + "use_new_return_type=True to use the new return type." + ) + return result[0]["data"] # type: ignore[no-any-return] + return result + + @action(auto_prefix=True, description="序列化酶标仪的当前状态或资源信息。") + def serialize(self) -> dict: + _unilab_logger.debug("[UNILAB] PlateReader.serialize() called") + return {**Resource.serialize(self), **Machine.serialize(self)} diff --git a/unilabos/devices/_phage_display/sealer.py b/unilabos/devices/_phage_display/sealer.py new file mode 100644 index 000000000..7b9e63144 --- /dev/null +++ b/unilabos/devices/_phage_display/sealer.py @@ -0,0 +1,158 @@ +""" +Auto-generated by Build Agent +Original: pylabrobot/sealing/sealer.py + +[UNILAB DEBUG] 此驱动已注入调试日志 +- 每个方法被调用时会打印日志 +- 日志格式: [UNILAB] ClassName.method_name() called +- 查看日志: logging.getLogger("unilab.driver").setLevel(logging.DEBUG) +""" +from unilabos.registry.decorators import action, device +from typing import Dict, Any, Optional +import logging +import warnings as _warnings +_warnings.filterwarnings("ignore", message=r"pkg_resources is deprecated as an API\..*") +import sys as _sys +import types as _types + +# 兼容性:部分上游框架(例如 lantz)在 import-time 强制加载 Qt(PyQt4/PySide1), +# 在 headless/现代 Python 环境中通常不存在这些绑定,会导致导入直接失败。 +# 这里预注入一个最小的 `lantz.utils.qt` shim,避免触发其 Qt loader。 +if "lantz.utils.qt" not in _sys.modules: + _qt_mod = _types.ModuleType("lantz.utils.qt") + + class _DummyQtCore: + class QObject: + def __init__(self, *_args, **_kwargs): + # 兼容 QObject(parent=...) 等调用 + return None + + @staticmethod + def Signal(*_args, **_kwargs): + return object() + + @staticmethod + def Slot(*_args, **_kwargs): + def _decorator(fn): + return fn + + return _decorator + + _qt_mod.QtCore = _DummyQtCore + + class _DummySuperQObject(_DummyQtCore.QObject): + def __new__(cls, *_args, **_kwargs): + return super().__new__(cls) + + def __init__(self, *_args, **_kwargs): + # 允许 Driver(...) 传参,不触发 object.__init__ 的 TypeError + return None + + _qt_mod.MetaQObject = type + _qt_mod.SuperQObject = _DummySuperQObject + _sys.modules["lantz.utils.qt"] = _qt_mod + +# Unilab 驱动调试日志 +_unilab_logger = logging.getLogger("unilab.driver.Sealer") +_unilab_logger.setLevel(logging.DEBUG) +if not _unilab_logger.handlers: + _handler = logging.StreamHandler() + _handler.setFormatter(logging.Formatter("[%(asctime)s] %(message)s")) + _unilab_logger.addHandler(_handler) + +# Vendored repo packages: pylabrobot, pylabrobot +import sys as _sys +from pathlib import Path as _Path +_vendor_root = _Path(__file__).resolve().parent / "_vendor" +if _vendor_root.exists() and str(_vendor_root) not in _sys.path: + _sys.path.insert(0, str(_vendor_root)) + +from pylabrobot.machines import Machine + +from pylabrobot.sealing.backend import SealerBackend + +class MockSealerBackend(SealerBackend): + """封膜机模拟后端,用于无真实硬件时的测试。""" + + def __init__(self): + super().__init__() + self._temperature: float = 25.0 + self._is_open: bool = True + + async def setup(self): + _unilab_logger.debug("[UNILAB] MockSealerBackend.setup() called") + + async def stop(self): + _unilab_logger.debug("[UNILAB] MockSealerBackend.stop() called") + + async def seal(self, temperature: int, duration: float): + _unilab_logger.debug( + f"[UNILAB] MockSealerBackend.seal(temperature={temperature}, duration={duration}) called" + ) + self._temperature = float(temperature) + + async def open(self): + _unilab_logger.debug("[UNILAB] MockSealerBackend.open() called") + self._is_open = True + + async def close(self): + _unilab_logger.debug("[UNILAB] MockSealerBackend.close() called") + self._is_open = False + + async def set_temperature(self, temperature: float): + _unilab_logger.debug( + f"[UNILAB] MockSealerBackend.set_temperature(temperature={temperature}) called" + ) + self._temperature = float(temperature) + + async def get_temperature(self) -> float: + _unilab_logger.debug("[UNILAB] MockSealerBackend.get_temperature() called") + return self._temperature + +# @device( +# id="sealer", +# category=["Plate Sealer"], +# description="用于实验室自动化中对微孔板进行封膜的设备,可执行封膜、开合机构控制,以及温度设置与读取。", +# model={ +# "type": "device", +# "mesh": "sealer", +# "path": "https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/devices/sealer/macro_device.xacro", +# }, +# ) +class Sealer(Machine): + """A microplate sealer""" + + def __init__(self, backend: SealerBackend): + print("[UNILAB] Sealer.__init__() called", flush=True) + super().__init__(backend=backend) + if isinstance(backend, SealerBackend): + self.backend: SealerBackend = backend # fix type + else: + self.backend = MockSealerBackend() + + # @action(auto_prefix=True, description="按设定温度和持续时间执行微孔板封膜。") + async def seal(self, temperature: int, duration: float): + _unilab_logger.debug("[UNILAB] Sealer.seal() called") + return await self.backend.seal(temperature=temperature, duration=duration) + + # @action(auto_prefix=True, description="打开封膜机机构。") + async def open(self): + _unilab_logger.debug("[UNILAB] Sealer.open() called") + return await self.backend.open() + + # @action(auto_prefix=True, description="关闭封膜机机构。") + async def close(self): + _unilab_logger.debug("[UNILAB] Sealer.close() called") + return await self.backend.close() + + # @action(auto_prefix=True, description="设置封膜机温度。") + async def set_temperature(self, temperature: float): + _unilab_logger.debug("[UNILAB] Sealer.set_temperature() called") + """Set the temperature of the sealer in degrees Celsius.""" + return await self.backend.set_temperature(temperature=temperature) + + # @action(auto_prefix=True, description="获取封膜机温度。") + async def get_temperature(self) -> float: + _unilab_logger.debug("[UNILAB] Sealer.get_temperature() called") + """Get the current temperature of the sealer in degrees Celsius.""" + return await self.backend.get_temperature() diff --git a/unilabos/devices/_phage_display/star_backend.py b/unilabos/devices/_phage_display/star_backend.py new file mode 100644 index 000000000..81b234e8a --- /dev/null +++ b/unilabos/devices/_phage_display/star_backend.py @@ -0,0 +1,12270 @@ +""" +Auto-generated by Build Agent +Original: pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py + +[UNILAB DEBUG] 此驱动已注入调试日志 +- 每个方法被调用时会打印日志 +- 日志格式: [UNILAB] ClassName.method_name() called +- 查看日志: logging.getLogger("unilab.driver").setLevel(logging.DEBUG) +""" +from unilabos.registry.decorators import action, device +from typing import Dict, Any, Optional +import logging +import warnings as _warnings +_warnings.filterwarnings("ignore", message=r"pkg_resources is deprecated as an API\..*") +import sys as _sys +import types as _types + +# 兼容性:部分上游框架(例如 lantz)在 import-time 强制加载 Qt(PyQt4/PySide1), +# 在 headless/现代 Python 环境中通常不存在这些绑定,会导致导入直接失败。 +# 这里预注入一个最小的 `lantz.utils.qt` shim,避免触发其 Qt loader。 +if "lantz.utils.qt" not in _sys.modules: + _qt_mod = _types.ModuleType("lantz.utils.qt") + + class _DummyQtCore: + class QObject: + def __init__(self, *_args, **_kwargs): + # 兼容 QObject(parent=...) 等调用 + return None + + @staticmethod + def Signal(*_args, **_kwargs): + return object() + + @staticmethod + def Slot(*_args, **_kwargs): + def _decorator(fn): + return fn + + return _decorator + + _qt_mod.QtCore = _DummyQtCore + + class _DummySuperQObject(_DummyQtCore.QObject): + def __new__(cls, *_args, **_kwargs): + return super().__new__(cls) + + def __init__(self, *_args, **_kwargs): + # 允许 Driver(...) 传参,不触发 object.__init__ 的 TypeError + return None + + _qt_mod.MetaQObject = type + _qt_mod.SuperQObject = _DummySuperQObject + _sys.modules["lantz.utils.qt"] = _qt_mod + +# Unilab 驱动调试日志 +_unilab_logger = logging.getLogger("unilab.driver.STARBackend") +_unilab_logger.setLevel(logging.DEBUG) +if not _unilab_logger.handlers: + _handler = logging.StreamHandler() + _handler.setFormatter(logging.Formatter("[%(asctime)s] %(message)s")) + _unilab_logger.addHandler(_handler) + +# Vendored repo packages: pylabrobot, pylabrobot +import sys as _sys +from pathlib import Path as _Path +_vendor_root = _Path(__file__).resolve().parent / "_vendor" +if _vendor_root.exists() and str(_vendor_root) not in _sys.path: + _sys.path.insert(0, str(_vendor_root)) + +import asyncio +import datetime +import enum +import functools +import logging +import re +import sys +import warnings +from abc import ABCMeta +from contextlib import asynccontextmanager, contextmanager +from dataclasses import dataclass +from typing import ( + Any, + Callable, + Coroutine, + Dict, + List, + Literal, + Optional, + Sequence, + Tuple, + Type, + TypeVar, + Union, + cast, +) + +if sys.version_info < (3, 10): + from typing_extensions import Concatenate, ParamSpec +else: + from typing import Concatenate, ParamSpec + +from pylabrobot import audio +from pylabrobot.heating_shaking.hamilton_backend import HamiltonHeaterShakerInterface +from pylabrobot.liquid_handling.backends.hamilton.base import ( + HamiltonLiquidHandler, +) +from pylabrobot.liquid_handling.backends.hamilton.common import fill_in_defaults +from pylabrobot.liquid_handling.errors import ChannelizedError +from pylabrobot.liquid_handling.liquid_classes.hamilton import ( + HamiltonLiquidClass, + get_star_liquid_class, +) +from pylabrobot.liquid_handling.standard import ( + Drop, + DropTipRack, + GripDirection, + MultiHeadAspirationContainer, + MultiHeadAspirationPlate, + MultiHeadDispenseContainer, + MultiHeadDispensePlate, + Pickup, + PickupTipRack, + PipettingOp, + ResourceDrop, + ResourceMove, + ResourcePickup, + SingleChannelAspiration, + SingleChannelDispense, +) +from pylabrobot.liquid_handling.utils import ( + get_tight_single_resource_liquid_op_offsets, + get_wide_single_resource_liquid_op_offsets, +) +from pylabrobot.resources import ( + Carrier, + Container, + Coordinate, + Plate, + Resource, + Tip, + TipRack, + TipSpot, + Well, +) +from pylabrobot.resources.barcode import Barcode, Barcode1DSymbology +from pylabrobot.resources.errors import ( + HasTipError, + NoTipError, + TooLittleLiquidError, + TooLittleVolumeError, +) +from pylabrobot.resources.hamilton import ( + HamiltonTip, + TipDropMethod, + TipPickupMethod, + TipSize, +) +from pylabrobot.resources.hamilton.hamilton_decks import ( + HamiltonCoreGrippers, + rails_for_x_coordinate, +) +from pylabrobot.resources.liquid import Liquid +from pylabrobot.resources.rotation import Rotation +from pylabrobot.resources.trash import Trash + +T = TypeVar("T") + + +logger = logging.getLogger("pylabrobot") + +_P = ParamSpec("_P") +_R = TypeVar("_R") + + +def need_iswap_parked( + method: Callable[Concatenate["STARBackend", _P], Coroutine[Any, Any, _R]], +) -> Callable[Concatenate["STARBackend", _P], Coroutine[Any, Any, _R]]: + """Ensure that the iSWAP is in parked position before running command. + + If the iSWAP is not parked, it get's parked before running the command. + """ + + @functools.wraps(method) + async def wrapper(self: "STARBackend", *args, **kwargs): + if self.iswap_installed and not self.iswap_parked: + await self.park_iswap( + minimum_traverse_height_at_beginning_of_a_command=int(self._iswap_traversal_height * 10) + ) + + return await method(self, *args, **kwargs) + + return wrapper + + +def _requires_head96( + method: Callable[Concatenate["STARBackend", _P], Coroutine[Any, Any, _R]], +) -> Callable[Concatenate["STARBackend", _P], Coroutine[Any, Any, _R]]: + """Ensure that a 96-head is installed before running the command.""" + + @functools.wraps(method) + async def wrapper(self: "STARBackend", *args, **kwargs): + if not self.core96_head_installed: + raise RuntimeError( + "This command requires a 96-head, but none is installed. " + "Check your instrument configuration." + ) + return await method(self, *args, **kwargs) + + return wrapper + + +def parse_star_fw_string(resp: str, fmt: str = "") -> dict: + """Parse a machine command or response string according to a format string. + + The format contains names of parameters (always length 2), + followed by an arbitrary number of the following, but always + the same: + - '&': char + - '#': decimal + - '*': hex + + The order of parameters in the format and response string do not + have to (and often do not) match. + + The identifier parameter (id####) is added automatically. + + TODO: string parsing + The firmware docs mention strings in the following format: '...' + However, the length of these is always known (except when reading + barcodes), so it is easier to convert strings to the right number + of '&'. With barcode reading the length of the barcode is included + with the response string. We'll probably do a custom implementation + for that. + + TODO: spaces + We should also parse responses where integers are separated by spaces, + like this: `ua#### #### ###### ###### ###### ######` + + Args: + resp: The response string to parse. + fmt: The format string. + + Raises: + ValueError: if the format string is incompatible with the response. + + Returns: + A dictionary containing the parsed values. + + Examples: + Parsing a string containing decimals (`1111`), hex (`0xB0B`) and chars (`'rw'`): + + ``` + >>> parse_fw_string("aa1111bbrwccB0B", "aa####bb&&cc***") + {'aa': 1111, 'bb': 'rw', 'cc': 2827} + ``` + """ + + # Remove device and cmd identifier from response. + resp = resp[4:] + + # Parse the parameters in the fmt string. + info = {} + + def find_param(param): + name, data = param[0:2], param[2:] + type_ = {"#": "int", "*": "hex", "&": "str"}[data[0]] + + # Build a regex to match this parameter. + exp = { + "int": r"[-+]?[\d ]", + "hex": r"[\da-fA-F ]", + "str": ".", + }[type_] + len_ = len(data.split(" ")[0]) # Get length of first block. + regex = f"{name}((?:{exp}{ {len_} }" + + if param.endswith(" (n)"): + regex += " ?)+)" + is_list = True + else: + regex += "))" + is_list = False + + # Match response against regex, save results in right datatype. + r = re.search(regex, resp) + if r is None: + raise ValueError(f"could not find matches for parameter {name}") + + g = r.groups() + if len(g) == 0: + raise ValueError(f"could not find value for parameter {name}") + m = g[0] + + if is_list: + m = m.split(" ") + + if type_ == "str": + info[name] = m + elif type_ == "int": + info[name] = [int(m_) for m_ in m if m_ != ""] + elif type_ == "hex": + info[name] = [int(m_, base=16) for m_ in m if m_ != ""] + else: + if type_ == "str": + info[name] = m + elif type_ == "int": + info[name] = int(m) + elif type_ == "hex": + info[name] = int(m, base=16) + + # Find params in string. All params are identified by 2 lowercase chars. + param = "" + prevchar = None + for char in fmt: + if char.islower() and prevchar != "(": + if len(param) > 2: + find_param(param) + param = "" + param += char + prevchar = char + if param != "": + find_param(param) # last parameter is not closed by loop. + + # If id not in fmt, add it. + if "id" not in info: + find_param("id####") + + return info + + +class STARModuleError(Exception, metaclass=ABCMeta): + """Base class for all Hamilton backend errors, raised by a single module.""" + + def __init__( + self, + message: str, + trace_information: int, + raw_response: str, + raw_module: str, + ): + self.message = message + self.trace_information = trace_information + self.raw_response = raw_response + self.raw_module = raw_module + + def __repr__(self) -> str: + return f"{self.__class__.__name__}('{self.message}')" + + +class CommandSyntaxError(STARModuleError): + """Command syntax error + + Code: 01 + """ + + +class HardwareError(STARModuleError): + """Hardware error + + Possible cause(s): + drive blocked, low power etc. + + Code: 02 + """ + + +class CommandNotCompletedError(STARModuleError): + """Command not completed + + Possible cause(s): + error in previous sequence (not executed) + + Code: 03 + """ + + +class ClotDetectedError(STARModuleError): + """Clot detected + + Possible cause(s): + LLD not interrupted + + Code: 04 + """ + + +class BarcodeUnreadableError(STARModuleError): + """Barcode unreadable + + Possible cause(s): + bad or missing barcode + + Code: 05 + """ + + +class TipTooLittleVolumeError(STARModuleError): + """Too little liquid + + Possible cause(s): + 1. liquid surface is not detected, + 2. Aspirate / Dispense conditions could not be fulfilled. + + Code: 06 + """ + + +class TipAlreadyFittedError(STARModuleError): + """Tip already fitted + + Possible cause(s): + Repeated attempts to fit a tip or iSwap movement with tips + + Code: 07 + """ + + +class HamiltonNoTipError(STARModuleError): + """No tips + + Possible cause(s): + command was started without fitting tip (tip was not fitted or fell off again) + + Code: 08 + """ + + +class NoCarrierError(STARModuleError): + """No carrier + + Possible cause(s): + load command without carrier + + Code: 09 + """ + + +class NotCompletedError(STARModuleError): + """Not completed + + Possible cause(s): + Command in command buffer was aborted due to an error in a previous command, or command stack + was deleted. + + Code: 10 + """ + + +class DispenseWithPressureLLDError(STARModuleError): + """Dispense with pressure LLD + + Possible cause(s): + dispense with pressure LLD is not permitted + + Code: 11 + """ + + +class NoTeachInSignalError(STARModuleError): + """No Teach In Signal + + Possible cause(s): + X-Movement to LLD reached maximum allowable position with- out detecting Teach in signal + + Code: 12 + """ + + +class LoadingTrayError(STARModuleError): + """Loading Tray error + + Possible cause(s): + position already occupied + + Code: 13 + """ + + +class SequencedAspirationWithPressureLLDError(STARModuleError): + """Sequenced aspiration with pressure LLD + + Possible cause(s): + sequenced aspiration with pressure LLD is not permitted + + Code: 14 + """ + + +class NotAllowedParameterCombinationError(STARModuleError): + """Not allowed parameter combination + + Possible cause(s): + i.e. PLLD and dispense or wrong X-drive assignment + + Code: 15 + """ + + +class CoverCloseError(STARModuleError): + """Cover close error + + Possible cause(s): + cover is not closed and couldn't be locked + + Code: 16 + """ + + +class AspirationError(STARModuleError): + """Aspiration error + + Possible cause(s): + aspiration liquid stream error detected + + Code: 17 + """ + + +class WashFluidOrWasteError(STARModuleError): + """Wash fluid or trash error + + Possible cause(s): + 1. missing wash fluid + 2. trash of particular washer is full + + Code: 18 + """ + + +class IncubationError(STARModuleError): + """Incubation error + + Possible cause(s): + incubator temperature out of limit + + Code: 19 + """ + + +class TADMMeasurementError(STARModuleError): + """TADM measurement error + + Possible cause(s): + overshoot of limits during aspiration or dispensation + + Code: 20, 26 + """ + + +class NoElementError(STARModuleError): + """No element + + Possible cause(s): + expected element not detected + + Code: 21 + """ + + +class ElementStillHoldingError(STARModuleError): + """Element still holding + + Possible cause(s): + "Get command" is sent twice or element is not dropped expected element is missing (lost) + + Code: 22 + """ + + +class ElementLostError(STARModuleError): + """Element lost + + Possible cause(s): + expected element is missing (lost) + + Code: 23 + """ + + +class IllegalTargetPlatePositionError(STARModuleError): + """Illegal target plate position + + Possible cause(s): + 1. over or underflow of iSWAP positions + 2. iSWAP is not in park position during pipetting activities + + Code: 24 + """ + + +class IllegalUserAccessError(STARModuleError): + """Illegal user access + + Possible cause(s): + carrier was manually removed or cover is open (immediate stop is executed) + + Code: 25 + """ + + +class PositionNotReachableError(STARModuleError): + """Position not reachable + + Possible cause(s): + position out of mechanical limits using iSWAP, CoRe gripper or PIP-channels + + Code: 27 + """ + + +class UnexpectedLLDError(STARModuleError): + """unexpected LLD + + Possible cause(s): + liquid level is reached before LLD scanning is started (using PIP or XL channels) + + Code: 28 + """ + + +class AreaAlreadyOccupiedError(STARModuleError): + """area already occupied + + Possible cause(s): + Its impossible to occupy area because this area is already in use + + Code: 29 + """ + + +class ImpossibleToOccupyAreaError(STARModuleError): + """impossible to occupy area + + Possible cause(s): + Area cant be occupied because is no solution for arm prepositioning + + Code: 30 + """ + + +class AntiDropControlError(STARModuleError): + """ + Anti drop controlling out of tolerance. (VENUS only) + + Code: 31 + """ + + +class DecapperError(STARModuleError): + """ + Decapper lock error while screw / unscrew a cap by twister channels. (VENUS only) + + Code: 32 + """ + + +class DecapperHandlingError(STARModuleError): + """ + Decapper station error while lock / unlock a cap. (VENUS only) + + Code: 33 + """ + + +class StopError(STARModuleError): + """ + Hood is open (Not from documentation, but observed) + + Code: 36 + """ + + +class SlaveError(STARModuleError): + """Slave error + + Possible cause(s): + This error code indicates an error in one of slaves. (for error handling purpose using service + software macro code) + + Code: 99 + """ + + +class WrongCarrierError(STARModuleError): + """ + Wrong carrier barcode detected. (VENUS only) + + Code: 100 + """ + + +class NoCarrierBarcodeError(STARModuleError): + """ + Carrier barcode could not be read or is missing. (VENUS only) + + Code: 101 + """ + + +class LiquidLevelError(STARModuleError): + """ + Liquid surface not detected. (VENUS only) + + This error is created from main / slave error 06/70, 06/73 and 06/87. + + Code: 102 + """ + + +class NotDetectedError(STARModuleError): + """ + Carrier not detected at deck end position. (VENUS only) + + Code: 103 + """ + + +class NotAspiratedError(STARModuleError): + """ + Dispense volume exceeds the aspirated volume. (VENUS only) + + This error is created from main / slave error 02/54. + + Code: 104 + """ + + +class ImproperDispensationError(STARModuleError): + """ + The dispensed volume is out of tolerance (may only occur for Nano Pipettor Dispense steps). + (VENUS only) + + This error is created from main / slave error 02/52 and 02/54. + + Code: 105 + """ + + +class NoLabwareError(STARModuleError): + """ + The labware to be loaded was not detected by autoload module. (VENUS only) + + Note: + + May only occur on a Reload Carrier step if the labware property 'MlStarCarPosAreRecognizable' is + set to 1. + + Code: 106 + """ + + +class UnexpectedLabwareError(STARModuleError): + """ + The labware contains unexpected barcode ( may only occur on a Reload Carrier step ). (VENUS only) + + Code: 107 + """ + + +class WrongLabwareError(STARModuleError): + """ + The labware to be reloaded contains wrong barcode ( may only occur on a Reload Carrier step ). + (VENUS only) + + Code: 108 + """ + + +class BarcodeMaskError(STARModuleError): + """ + The barcode read doesn't match the barcode mask defined. (VENUS only) + + Code: 109 + """ + + +class BarcodeNotUniqueError(STARModuleError): + """ + The barcode read is not unique. Previously loaded labware with same barcode was loaded without + unique barcode check. (VENUS only) + + Code: 110 + """ + + +class BarcodeAlreadyUsedError(STARModuleError): + """ + The barcode read is already loaded as unique barcode ( it's not possible to load the same barcode + twice ). (VENUS only) + + Code: 111 + """ + + +class KitLotExpiredError(STARModuleError): + """ + Kit Lot expired. (VENUS only) + + Code: 112 + """ + + +class DelimiterError(STARModuleError): + """ + Barcode contains character which is used as delimiter in result string. (VENUS only) + + Code: 113 + """ + + +class UnknownHamiltonError(STARModuleError): + """Unknown error""" + + +def _module_id_to_module_name(id_): + """Convert a module ID to a module name.""" + return { + "C0": "Master", + "X0": "X-drives", + "I0": "Auto Load", + "W1": "Wash station 1-3", + "W2": "Wash station 4-6", + "T1": "Temperature carrier 1", + "T2": "Temperature carrier 2", + "R0": "ISWAP", + "P1": "Pipetting channel 1", + "P2": "Pipetting channel 2", + "P3": "Pipetting channel 3", + "P4": "Pipetting channel 4", + "P5": "Pipetting channel 5", + "P6": "Pipetting channel 6", + "P7": "Pipetting channel 7", + "P8": "Pipetting channel 8", + "P9": "Pipetting channel 9", + "PA": "Pipetting channel 10", + "PB": "Pipetting channel 11", + "PC": "Pipetting channel 12", + "PD": "Pipetting channel 13", + "PE": "Pipetting channel 14", + "PF": "Pipetting channel 15", + "PG": "Pipetting channel 16", + "H0": "CoRe 96 Head", + "HW": "Pump station 1 station", + "HU": "Pump station 2 station", + "HV": "Pump station 3 station", + "N0": "Nano dispenser", + "D0": "384 dispensing head", + "NP": "Nano disp. pressure controller", + "M1": "Reserved for module 1", + }.get(id_, "Unknown Module") + + +def error_code_to_exception(code: int) -> Type[STARModuleError]: + """Convert an error code to an exception.""" + codes = { + 1: CommandSyntaxError, + 2: HardwareError, + 3: CommandNotCompletedError, + 4: ClotDetectedError, + 5: BarcodeUnreadableError, + 6: TipTooLittleVolumeError, + 7: TipAlreadyFittedError, + 8: HamiltonNoTipError, + 9: NoCarrierError, + 10: NotCompletedError, + 11: DispenseWithPressureLLDError, + 12: NoTeachInSignalError, + 13: LoadingTrayError, + 14: SequencedAspirationWithPressureLLDError, + 15: NotAllowedParameterCombinationError, + 16: CoverCloseError, + 17: AspirationError, + 18: WashFluidOrWasteError, + 19: IncubationError, + 20: TADMMeasurementError, + 21: NoElementError, + 22: ElementStillHoldingError, + 23: ElementLostError, + 24: IllegalTargetPlatePositionError, + 25: IllegalUserAccessError, + 26: TADMMeasurementError, + 27: PositionNotReachableError, + 28: UnexpectedLLDError, + 29: AreaAlreadyOccupiedError, + 30: ImpossibleToOccupyAreaError, + 31: AntiDropControlError, + 32: DecapperError, + 33: DecapperHandlingError, + 99: SlaveError, + 100: WrongCarrierError, + 101: NoCarrierBarcodeError, + 102: LiquidLevelError, + 103: NotDetectedError, + 104: NotAspiratedError, + 105: ImproperDispensationError, + 106: NoLabwareError, + 107: UnexpectedLabwareError, + 108: WrongLabwareError, + 109: BarcodeMaskError, + 110: BarcodeNotUniqueError, + 111: BarcodeAlreadyUsedError, + 112: KitLotExpiredError, + 113: DelimiterError, + } + if code in codes: + return codes[code] + return UnknownHamiltonError + + +def trace_information_to_string(module_identifier: str, trace_information: int) -> str: + """Convert a trace identifier to an error message.""" + table = None + + if module_identifier == "C0": # master + table = { + 10: "CAN error", + 11: "Slave command time out", + 20: "E2PROM error", + 30: "Unknown command", + 31: "Unknown parameter", + 32: "Parameter out of range", + 33: "Parameter does not belong to command, or not all parameters were sent", + 34: "Node name unknown", + 35: "id parameter error", + 37: "node name defined twice", + 38: "faulty XL channel settings", + 39: "faulty robotic channel settings", + 40: "PIP task busy", + 41: "Auto load task busy", + 42: "Miscellaneous task busy", + 43: "Incubator task busy", + 44: "Washer task busy", + 45: "iSWAP task busy", + 46: "CoRe 96 head task busy", + 47: "Carrier sensor doesn't work properly", + 48: "CoRe 384 head task busy", + 49: "Nano pipettor task busy", + 50: "XL channel task busy", + 51: "Tube gripper task busy", + 52: "Imaging channel task busy", + 53: "Robotic channel task busy", + } + elif module_identifier == "I0": # autoload + table = {36: "Hamilton will not run while the hood is open"} + elif module_identifier in [ + "PX", + "P1", + "P2", + "P3", + "P4", + "P5", + "P6", + "P7", + "P8", + "P9", + "PA", + "PB", + "PC", + "PD", + "PE", + "PF", + "PG", + ]: + table = { + 0: "No error", + 20: "No communication to EEPROM", + 30: "Unknown command", + 31: "Unknown parameter", + 32: "Parameter out of range", + 35: "Voltages outside permitted range", + 36: "Stop during execution of command", + 37: "Stop during execution of command", + 40: "No parallel processes permitted (Two or more commands sent for the same control" + "process)", + 50: "Dispensing drive init. position not found", + 51: "Dispensing drive not initialized", + 52: "Dispensing drive movement error", + 53: "Maximum volume in tip reached", + 54: "Position outside of permitted area", + 55: "Y-drive blocked", + 56: "Y-drive not initialized", + 57: "Y-drive movement error", + 60: "Z-drive blocked", + 61: "Z-drive not initialized", + 62: "Z-drive movement error", + 63: "Z-drive limit stop not found", + 65: "Squeezer drive blocked. Can you manually unblock the squeezer drive by turning its screw?", + 66: "Squeezer drive not initialized", + 67: "Squeezer drive movement error: Step loss", + 68: "Init position adjustment error", + 70: "No liquid level found (possibly because no liquid was present, or too little liquid was present to trigger cLLD)", + 71: "Not enough liquid present (Immersion depth or surface following position possibly" + "below minimal access range)", + 72: "Auto calibration at pressure (Sensor not possible)", + 73: "No liquid level found with dual LLD", + 74: "Liquid at a not allowed position detected", + 75: "No tip picked up, possibly because no was present at specified position", + 76: "Tip already picked up", + 77: "Tip not dropped", + 78: "Wrong tip picked up", + 80: "Liquid not correctly aspirated", + 81: "Clot detected", + 82: "TADM measurement out of lower limit curve", + 83: "TADM measurement out of upper limit curve", + 84: "Not enough memory for TADM measurement", + 85: "No communication to digital potentiometer", + 86: "ADC algorithm error", + 87: "2nd phase of liquid nt found", + 88: "Not enough liquid present (Immersion depth or surface following position possibly" + "below minimal access range)", + 90: "Limit curve not resettable", + 91: "Limit curve not programmable", + 92: "Limit curve not found", + 93: "Limit curve data incorrect", + 94: "Not enough memory for limit curve", + 95: "Invalid limit curve index", + 96: "Limit curve already stored", + } + elif module_identifier == "H0": # Core 96 head + table = { + 20: "No communication to EEPROM", + 30: "Unknown command", + 31: "Unknown parameter", + 32: "Parameter out of range", + 35: "Voltage outside permitted range", + 36: "Stop during execution of command", + 37: "The adjustment sensor did not switch", + 40: "No parallel processes permitted", + 50: "Dispensing drive initialization failed", + 51: "Dispensing drive not initialized", + 52: "Dispensing drive movement error", + 53: "Maximum volume in tip reached", + 54: "Position out of permitted area", + 55: "Y drive initialization failed", + 56: "Y drive not initialized", + 57: "Y drive movement error", + 58: "Y drive position outside of permitted area", + 60: "Z drive initialization failed", + 61: "Z drive not initialized", + 62: "Z drive movement error", + 63: "Z drive position outside of permitted area", + 65: "Squeezer drive initialization failed", + 66: "Squeezer drive not initialized", + 67: "Squeezer drive movement error: drive blocked or incremental sensor fault", + 68: "Squeezer drive position outside of permitted area", + 70: "No liquid level found", + 71: "Not enough liquid present", + 75: "No tip picked up", + 76: "Tip already picked up", + 81: "Clot detected", + } + elif module_identifier == "R0": # iswap + table = { + 20: "No communication to EEPROM", + 30: "Unknown command", + 31: "Unknown parameter", + 32: "Parameter out of range", + 33: "FW doesn't match to HW", + 36: "Stop during execution of command", + 37: "The adjustment sensor did not switch", + 38: "The adjustment sensor cannot be searched", + 40: "No parallel processes permitted", + 41: "No parallel processes permitted", + 42: "No parallel processes permitted", + 50: "Y-drive Initialization failed", + 51: "Y-drive not initialized", + 52: "Y-drive movement error: drive locked or incremental sensor fault", + 53: "Y-drive movement error: position counter over/underflow", + 60: "Z-drive initialization failed", + 61: "Z-drive not initialized", + 62: "Z-drive movement error: drive locked or incremental sensor fault", + 63: "Z-drive movement error: position counter over/underflow", + 70: "Rotation-drive initialization failed", + 71: "Rotation-drive not initialized", + 72: "Rotation-drive movement error: drive locked or incremental sensor fault", + 73: "Rotation-drive movement error: position counter over/underflow", + 80: "Wrist twist drive initialization failed", + 81: "Wrist twist drive not initialized", + 82: "Wrist twist drive movement error: drive locked or incremental sensor fault", + 83: "Wrist twist drive movement error: position counter over/underflow", + 85: "Gripper drive: communication error to gripper DMS digital potentiometer", + 86: "Gripper drive: Auto adjustment of DMS digital potentiometer not possible", + 89: "Gripper drive movement error: drive locked or incremental sensor fault during gripping", + 90: "Gripper drive initialized failed", + 91: "iSWAP not initialized. Call STARBackend.initialize_iswap().", + 92: "Gripper drive movement error: drive locked or incremental sensor fault during release", + 93: "Gripper drive movement error: position counter over/underflow", + 94: "Plate not found", + 96: "Plate not available", + 97: "Unexpected object found", + } + + if table is not None and trace_information in table: + return table[trace_information] + + return f"Unknown trace information code {trace_information:02}" + + +class STARFirmwareError(Exception): + def __init__(self, errors: Dict[str, STARModuleError], raw_response: str): + self.errors = errors + self.raw_response = raw_response + super().__init__(f"{errors}, {raw_response}") + + +def star_firmware_string_to_error( + error_code_dict: Dict[str, str], + raw_response: str, +) -> STARFirmwareError: + """Convert a firmware string to a STARFirmwareError.""" + + errors = {} + + for module_id, error in error_code_dict.items(): + module_name = _module_id_to_module_name(module_id) + if "/" in error: + # C0 module: error code / trace information + error_code_str, trace_information_str = error.split("/") + error_code, trace_information = ( + int(error_code_str), + int(trace_information_str), + ) + if error_code == 0: # No error + continue + error_class = error_code_to_exception(error_code) + elif module_id == "I0" and error == "36": + error_class = StopError + trace_information = int(error) + else: + # Slave modules: er## (just trace information) + error_class = UnknownHamiltonError + trace_information = int(error) + error_description = trace_information_to_string( + module_identifier=module_id, trace_information=trace_information + ) + errors[module_name] = error_class( + message=error_description, + trace_information=trace_information, + raw_response=error, + raw_module=module_id, + ) + + # If the master error is a SlaveError, remove it from the errors dict. + if isinstance(errors.get("Master"), SlaveError): + errors.pop("Master") + + return STARFirmwareError(errors=errors, raw_response=raw_response) + + +def convert_star_module_error_to_plr_error( + error: STARModuleError, +) -> Optional[Exception]: + """Convert an error returned by a specific STAR module to a Hamilton error.""" + # TipAlreadyFittedError -> HasTipError + if isinstance(error, TipAlreadyFittedError): + return HasTipError() + + # HamiltonNoTipError -> NoTipError + if isinstance(error, HamiltonNoTipError): + return NoTipError(error.message) + + if error.trace_information == 75: + return NoTipError(error.message) + + if error.trace_information in {70, 71}: + return TooLittleLiquidError(error.message) + + if error.trace_information in {54}: + return TooLittleVolumeError(error.message) + + return None + + +def convert_star_firmware_error_to_plr_error( + error: STARFirmwareError, +) -> Optional[Exception]: + """Check if a STARFirmwareError can be converted to a native PLR error. If so, return it, else + return `None`.""" + + # if all errors are channel errors, return a ChannelizedError + if all(e.startswith("Pipetting channel ") for e in error.errors): + + def _channel_to_int(channel: str) -> int: + return int(channel.split(" ")[-1]) - 1 # star is 1-indexed, plr is 0-indexed + + errors = { + _channel_to_int(module_name): convert_star_module_error_to_plr_error(error) or error + for module_name, error in error.errors.items() + } + return ChannelizedError(errors=errors, raw_response=error.raw_response) + + return None + + +def _dispensing_mode_for_op(empty: bool, jet: bool, blow_out: bool) -> int: + """from docs: + 0 = Partial volume in jet mode + 1 = Blow out in jet mode, called "empty" in the VENUS liquid editor + 2 = Partial volume at surface + 3 = Blow out at surface, called "empty" in the VENUS liquid editor + 4 = Empty tip at fix position + """ + + if empty: + return 4 + if jet: + return 1 if blow_out else 0 + else: + return 3 if blow_out else 2 + + +@dataclass +class Head96Information: + """Information about the installed 96-head.""" + + StopDiscType = Literal["core_i", "core_ii"] + InstrumentType = Literal["legacy", "FM-STAR"] + HeadType = Literal["Low volume head", "High volume head", "96 head II", "96 head TADM", "unknown"] + + fw_version: datetime.date + supports_clot_monitoring_clld: bool + stop_disc_type: StopDiscType + instrument_type: InstrumentType + head_type: HeadType + + +@device( + id="star_backend", + category=["Liquid Handling Workstation"], + description="Hamilton STAR 自动化液体处理平台,用于实验室吸液、分液、装卸吸头、液位探测、板与载架搬运,并可控制 iSWAP、CoRe 96 头、autoload 载架装载及部分连接的加热冷却模块。", + model={ + "type": "device", + "mesh": "star_backend", + "path": "https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/devices/star_backend/macro_device.xacro", + }, +) +class STARBackend(HamiltonLiquidHandler, HamiltonHeaterShakerInterface): + """Interface for the Hamilton STARBackend.""" + + def __init__( + self, + device_address: Optional[int] = None, + serial_number: Optional[str] = None, + packet_read_timeout: int = 3, + read_timeout: int = 30, + write_timeout: int = 30, + ): + print("[UNILAB] STARBackend.__init__() called", flush=True) + """Create a new STAR interface. + + Args: + device_address: the USB device address of the Hamilton STARBackend. Only useful if using more than + one Hamilton machine over USB. + serial_number: the serial number of the Hamilton STARBackend. Only useful if using more than one + Hamilton machine over USB. + packet_read_timeout: timeout in seconds for reading a single packet. + read_timeout: timeout in seconds for reading a full response. + write_timeout: timeout in seconds for writing a command. + """ + + super().__init__( + device_address=device_address, + packet_read_timeout=packet_read_timeout, + read_timeout=read_timeout, + write_timeout=write_timeout, + id_product=0x8000, + serial_number=serial_number, + ) + + self.iswap_installed: Optional[bool] = None + self.autoload_installed: Optional[bool] = None + self.core96_head_installed: Optional[bool] = None + + self._iswap_parked: Optional[bool] = None + self._num_channels: Optional[int] = None + self._channel_minimum_y_spacing: float = 9.0 + self._core_parked: Optional[bool] = None + self._extended_conf: Optional[dict] = None + self._channel_traversal_height: float = 245.0 + self._iswap_traversal_height: float = 280.0 + self.core_adjustment = Coordinate.zero() + self._unsafe = UnSafe(self) + + self._iswap_version: Optional[str] = None # loaded lazily + + self._default_1d_symbology: Barcode1DSymbology = "Code 128 (Subset B and C)" + + self._setup_done = False + + @property + @action(auto_prefix=True, description="获取机械臂数量。") + def num_arms(self) -> int: + _unilab_logger.debug("[UNILAB] STARBackend.num_arms() called") + return 1 if self.iswap_installed else 0 + + @property + @action(auto_prefix=True, description="获取是否安装96头。") + def head96_installed(self) -> Optional[bool]: + _unilab_logger.debug("[UNILAB] STARBackend.head96_installed() called") + return self.core96_head_installed + + @property + @action(auto_prefix=True, description="获取高风险动作命名空间。") + def unsafe(self) -> "UnSafe": + _unilab_logger.debug("[UNILAB] STARBackend.unsafe() called") + """Actions that have a higher risk of damaging the robot. Use with care!""" + return self._unsafe + + @property + @action(auto_prefix=True, description="获取移液通道数量。") + def num_channels(self) -> int: + _unilab_logger.debug("[UNILAB] STARBackend.num_channels() called") + """The number of pipette channels present on the robot.""" + if self._num_channels is None: + raise RuntimeError("has not loaded num_channels, forgot to call `setup`?") + return self._num_channels + + @action(auto_prefix=True, description="设置最小通过高度。") + def set_minimum_traversal_height(self, traversal_height: float): + _unilab_logger.debug("[UNILAB] STARBackend.set_minimum_traversal_height() called") + raise NotImplementedError( + "set_minimum_traversal_height is deprecated. use set_minimum_channel_traversal_height or " + "set_minimum_iswap_traversal_height instead." + ) + + @action(auto_prefix=True, description="设置通道最小通过高度。") + def set_minimum_channel_traversal_height(self, traversal_height: float): + _unilab_logger.debug("[UNILAB] STARBackend.set_minimum_channel_traversal_height() called") + """Set the minimum traversal height for the pip channels. + + This refers to the bottom of the pipetting channel when no tip is present, or the bottom of the + tip when a tip is present. This value will be used as the default value for the + `minimal_traverse_height_at_begin_of_command` and `minimal_height_at_command_end` parameters + unless they are explicitly set. + """ + + assert 0 < traversal_height < 285, "Traversal height must be between 0 and 285 mm" + + self._channel_traversal_height = traversal_height + + @action(auto_prefix=True, description="设置iSWAP最小通过高度。") + def set_minimum_iswap_traversal_height(self, traversal_height: float): + _unilab_logger.debug("[UNILAB] STARBackend.set_minimum_iswap_traversal_height() called") + """Set the minimum traversal height for the iswap.""" + + assert 0 < traversal_height < 285, "Traversal height must be between 0 and 285 mm" + + self._iswap_traversal_height = traversal_height + + @contextmanager + @action(auto_prefix=True, description="设置iSWAP最小通过高度。") + def iswap_minimum_traversal_height(self, traversal_height: float): + _unilab_logger.debug("[UNILAB] STARBackend.iswap_minimum_traversal_height() called") + orig = self._iswap_traversal_height + self._iswap_traversal_height = traversal_height + try: + yield + except Exception as e: + self._iswap_traversal_height = orig + raise e + + @property + @action(auto_prefix=True, description="获取iSWAP通过高度。") + def iswap_traversal_height(self) -> float: + _unilab_logger.debug("[UNILAB] STARBackend.iswap_traversal_height() called") + return self._iswap_traversal_height + + @property + @action(auto_prefix=True, description="获取模块ID长度。") + def module_id_length(self): + _unilab_logger.debug("[UNILAB] STARBackend.module_id_length() called") + return 2 + + @property + @action(auto_prefix=True, description="获取扩展配置。") + def extended_conf(self) -> dict: + _unilab_logger.debug("[UNILAB] STARBackend.extended_conf() called") + """Extended configuration.""" + if self._extended_conf is None: + raise RuntimeError("has not loaded extended_conf, forgot to call `setup`?") + return self._extended_conf + + @property + @action(auto_prefix=True, description="获取iSWAP是否已停放。") + def iswap_parked(self) -> bool: + _unilab_logger.debug("[UNILAB] STARBackend.iswap_parked() called") + return self._iswap_parked is True + + @property + @action(auto_prefix=True, description="获取CoRe夹爪是否已停放。") + def core_parked(self) -> bool: + _unilab_logger.debug("[UNILAB] STARBackend.core_parked() called") + return self._core_parked is True + + @action(auto_prefix=True, description="获取iSWAP版本。") + async def get_iswap_version(self) -> str: + _unilab_logger.debug("[UNILAB] STARBackend.get_iswap_version() called") + """Lazily load the iSWAP version. Use cached value if available.""" + if self._iswap_version is None: + self._iswap_version = await self.request_iswap_version() + return self._iswap_version + + @action(auto_prefix=True, description="获取指定PIP通道版本。") + async def request_pip_channel_version(self, channel: int) -> str: + _unilab_logger.debug("[UNILAB] STARBackend.request_pip_channel_version() called") + return cast( + str, + (await self.send_command(STARBackend.channel_id(channel), "RF", fmt="rf" + "&" * 17))["rf"], + ) + + @action(auto_prefix=True, description="从固件响应中提取ID。") + def get_id_from_fw_response(self, resp: str) -> Optional[int]: + _unilab_logger.debug("[UNILAB] STARBackend.get_id_from_fw_response() called") + """Get the id from a firmware response.""" + parsed = parse_star_fw_string(resp, "id####") + if "id" in parsed and parsed["id"] is not None: + return int(parsed["id"]) + return None + + @action(auto_prefix=True, description="解析并检查固件返回错误。") + def check_fw_string_error(self, resp: str): + _unilab_logger.debug("[UNILAB] STARBackend.check_fw_string_error() called") + """Raise an error if the firmware response is an error response. + + Raises: + ValueError: if the format string is incompatible with the response. + HamiltonException: if the response contains an error. + """ + + # Parse errors. + module = resp[:2] + if module == "C0": + # C0 sends errors as er##/##. P1 raises errors as er## where the first group is the error + # code, and the second group is the trace information. + # Beyond that, specific errors may be added for individual channels and modules. These + # are formatted as P1##/## H0##/##, etc. These items are added programmatically as + # named capturing groups to the regex. + + exp = r"er(?P[0-9]{2}/[0-9]{2})" + for module in [ + "X0", + "I0", + "W1", + "W2", + "T1", + "T2", + "R0", + "P1", + "P2", + "P3", + "P4", + "P5", + "P6", + "P7", + "P8", + "P9", + "PA", + "PB", + "PC", + "PD", + "PE", + "PF", + "PG", + "H0", + "HW", + "HU", + "HV", + "N0", + "D0", + "NP", + "M1", + ]: + exp += f" ?(?:{module}(?P<{module}>[0-9]{{2}}/[0-9]{{2}}))?" + errors = re.search(exp, resp) + else: + # Other modules send errors as er##, and do not contain slave errors. + exp = f"er(?P<{module}>[0-9]{{2}})" + errors = re.search(exp, resp) + + if errors is not None: + # filter None elements + errors_dict = {k: v for k, v in errors.groupdict().items() if v is not None} + # filter 00 and 00/00 elements, which mean no error. + errors_dict = {k: v for k, v in errors_dict.items() if v not in ["00", "00/00"]} + + has_error = not (errors is None or len(errors_dict) == 0) + if has_error: + he = star_firmware_string_to_error(error_code_dict=errors_dict, raw_response=resp) + + # If there is a faulty parameter error, request which parameter that is. + for module_name, error in he.errors.items(): + if error.message == "Unknown parameter": + # temp. disabled until we figure out how to handle async in parse response (the + # background thread does not have an event loop, and I'm not sure if it should.) + # vp = await self.send_command(module=error.raw_module, command="VP", fmt="vp&&")["vp"] + # he[module_name].message += f" ({vp})" + + he.errors[ + module_name + ].message += " (call lh.backend.request_name_of_last_faulty_parameter)" + + raise he + + def _parse_response(self, resp: str, fmt: str) -> dict: + _unilab_logger.debug("[UNILAB] STARBackend._parse_response() called") + """Parse a response from the machine.""" + return parse_star_fw_string(resp, fmt) + + def _parse_firmware_version_datetime(self, fw_version: str) -> datetime.date: + _unilab_logger.debug("[UNILAB] STARBackend._parse_firmware_version_datetime() called") + """Extract datetime from firmware version string. + + Args: + fw_version: Firmware version string (e.g., "v2021.03.15" or "2023_Q2_v1.4") + + Returns: + A datetime object representing the extracted date + """ + + # Prefer full date patterns like YYYY.MM.DD / YYYY_MM_DD / YYYY-MM-DD + date_match = re.search(r"\b(20\d{2})[._-](\d{2})[._-](\d{2})\b", fw_version) + if date_match: + y, m, d = map(int, date_match.groups()) + return datetime.date(y, m, d) + + # Handle quarter formats like 2023_Q2 -> first day of the quarter + q_match = re.search(r"\b(20\d{2})_Q([1-4])\b", fw_version, flags=re.IGNORECASE) + if q_match: + y = int(q_match.group(1)) + q = int(q_match.group(2)) + month = (q - 1) * 3 + 1 + return datetime.date(y, month, 1) + + # Fall back to year only -> Jan 1st of that year, or None + year_match = re.search(r"\b(20\d{2})\b", fw_version) + if year_match is None: + raise ValueError(f"Could not parse year from firmware version string: '{fw_version}'") + return datetime.date(int(year_match.group(1)), 1, 1) + + @action(auto_prefix=True, description="执行整机初始化设置。") + async def setup( + self, + skip_instrument_initialization=False, + skip_pip=False, + skip_autoload=False, + skip_iswap=False, + skip_core96_head=False, + ): + _unilab_logger.debug("[UNILAB] STARBackend.setup() called") + """Creates a USB connection and finds read/write interfaces. + + Args: + skip_autoload: if True, skip initializing the autoload module, if applicable. + skip_iswap: if True, skip initializing the iSWAP module, if applicable. + skip_core96_head: if True, skip initializing the CoRe 96 head module, if applicable. + """ + + await super().setup() + + self.id_ = 0 + + # Request machine information + conf = await self.request_machine_configuration() + self._extended_conf = await self.request_extended_configuration() + + left_x_drive_configuration_byte_1 = bin(self.extended_conf["xl"]) + left_x_drive_configuration_byte_1 = left_x_drive_configuration_byte_1 + "0" * ( + 16 - len(left_x_drive_configuration_byte_1) + ) + left_x_drive_configuration_byte_1 = left_x_drive_configuration_byte_1[2:] + configuration_data1 = bin(conf["kb"]).split("b")[-1].zfill(8) + autoload_configuration_byte = configuration_data1[-4] + # Identify installations + self.autoload_installed = autoload_configuration_byte == "1" + self.core96_head_installed = left_x_drive_configuration_byte_1[2] == "1" + self.iswap_installed = left_x_drive_configuration_byte_1[1] == "1" + self._head96_information: Optional[Head96Information] = None + + initialized = await self.request_instrument_initialization_status() + + if not initialized: + if not skip_instrument_initialization: + logger.info("Running backend initialization procedure.") + + await self.pre_initialize_instrument() + else: + # pre_initialize only runs when the robot is not initialized + # pre_initialize will move all channels to Z safety + # so if we skip pre_initialize, we need to raise the channels ourselves + await self.move_all_channels_in_z_safety() + if self.core96_head_installed: + await self.move_core_96_to_safe_position() + + tip_presences = await self.request_tip_presence() + self._num_channels = len(tip_presences) + + async def set_up_pip(): + _unilab_logger.debug("[UNILAB] STARBackend.set_up_pip() called") + if (not initialized or any(tip_presences)) and not skip_pip: + await self.initialize_pip() + self._channel_minimum_y_spacing = ( + 9.0 # TODO: identify from machine directly to override default + ) + + async def set_up_autoload(): + _unilab_logger.debug("[UNILAB] STARBackend.set_up_autoload() called") + if self.autoload_installed and not skip_autoload: + autoload_initialized = await self.request_autoload_initialization_status() + if not autoload_initialized: + await self.initialize_autoload() + + await self.park_autoload() + + async def set_up_iswap(): + _unilab_logger.debug("[UNILAB] STARBackend.set_up_iswap() called") + if self.iswap_installed and not skip_iswap: + iswap_initialized = await self.request_iswap_initialization_status() + if not iswap_initialized: + await self.initialize_iswap() + + await self.park_iswap( + minimum_traverse_height_at_beginning_of_a_command=int(self._iswap_traversal_height * 10) + ) + + async def set_up_core96_head(): + _unilab_logger.debug("[UNILAB] STARBackend.set_up_core96_head() called") + if self.core96_head_installed and not skip_core96_head: + # Initialize 96-head + core96_head_initialized = await self.request_core_96_head_initialization_status() + if not core96_head_initialized: + await self.initialize_core_96_head( + trash96=self.deck.get_trash_area96(), + z_position_at_the_command_end=self._channel_traversal_height, + ) + + # Cache firmware version and configuration for version-specific behavior + fw_version = await self.head96_request_firmware_version() + configuration_96head = await self._head96_request_configuration() + head96_type = await self.head96_request_type() + + self._head96_information = Head96Information( + fw_version=fw_version, + supports_clot_monitoring_clld=bool(int(configuration_96head[0])), + stop_disc_type="core_i" if configuration_96head[1] == "0" else "core_ii", + instrument_type="legacy" if configuration_96head[2] == "0" else "FM-STAR", + head_type=head96_type, + ) + + async def set_up_arm_modules(): + _unilab_logger.debug("[UNILAB] STARBackend.set_up_arm_modules() called") + await set_up_pip() + await set_up_iswap() + await set_up_core96_head() + + await asyncio.gather(set_up_autoload(), set_up_arm_modules()) + + # After setup, STAR will have thrown out anything mounted on the pipetting channels, including + # the core grippers. + self._core_parked = True + + self._setup_done = True + + @action(auto_prefix=True, description="立即停止设备。") + async def stop(self): + _unilab_logger.debug("[UNILAB] STARBackend.stop() called") + await super().stop() + self._setup_done = False + + @property + @action(auto_prefix=True, description="获取是否完成设置。") + def setup_done(self) -> bool: + _unilab_logger.debug("[UNILAB] STARBackend.setup_done() called") + return self._setup_done + + # ============== LiquidHandlerBackend methods ============== + + # # # # Single-Channel Pipette Commands # # # # + + # # # Machine Query (MEM-READ) Commands: Single-Channel # # # + + @action(auto_prefix=True, description="查询通道最小Y间距。") + async def channel_request_y_minimum_spacing(self, channel_idx: int) -> float: + _unilab_logger.debug("[UNILAB] STARBackend.channel_request_y_minimum_spacing() called") + """Request the minimum Y spacing for a given channel. + Args: + channel_idx: the channel index to query. (0-indexed) + Returns: + The minimum Y spacing in mm. + """ + + if not 0 <= channel_idx <= self.num_channels - 1: + raise ValueError( + f"channel_idx must be between 0 and {self.num_channels - 1}, " f"got {channel_idx}." + ) + + resp = await self.send_command( + module=self.channel_id(channel_idx), + command="VY", + fmt="yc### (n)", + ) + return self.y_drive_increment_to_mm(resp["yc"][1]) + + @action(auto_prefix=True, description="检查通道是否可达指定位置。") + def can_reach_position(self, channel_idx: int, position: Coordinate) -> bool: + _unilab_logger.debug("[UNILAB] STARBackend.can_reach_position() called") + """Check if a position is reachable by a channel (center-based).""" + if not (0 <= channel_idx < self.num_channels): + raise ValueError(f"Channel {channel_idx} is out of range for this robot.") + + # frontmost channel can go to y=6, every channel after that is about 8.9 mm further back + min_y_pos = 6 + 8.9 * (self.num_channels - channel_idx - 1) + if position.y < min_y_pos: + return False + + # backmost channel can go to y=601.6, every channel before that is about 8.9 mm further forward + max_y_pos = 601.6 - 8.9 * channel_idx + if position.y > max_y_pos: + return False + + return True + + @action(auto_prefix=True, description="确保所选通道可达目标位置。") + def ensure_can_reach_position( + self, use_channels: List[int], ops: Sequence[PipettingOp], op_name: str + ): + _unilab_logger.debug("[UNILAB] STARBackend.ensure_can_reach_position() called") + locs = [(op.resource.get_location_wrt(self.deck, y="c") + op.offset) for op in ops] + cant_reach = [ + channel_idx + for channel_idx, loc in zip(use_channels, locs) + if not self.can_reach_position(channel_idx, loc) + ] + if len(cant_reach) > 0: + raise ValueError( + f"Channels {cant_reach} cannot reach their target positions in '{op_name}' operation.\n" + "Robots with more than 8 channels have limited Y-axis reach per channel; they don't have random access to the full deck area.\n" + "Try the operation with different channels or a different target position (i.e. different labware placement)." + ) + + # # # ACTION Commands # # # + + @action(auto_prefix=True, description="拾取多个通道吸头。") + async def pick_up_tips( + self, + ops: List[Pickup], + use_channels: List[int], + begin_tip_pick_up_process: Optional[float] = None, + end_tip_pick_up_process: Optional[float] = None, + minimum_traverse_height_at_beginning_of_a_command: Optional[float] = None, + pickup_method: Optional[TipPickupMethod] = None, + ): + _unilab_logger.debug("[UNILAB] STARBackend.pick_up_tips() called") + """Pick up tips from a resource.""" + + self.ensure_can_reach_position(use_channels, ops, "pick_up_tips") + + x_positions, y_positions, channels_involved = self._ops_to_fw_positions(ops, use_channels) + + tip_spots = [op.resource for op in ops] + tips = set(cast(HamiltonTip, tip_spot.get_tip()) for tip_spot in tip_spots) + if len(tips) > 1: + raise ValueError("Cannot mix tips with different tip types.") + ttti = await self.get_or_assign_tip_type_index(tips.pop()) + + max_z = max(op.resource.get_location_wrt(self.deck).z + op.offset.z for op in ops) + max_total_tip_length = max(op.tip.total_tip_length for op in ops) + max_tip_length = max((op.tip.total_tip_length - op.tip.fitting_depth) for op in ops) + + # not sure why this is necessary, but it is according to log files and experiments + if self._get_hamilton_tip([op.resource for op in ops]).tip_size == TipSize.LOW_VOLUME: + max_tip_length += 2 + elif self._get_hamilton_tip([op.resource for op in ops]).tip_size != TipSize.STANDARD_VOLUME: + max_tip_length -= 2 + + tip = ops[0].tip + if not isinstance(tip, HamiltonTip): + raise TypeError("Tip type must be HamiltonTip.") + + begin_tip_pick_up_process = ( + round((max_z + max_total_tip_length) * 10) + if begin_tip_pick_up_process is None + else int(begin_tip_pick_up_process * 10) + ) + end_tip_pick_up_process = ( + round((max_z + max_tip_length) * 10) + if end_tip_pick_up_process is None + else round(end_tip_pick_up_process * 10) + ) + minimum_traverse_height_at_beginning_of_a_command = ( + round(self._channel_traversal_height * 10) + if minimum_traverse_height_at_beginning_of_a_command is None + else round(minimum_traverse_height_at_beginning_of_a_command * 10) + ) + pickup_method = pickup_method or tip.pickup_method + + try: + return await self.pick_up_tip( + x_positions=x_positions, + y_positions=y_positions, + tip_pattern=channels_involved, + tip_type_idx=ttti, + begin_tip_pick_up_process=begin_tip_pick_up_process, + end_tip_pick_up_process=end_tip_pick_up_process, + minimum_traverse_height_at_beginning_of_a_command=minimum_traverse_height_at_beginning_of_a_command, + pickup_method=pickup_method, + ) + except STARFirmwareError as e: + if plr_e := convert_star_firmware_error_to_plr_error(e): + raise plr_e from e + raise e + + @action(auto_prefix=True, description="卸下多个通道吸头。") + async def drop_tips( + self, + ops: List[Drop], + use_channels: List[int], + drop_method: Optional[TipDropMethod] = None, + begin_tip_deposit_process: Optional[float] = None, + end_tip_deposit_process: Optional[float] = None, + minimum_traverse_height_at_beginning_of_a_command: Optional[float] = None, + z_position_at_end_of_a_command: Optional[float] = None, + ): + _unilab_logger.debug("[UNILAB] STARBackend.drop_tips() called") + """Drop tips to a resource. + + Args: + drop_method: The method to use for dropping tips. If None, the default method for dropping to + tip spots is `DROP`, and everything else is `PLACE_SHIFT`. Note that `DROP` is only the + default if *all* tips are being dropped to a tip spot. + """ + + self.ensure_can_reach_position(use_channels, ops, "drop_tips") + + if drop_method is None: + if any(not isinstance(op.resource, TipSpot) for op in ops): + drop_method = TipDropMethod.PLACE_SHIFT + else: + drop_method = TipDropMethod.DROP + + x_positions, y_positions, channels_involved = self._ops_to_fw_positions(ops, use_channels) + + # get highest z position + max_z = max(op.resource.get_location_wrt(self.deck).z + op.offset.z for op in ops) + if drop_method == TipDropMethod.PLACE_SHIFT: + # magic values empirically found in https://github.com/PyLabRobot/pylabrobot/pull/63 + begin_tip_deposit_process = ( + round((max_z + 59.9) * 10) + if begin_tip_deposit_process is None + else round(begin_tip_deposit_process * 10) + ) + end_tip_deposit_process = ( + round((max_z + 49.9) * 10) + if end_tip_deposit_process is None + else round(end_tip_deposit_process * 10) + ) + else: + max_total_tip_length = max(op.tip.total_tip_length for op in ops) + max_tip_length = max((op.tip.total_tip_length - op.tip.fitting_depth) for op in ops) + begin_tip_deposit_process = ( + round((max_z + max_total_tip_length) * 10) + if begin_tip_deposit_process is None + else round(begin_tip_deposit_process * 10) + ) + end_tip_deposit_process = ( + round((max_z + max_tip_length) * 10) + if end_tip_deposit_process is None + else round(end_tip_deposit_process * 10) + ) + + minimum_traverse_height_at_beginning_of_a_command = ( + round(self._channel_traversal_height * 10) + if minimum_traverse_height_at_beginning_of_a_command is None + else round(minimum_traverse_height_at_beginning_of_a_command * 10) + ) + z_position_at_end_of_a_command = ( + round(self._channel_traversal_height * 10) + if z_position_at_end_of_a_command is None + else round(z_position_at_end_of_a_command * 10) + ) + + try: + return await self.discard_tip( + x_positions=x_positions, + y_positions=y_positions, + tip_pattern=channels_involved, + begin_tip_deposit_process=begin_tip_deposit_process, + end_tip_deposit_process=end_tip_deposit_process, + minimum_traverse_height_at_beginning_of_a_command=minimum_traverse_height_at_beginning_of_a_command, + z_position_at_end_of_a_command=z_position_at_end_of_a_command, + discarding_method=drop_method, + ) + except STARFirmwareError as e: + if plr_e := convert_star_firmware_error_to_plr_error(e): + raise plr_e from e + raise e + + def _assert_valid_resources(self, resources: Sequence[Resource]) -> None: + _unilab_logger.debug("[UNILAB] STARBackend._assert_valid_resources() called") + """Assert that resources are in a valid location for pipetting.""" + for resource in resources: + if resource.get_location_wrt(self.deck).z < 100: + raise ValueError( + f"Resource {resource} is too low: {resource.get_location_wrt(self.deck).z} < 100" + ) + + class LLDMode(enum.Enum): + """Liquid level detection mode.""" + + OFF = 0 + GAMMA = 1 + PRESSURE = 2 + DUAL = 3 + Z_TOUCH_OFF = 4 + + class PressureLLDMode(enum.Enum): + """Pressure liquid level detection mode.""" + + LIQUID = 0 + FOAM = 1 + + @action(auto_prefix=True, description="探测液面高度。") + async def probe_liquid_heights( + self, + containers: List[Container], + use_channels: Optional[List[int]] = None, + resource_offsets: Optional[List[Coordinate]] = None, + lld_mode: LLDMode = LLDMode.GAMMA, + search_speed: float = 10.0, + n_replicates: int = 1, + move_to_z_safety_after: bool = True, + ) -> List[float]: + _unilab_logger.debug("[UNILAB] STARBackend.probe_liquid_heights() called") + """Probe liquid surface heights in containers using liquid level detection. + + Performs capacitive or pressure-based liquid level detection (LLD) by moving channels to + container positions and sensing the liquid surface. Heights are measured from the bottom + of each container's cavity. + + Args: + containers: List of Container objects to probe, one per channel. + use_channels: Channel indices to use for probing (0-indexed). + resource_offsets: Optional XYZ offsets from container centers. Auto-calculated for single containers with odd channel counts to avoid center dividers. Defaults to container centers. + lld_mode: Detection mode - LLDMode(1) for capacitive, LLDMode(2) for pressure-based. Defaults to capacitive. + search_speed: Z-axis search speed in mm/s. Default 10.0 mm/s. + n_replicates: Number of measurements per channel. Default 1. + move_to_z_safety_after: Whether to move channels to safe Z height after probing. Default True. + + Returns: + Mean of measured liquid heights for each container (mm from cavity bottom). + + Raises: + RuntimeError: If channels lack tips. + NotImplementedError: If channels require different X positions. + + Notes: + - All specified channels must have tips attached + - All channels must be at the same X position (single-row operation) + - For single containers with odd channel counts, Y-offsets are applied to avoid + center dividers (Hamilton 1000 uL spacing: 9mm, offset: 5.5mm) + """ + + if use_channels is None: + use_channels = list(range(len(containers))) + + # Handle tip positioning ... if SINGLE container instance + if resource_offsets is None: + if len(set(containers)) == 1: + resource_offsets = get_wide_single_resource_liquid_op_offsets( + resource=containers[0], num_channels=len(containers) + ) + + if len(use_channels) % 2 != 0: + # Hamilton 1000 uL channels are 9 mm apart, so offset by half the distance + # + extra for the potential central 'splash guard' + y_offset = 5.5 + resource_offsets = [ + resource_offsets[i] + Coordinate(0, y_offset, 0) for i in range(len(use_channels)) + ] + + resource_offsets = resource_offsets or [Coordinate.zero()] * len(containers) + + # Validate parameters. + if lld_mode not in {self.LLDMode.GAMMA, self.LLDMode.PRESSURE}: + raise ValueError(f"LLDMode must be 1 (capacitive) or 2 (pressure-based), is {lld_mode}") + + if not len(containers) == len(use_channels) == len(resource_offsets): + raise ValueError( + "Length of containers, use_channels, resource_offsets and tip_lengths must match." + f"are {len(containers)}, {len(use_channels)}, {len(resource_offsets)}." + ) + + # Make sure we have tips on all channels and know their lengths + tip_presence = await self.request_tip_presence() + if not all(tip_presence[idx] for idx in use_channels): + raise RuntimeError("All specified channels must have tips attached.") + + tip_lengths = [await self.request_tip_len_on_channel(channel_idx=idx) for idx in use_channels] + + # Move channels to safe Z height before starting + await self.move_all_channels_in_z_safety() + + # Check if all channels are on the same x position, then move there + x_pos = [ + resource.get_location_wrt(self.deck, x="c", y="c", z="b").x + offset.x + for resource, offset in zip(containers, resource_offsets) + ] + if len(set(x_pos)) > 1: # TODO: implement + raise NotImplementedError( + "probe_liquid_heights is not yet supported for multiple x positions." + ) + await self.move_channel_x(0, x_pos[0]) + + # Move channels to their y positions + y_pos = [ + resource.get_location_wrt(self.deck, x="c", y="c", z="b").y + offset.y + for resource, offset in zip(containers, resource_offsets) + ] + await self.position_channels_in_y_direction( + {channel: y for channel, y in zip(use_channels, y_pos)} + ) + + # Detect liquid heights + absolute_heights_measurements: Dict[int, List[Optional[float]]] = { + ch: [] for ch in use_channels + } + + lowest_immers_positions = [ + container.get_absolute_location("c", "c", "cavity_bottom").z + + tip_len + - self.DEFAULT_TIP_FITTING_DEPTH + for container, tip_len in zip(containers, tip_lengths) + ] + start_pos_searches = [ + container.get_absolute_location("c", "c", "t").z + + tip_len + - self.DEFAULT_TIP_FITTING_DEPTH + + 5 + for container, tip_len in zip(containers, tip_lengths) + ] + + try: + for _ in range(n_replicates): + if lld_mode == self.LLDMode.GAMMA: + results = await asyncio.gather( + *[ + self._move_z_drive_to_liquid_surface_using_clld( + channel_idx=channel, + lowest_immers_pos=lip, + start_pos_search=sps, + channel_speed=search_speed, + ) + for channel, lip, sps in zip( + use_channels, lowest_immers_positions, start_pos_searches + ) + ], + return_exceptions=True, + ) + + else: + results = await asyncio.gather( + *[ + self._search_for_surface_using_plld( + channel_idx=channel, + lowest_immers_pos=lip, + start_pos_search=sps, + channel_speed=search_speed, + dispense_drive_speed=5.0, + plld_mode=self.PressureLLDMode.LIQUID, + clld_verification=False, + post_detection_dist=0.0, + ) + for channel, lip, sps in zip( + use_channels, lowest_immers_positions, start_pos_searches + ) + ], + return_exceptions=True, + ) + + # Get heights for ALL channels, handling failures for channels with no liquid + # (indexed 0 to self.num_channels-1) but only store for used channels + current_absolute_liquid_heights = await self.request_pip_height_last_lld() + for idx, (ch_idx, result) in enumerate(zip(use_channels, results)): + if isinstance(result, STARFirmwareError): + # Check if it's specifically the "no liquid found" error + error_msg = str(result).lower() + if "no liquid level found" in error_msg or "no liquid was present" in error_msg: + height = None # No liquid detected - this is expected + msg = ( + f"Channel {ch_idx}: No liquid detected. Could be because there is " + f"no liquid in container {containers[idx].name} or liquid level is too low." + ) + if lld_mode == self.LLDMode.GAMMA: + msg += " Consider using pressure-based LLD if liquid is believed to exist." + logger.warning(msg) + else: + # Some other firmware error - re-raise it + raise result + elif isinstance(result, Exception): + # Some other unexpected error - re-raise it + raise result + else: + height = current_absolute_liquid_heights[ch_idx] + absolute_heights_measurements[ch_idx].append(height) + except: + await self.move_all_channels_in_z_safety() + raise + + # Compute liquid heights relative to well bottom + relative_to_well: List[float] = [] + inconsistent_channels: List[str] = [] + + for ch, container in zip(use_channels, containers): + measurements = absolute_heights_measurements[ch] + valid = [m for m in measurements if m is not None] + cavity_bottom = container.get_absolute_location("c", "c", "cavity_bottom").z + + if len(valid) == 0: + relative_to_well.append(0.0) + elif len(valid) == len(measurements): + relative_to_well.append(sum(valid) / len(valid) - cavity_bottom) + else: + inconsistent_channels.append( + f"Channel {ch}: {len(valid)}/{len(measurements)} replicates detected liquid" + ) + + if inconsistent_channels: + raise RuntimeError( + "Inconsistent liquid detection across replicates. " + "This may indicate liquid levels near the detection limit:\n" + + "\n".join(inconsistent_channels) + ) + + if move_to_z_safety_after: + await self.move_all_channels_in_z_safety() + + return relative_to_well + + @action(auto_prefix=True, description="探测液体体积。") + async def probe_liquid_volumes( + self, + containers: List[Container], + use_channels: List[int], + resource_offsets: Optional[List[Coordinate]] = None, + lld_mode: LLDMode = LLDMode.GAMMA, + search_speed: float = 10.0, + n_replicates: int = 3, + move_to_z_safety_after: bool = True, + ) -> List[float]: + _unilab_logger.debug("[UNILAB] STARBackend.probe_liquid_volumes() called") + """Probe liquid volumes in containers by measuring heights and converting to volumes. + + Performs liquid level detection to measure surface heights, then converts heights to + volumes using each container's geometric model. This is a convenience wrapper around + probe_liquid_heights that handles the height-to-volume conversion. + + Args: + containers: List of Container objects to probe, one per channel. All must support height-to-volume conversion via compute_volume_from_height(). + use_channels: Channel indices to use for probing (0-indexed). + resource_offsets: Optional XYZ offsets from container centers. Auto-calculated for single containers with odd channel counts. Defaults to container centers. + lld_mode: Detection mode - LLDMode(1) for capacitive, LLDMode(2) for pressure-based. Defaults to capacitive. + search_speed: Z-axis search speed in mm/s. Default 10.0 mm/s. + n_replicates: Number of measurements per channel. Default 3. + move_to_z_safety_after: Whether to move channels to safe Z height after probing. Default True. + + Returns: + Volumes in each container (uL). + + Raises: + ValueError: If any container doesn't support height-to-volume conversion (raised by probe_liquid_heights). + NotImplementedError: If channels require different X positions. + + Notes: + - Delegates all motion, LLD, validation, and safety logic to probe_liquid_heights + - All containers must support height-volume functions. Volume calculation uses Container.compute_volume_from_height() + """ + + if any(not resource.supports_compute_height_volume_functions() for resource in containers): + raise ValueError( + "probe_liquid_volumes can only be used with containers that support height<->volume functions." + ) + + liquid_heights = await self.probe_liquid_heights( + containers=containers, + use_channels=use_channels, + resource_offsets=resource_offsets, + lld_mode=lld_mode, + search_speed=search_speed, + n_replicates=n_replicates, + move_to_z_safety_after=move_to_z_safety_after, + ) + + return [ + container.compute_volume_from_height(height) + for container, height in zip(containers, liquid_heights) + ] + + # # # Granular channel control methods # # # + + DISPENSING_DRIVE_VOL_LIMIT_BOTTOM = -45 # vol TODO: confirm with others + DISPENSING_DRIVE_VOL_LIMIT_TOP = 1_250 # vol + + @action(auto_prefix=True, description="读取通道分液驱动当前位置。") + async def channel_dispensing_drive_request_position(self, channel_idx: int) -> float: + _unilab_logger.debug("[UNILAB] STARBackend.channel_dispensing_drive_request_position() called") + """Request the current position of the channel's dispensing drive""" + + if not (0 <= channel_idx < self.num_channels): + raise ValueError(f"channel_idx must be between 0 and {self.num_channels-1}") + + resp = await self.send_command( + module=STARBackend.channel_id(channel_idx), command="RD", fmt="rd##### #####" + ) + return STARBackend.dispensing_drive_increment_to_volume(resp["rd"]) + + @action(auto_prefix=True, description="将通道分液驱动移动到指定体积位置。") + async def channel_dispensing_drive_move_to_volume_position( + self, + channel_idx: int, + vol: float, + flow_rate: float = 200.0, # uL/sec + acceleration: float = 3000.0, # uL/sec**2, + current_limit: int = 5, + ): + _unilab_logger.debug("[UNILAB] STARBackend.channel_dispensing_drive_move_to_volume_position() called") + """Move channel's dispensing drive to specified volume position + + Args: + channel_idx: Index of the channel to move (0-indexed). + vol: Target volume position to move the dispensing drive piston to (uL). + flow_rate: Speed of the movement (uL/sec). Default is 200.0 uL/sec. + acceleration: Acceleration of the movement (uL/sec**2). Default is 3000.0 uL/sec**2. + current_limit: Current limit for the drive (1-7). Default is 5. + """ + + if not (self.DISPENSING_DRIVE_VOL_LIMIT_BOTTOM <= vol <= self.DISPENSING_DRIVE_VOL_LIMIT_TOP): + raise ValueError( + f"Target dispensing Drive vol must be between {self.DISPENSING_DRIVE_VOL_LIMIT_BOTTOM}" + f" and {self.DISPENSING_DRIVE_VOL_LIMIT_TOP}, is {vol}" + ) + if not (0.9 <= flow_rate <= 632.8): + raise ValueError( + f"Dispensing drive speed must be between 0.9 and 632.8 uL/sec, is {flow_rate}" + ) + if not (234.4 <= acceleration <= 28125.6): + raise ValueError( + f"Dispensing drive acceleration must be between 234.4 and 28125.6 uL/sec**2, is {acceleration}" + ) + if not (1 <= current_limit <= 7): + raise ValueError( + f"Dispensing drive current limit must be between 1 and 7, is {current_limit}" + ) + + current_position = await self.channel_dispensing_drive_request_position(channel_idx=channel_idx) + relative_vol_movement = round(vol - current_position, 1) + relative_vol_movement_increment = STARBackend.dispensing_drive_vol_to_increment( + abs(relative_vol_movement) + ) + speed_increment = STARBackend.dispensing_drive_vol_to_increment(flow_rate) + acceleration_increment = STARBackend.dispensing_drive_vol_to_increment(acceleration) + acceleration_increment_thousands = round(acceleration_increment * 0.001) + + await self.send_command( + module=STARBackend.channel_id(channel_idx), + command="DS", + ds=f"{relative_vol_movement_increment:05}", + dt="0" if relative_vol_movement >= 0 else "1", + dv=f"{speed_increment:05}", + dr=f"{acceleration_increment_thousands:03}", + dw=f"{current_limit}", + ) + + @action(auto_prefix=True, description="排空单个吸头中的液体。") + async def empty_tip( + self, + channel_idx: int, + vol: Optional[float] = None, + flow_rate: float = 200.0, # vol/sec + acceleration: float = 3000.0, # vol/sec**2, + current_limit: int = 5, + reset_dispensing_drive_after: bool = True, + ): + _unilab_logger.debug("[UNILAB] STARBackend.empty_tip() called") + """Empty tip by moving to `vol` (default bottom limit), optionally returning plunger position to 0. + + Args: + channel_idx: Index of the channel to empty (0-indexed). + vol: Target volume position to move the dispensing drive piston to (uL). If None, defaults to bottom limit. + flow_rate: Speed of the movement (uL/sec). Default is 200.0 uL/sec. + acceleration: Acceleration of the movement (uL/sec**2). Default is 3000.0 uL/sec**2. + current_limit: Current limit for the drive (1-7). Default is 5. + reset_dispensing_drive_after: Whether to return the dispensing drive to 0 after emptying. Default is True + """ + + if vol is None: + vol = self.DISPENSING_DRIVE_VOL_LIMIT_BOTTOM + + # Empty tip + await self.channel_dispensing_drive_move_to_volume_position( + channel_idx=channel_idx, + vol=vol, + flow_rate=flow_rate, + acceleration=acceleration, + current_limit=current_limit, + ) + + if reset_dispensing_drive_after: + # Reset only channel used back to vol=0.0 position + await self.channel_dispensing_drive_move_to_volume_position( + channel_idx=channel_idx, + vol=0, + flow_rate=flow_rate, + acceleration=acceleration, + current_limit=current_limit, + ) + + @action(auto_prefix=True, description="排空多个吸头中的液体。") + async def empty_tips( + self, + channels: Optional[List[int]] = None, + vol: Optional[float] = None, + flow_rate: float = 200.0, # vol/sec + acceleration: float = 3000.0, # vol/sec**2, + current_limit: int = 5, + reset_dispensing_drive_after: bool = True, + ): + _unilab_logger.debug("[UNILAB] STARBackend.empty_tips() called") + """Empty multiple tips by moving to `vol` (default bottom limit), optionally returning plunger position to 0. + + Args: + channels: List of channel indices to empty (0-indexed). If None, all channels with tips mounted are emptied. + vol: Target volume position to move the dispensing drive piston to (uL). If None, defaults to bottom limit. + flow_rate: Speed of the movement (uL/sec). Default is 200.0 uL/sec. + acceleration: Acceleration of the movement (uL/sec**2). Default is 3000.0 uL/sec**2. + current_limit: Current limit for the drive (1-7). Default is 5. + reset_dispensing_drive_after: Whether to return the dispensing drive to 0 after emptying. Default is True + """ + + if channels is None: + channel_occupancy = await self.request_tip_presence() + channels = [ch for ch, occupied in enumerate(channel_occupancy) if occupied] + else: + # Validate that all provided channels are within valid range + if not all(0 <= ch < self.num_channels for ch in channels): + raise ValueError(f"channel_idx must be between 0 and {self.num_channels-1}, got {channels}") + + await asyncio.gather( + *[ + self.empty_tip( + channel_idx=ch, + vol=vol, + flow_rate=flow_rate, + acceleration=acceleration, + current_limit=current_limit, + reset_dispensing_drive_after=reset_dispensing_drive_after, + ) + for ch in channels + ] + ) + + # # # Channel Liquid Handling Commands # # # + + @action(auto_prefix=True, description="执行通道吸液。") + async def aspirate( + self, + ops: List[SingleChannelAspiration], + use_channels: List[int], + jet: Optional[List[bool]] = None, + blow_out: Optional[List[bool]] = None, + lld_search_height: Optional[List[float]] = None, + clot_detection_height: Optional[List[float]] = None, + pull_out_distance_transport_air: Optional[List[float]] = None, + second_section_height: Optional[List[float]] = None, + second_section_ratio: Optional[List[float]] = None, + minimum_height: Optional[List[float]] = None, + immersion_depth: Optional[List[float]] = None, + surface_following_distance: Optional[List[float]] = None, + transport_air_volume: Optional[List[float]] = None, + pre_wetting_volume: Optional[List[float]] = None, + lld_mode: Optional[List[LLDMode]] = None, + gamma_lld_sensitivity: Optional[List[int]] = None, + dp_lld_sensitivity: Optional[List[int]] = None, + aspirate_position_above_z_touch_off: Optional[List[float]] = None, + detection_height_difference_for_dual_lld: Optional[List[float]] = None, + swap_speed: Optional[List[float]] = None, + settling_time: Optional[List[float]] = None, + mix_position_from_liquid_surface: Optional[List[float]] = None, + mix_surface_following_distance: Optional[List[float]] = None, + limit_curve_index: Optional[List[int]] = None, + use_2nd_section_aspiration: Optional[List[bool]] = None, + retract_height_over_2nd_section_to_empty_tip: Optional[List[float]] = None, + dispensation_speed_during_emptying_tip: Optional[List[float]] = None, + dosing_drive_speed_during_2nd_section_search: Optional[List[float]] = None, + z_drive_speed_during_2nd_section_search: Optional[List[float]] = None, + cup_upper_edge: Optional[List[float]] = None, + ratio_liquid_rise_to_tip_deep_in: Optional[List[int]] = None, + immersion_depth_2nd_section: Optional[List[float]] = None, + minimum_traverse_height_at_beginning_of_a_command: Optional[float] = None, + min_z_endpos: Optional[float] = None, + liquid_surface_no_lld: Optional[List[float]] = None, + # PLR: + probe_liquid_height: bool = False, + auto_surface_following_distance: bool = False, + hamilton_liquid_classes: Optional[List[Optional[HamiltonLiquidClass]]] = None, + disable_volume_correction: Optional[List[bool]] = None, + # remove >2026-01 + mix_volume: Optional[List[float]] = None, + mix_cycles: Optional[List[int]] = None, + mix_speed: Optional[List[float]] = None, + immersion_depth_direction: Optional[List[int]] = None, + liquid_surfaces_no_lld: Optional[List[float]] = None, + ): + _unilab_logger.debug("[UNILAB] STARBackend.aspirate() called") + """Aspirate liquid from the specified channels. + + For all parameters where `None` is the default value, STAR will use the default value, based on + the aspirations. For all list parameters, the length of the list must be equal to the number of + operations. + + Args: + ops: The aspiration operations to perform. + use_channels: The channels to use for the operations. + jet: whether to search for a jet liquid class. Only used on dispense. Default is False. + blow_out: whether to blow out air. Only used on dispense. Note that in the VENUS Liquid + Editor, this is called "empty". Default is False. + + lld_search_height: The height to start searching for the liquid level when using LLD. + clot_detection_height: Unknown, but probably the height to search for clots when doing LLD. + pull_out_distance_transport_air: The distance to pull out when aspirating air, if LLD is + disabled. + second_section_height: The height to start the second section of aspiration. + second_section_ratio: + minimum_height: The minimum height to move to, this is the end of aspiration. The channel will move linearly from the liquid surface to this height over the course of the aspiration. + immersion_depth: The z distance to move after detecting the liquid, can be into or away from the liquid surface. + surface_following_distance: The distance to follow the liquid surface. + transport_air_volume: The volume of air to aspirate after the liquid. + pre_wetting_volume: The volume of liquid to use for pre-wetting. + lld_mode: The liquid level detection mode to use. + gamma_lld_sensitivity: The sensitivity of the gamma LLD. + dp_lld_sensitivity: The sensitivity of the DP LLD. + aspirate_position_above_z_touch_off: If the LLD mode is Z_TOUCH_OFF, this is the height above the bottom of the well (presumably) to aspirate from. + detection_height_difference_for_dual_lld: Difference between the gamma and DP LLD heights if the LLD mode is DUAL. + swap_speed: Swap speed (on leaving liquid) [mm/s]. Must be between 3 and 1600. Default 100. + settling_time: The time to wait after mix. + mix_position_from_liquid_surface: The height to aspirate from for mix (LLD or absolute terms). + mix_surface_following_distance: The distance to follow the liquid surface for mix. + limit_curve_index: The index of the limit curve to use. + + use_2nd_section_aspiration: Whether to use the second section of aspiration. + retract_height_over_2nd_section_to_empty_tip: Unknown. + dispensation_speed_during_emptying_tip: Unknown. + dosing_drive_speed_during_2nd_section_search: Unknown. + z_drive_speed_during_2nd_section_search: Unknown. + cup_upper_edge: Unknown. + + minimum_traverse_height_at_beginning_of_a_command: The minimum height to move to before starting an aspiration. + min_z_endpos: The minimum height to move to, this is the end of aspiration. + + hamilton_liquid_classes: Override the default liquid classes. See pylabrobot/liquid_handling/liquid_classes/hamilton/STARBackend.py + liquid_surface_no_lld: Liquid surface at function without LLD [mm]. Must be between 0 and 360. Defaults to well bottom + liquid height. Should use absolute z. + disable_volume_correction: Whether to disable liquid class volume correction for each operation. + + probe_liquid_height: PLR-specific parameter. If True, probe the liquid height using cLLD before aspirating to set the liquid_height of every operation instead of using the default 0. Liquid heights must not be set when using this function. + auto_surface_following_distance: automatically compute the surface following distance based on the container height<->volume functions. Requires liquid height to be specified or `probe_liquid_height=True`. + """ + + # # # TODO: delete > 2026-01 # # # + if mix_volume is not None or mix_cycles is not None or mix_speed is not None: + raise NotImplementedError( + "Mixing through backend kwargs is deprecated. Use the `mix` parameter of LiquidHandler.aspirate instead. " + "https://docs.pylabrobot.org/user_guide/00_liquid-handling/mixing.html" + ) + + if immersion_depth_direction is not None: + warnings.warn( + "The immersion_depth_direction parameter is deprecated and will be removed in the future. " + "Use positive values for immersion_depth to move into the liquid, and negative values to move " + "out of the liquid.", + DeprecationWarning, + ) + + if liquid_surfaces_no_lld is not None: + warnings.warn( + "The liquid_surfaces_no_lld parameter is deprecated and will be removed in the future. " + "Use liquid_surface_no_lld instead.", + DeprecationWarning, + ) + liquid_surface_no_lld = liquid_surface_no_lld or liquid_surfaces_no_lld + # # # delete # # # + + self.ensure_can_reach_position(use_channels, ops, "aspirate") + + x_positions, y_positions, channels_involved = self._ops_to_fw_positions(ops, use_channels) + + n = len(ops) + + if jet is None: + jet = [False] * n + if blow_out is None: + blow_out = [False] * n + + if hamilton_liquid_classes is None: + hamilton_liquid_classes = [] + for i, op in enumerate(ops): + hamilton_liquid_classes.append( + get_star_liquid_class( + tip_volume=op.tip.maximal_volume, + is_core=False, + is_tip=True, + has_filter=op.tip.has_filter, + liquid=Liquid.WATER, # default to WATER + jet=jet[i], + blow_out=blow_out[i], + ) + ) + + # correct volumes using the liquid class + disable_volume_correction = fill_in_defaults(disable_volume_correction, [False] * n) + volumes = [ + hlc.compute_corrected_volume(op.volume) if hlc is not None and not disabled else op.volume + for op, hlc, disabled in zip(ops, hamilton_liquid_classes, disable_volume_correction) + ] + + well_bottoms = [ + op.resource.get_location_wrt(self.deck).z + op.offset.z + op.resource.material_z_thickness + for op in ops + ] + if lld_search_height is None: + lld_search_height = [ + ( + wb + op.resource.get_absolute_size_z() + (2.7 if isinstance(op.resource, Well) else 5) + ) # ? + for wb, op in zip(well_bottoms, ops) + ] + else: + lld_search_height = [(wb + sh) for wb, sh in zip(well_bottoms, lld_search_height)] + clot_detection_height = fill_in_defaults( + clot_detection_height, + default=[ + hlc.aspiration_clot_retract_height if hlc is not None else 0.0 + for hlc in hamilton_liquid_classes + ], + ) + pull_out_distance_transport_air = fill_in_defaults(pull_out_distance_transport_air, [10] * n) + second_section_height = fill_in_defaults(second_section_height, [3.2] * n) + second_section_ratio = fill_in_defaults(second_section_ratio, [618.0] * n) + minimum_height = fill_in_defaults(minimum_height, well_bottoms) + if immersion_depth is None: + immersion_depth = [0.0] * n + immersion_depth_direction = immersion_depth_direction or [ + 0 if (id_ >= 0) else 1 for id_ in immersion_depth + ] + immersion_depth = [ + im * (-1 if immersion_depth_direction[i] else 1) for i, im in enumerate(immersion_depth) + ] + flow_rates = [ + op.flow_rate or (hlc.aspiration_flow_rate if hlc is not None else 100.0) + for op, hlc in zip(ops, hamilton_liquid_classes) + ] + transport_air_volume = fill_in_defaults( + transport_air_volume, + default=[ + hlc.aspiration_air_transport_volume if hlc is not None else 0.0 + for hlc in hamilton_liquid_classes + ], + ) + blow_out_air_volumes = [ + (op.blow_out_air_volume or (hlc.aspiration_blow_out_volume if hlc is not None else 0.0)) + for op, hlc in zip(ops, hamilton_liquid_classes) + ] + pre_wetting_volume = fill_in_defaults(pre_wetting_volume, [0.0] * n) + lld_mode = fill_in_defaults(lld_mode, [self.__class__.LLDMode.OFF] * n) + gamma_lld_sensitivity = fill_in_defaults(gamma_lld_sensitivity, [1] * n) + dp_lld_sensitivity = fill_in_defaults(dp_lld_sensitivity, [1] * n) + aspirate_position_above_z_touch_off = fill_in_defaults( + aspirate_position_above_z_touch_off, [0.0] * n + ) + detection_height_difference_for_dual_lld = fill_in_defaults( + detection_height_difference_for_dual_lld, [0.0] * n + ) + swap_speed = fill_in_defaults( + swap_speed, + default=[ + hlc.aspiration_swap_speed if hlc is not None else 100.0 for hlc in hamilton_liquid_classes + ], + ) + settling_time = fill_in_defaults( + settling_time, + default=[ + hlc.aspiration_settling_time if hlc is not None else 0.0 for hlc in hamilton_liquid_classes + ], + ) + mix_volume = [op.mix.volume if op.mix is not None else 0.0 for op in ops] + mix_cycles = [op.mix.repetitions if op.mix is not None else 0 for op in ops] + mix_position_from_liquid_surface = fill_in_defaults(mix_position_from_liquid_surface, [0.0] * n) + mix_speed = [op.mix.flow_rate if op.mix is not None else 100.0 for op in ops] + mix_surface_following_distance = fill_in_defaults(mix_surface_following_distance, [0.0] * n) + limit_curve_index = fill_in_defaults(limit_curve_index, [0] * n) + + use_2nd_section_aspiration = fill_in_defaults(use_2nd_section_aspiration, [False] * n) + retract_height_over_2nd_section_to_empty_tip = fill_in_defaults( + retract_height_over_2nd_section_to_empty_tip, [0.0] * n + ) + dispensation_speed_during_emptying_tip = fill_in_defaults( + dispensation_speed_during_emptying_tip, [50.0] * n + ) + dosing_drive_speed_during_2nd_section_search = fill_in_defaults( + dosing_drive_speed_during_2nd_section_search, [50.0] * n + ) + z_drive_speed_during_2nd_section_search = fill_in_defaults( + z_drive_speed_during_2nd_section_search, [30.0] * n + ) + cup_upper_edge = fill_in_defaults(cup_upper_edge, [0.0] * n) + + # Deprecated params - warn if passed, but don't use them + if ratio_liquid_rise_to_tip_deep_in is not None: + warnings.warn( + "ratio_liquid_rise_to_tip_deep_in is deprecated and will be removed in a future version.", + DeprecationWarning, + stacklevel=2, + ) + if immersion_depth_2nd_section is not None: + warnings.warn( + "immersion_depth_2nd_section is deprecated and will be removed in a future version.", + DeprecationWarning, + stacklevel=2, + ) + + if probe_liquid_height: + if any(op.liquid_height is not None for op in ops): + raise ValueError("Cannot use probe_liquid_height when liquid heights are set.") + + liquid_heights = await self.probe_liquid_heights( + containers=[op.resource for op in ops], + use_channels=use_channels, + resource_offsets=[op.offset for op in ops], + move_to_z_safety_after=False, + ) + + # override minimum traversal height because we don't want to move channels up. we are already above the liquid. + minimum_traverse_height_at_beginning_of_a_command = 100 + logger.info(f"Detected liquid heights: {liquid_heights}") + else: + liquid_heights = [op.liquid_height or 0 for op in ops] + + liquid_surfaces_no_lld = liquid_surface_no_lld or [ + wb + lh for wb, lh in zip(well_bottoms, liquid_heights) + ] + + if auto_surface_following_distance: + if any(op.liquid_height is None for op in ops) and not probe_liquid_height: + raise ValueError( + "To use auto_surface_following_distance all liquid heights must be set or probe_liquid_height must be True." + ) + + if any(not op.resource.supports_compute_height_volume_functions() for op in ops): + raise ValueError( + "automatic_surface_following can only be used with containers that support height<->volume functions." + ) + + current_volumes = [ + op.resource.compute_volume_from_height(liquid_heights[i]) for i, op in enumerate(ops) + ] + + # compute new liquid_height after aspiration + liquid_height_after_aspiration = [ + op.resource.compute_height_from_volume(current_volumes[i] - op.volume) + for i, op in enumerate(ops) + ] + + # compute new surface_following_distance + surface_following_distance = [ + liquid_heights[i] - liquid_height_after_aspiration[i] + for i in range(len(liquid_height_after_aspiration)) + ] + else: + surface_following_distance = fill_in_defaults(surface_following_distance, [0.0] * n) + + # check if the surface_following_distance would fall below the minimum height + # if lld is enabled, we expect to find liquid above the well bottom so we don't need to raise an error + if any( + ( + well_bottoms[i] + liquid_heights[i] - surface_following_distance[i] - minimum_height[i] + < -1e-6 + ) + and lld_mode[i] == STARBackend.LLDMode.OFF + for i in range(n) + ): + raise ValueError( + f"surface_following_distance would result in a height that goes below the minimum_height. " + f"Well bottom: {well_bottoms}, liquid height: {liquid_heights}, surface_following_distance: {surface_following_distance}, minimum_height: {minimum_height}" + ) + + try: + return await self.aspirate_pip( + aspiration_type=[0 for _ in range(n)], + tip_pattern=channels_involved, + x_positions=x_positions, + y_positions=y_positions, + aspiration_volumes=[round(vol * 10) for vol in volumes], + lld_search_height=[round(lsh * 10) for lsh in lld_search_height], + clot_detection_height=[round(cd * 10) for cd in clot_detection_height], + liquid_surface_no_lld=[round(ls * 10) for ls in liquid_surfaces_no_lld], + pull_out_distance_transport_air=[round(po * 10) for po in pull_out_distance_transport_air], + second_section_height=[round(sh * 10) for sh in second_section_height], + second_section_ratio=[round(sr * 10) for sr in second_section_ratio], + minimum_height=[round(mh * 10) for mh in minimum_height], + immersion_depth=[round(id_ * 10) for id_ in immersion_depth], + immersion_depth_direction=immersion_depth_direction, + surface_following_distance=[round(sfd * 10) for sfd in surface_following_distance], + aspiration_speed=[round(fr * 10) for fr in flow_rates], + transport_air_volume=[round(tav * 10) for tav in transport_air_volume], + blow_out_air_volume=[round(boa * 10) for boa in blow_out_air_volumes], + pre_wetting_volume=[round(pwv * 10) for pwv in pre_wetting_volume], + lld_mode=[mode.value for mode in lld_mode], + gamma_lld_sensitivity=gamma_lld_sensitivity, + dp_lld_sensitivity=dp_lld_sensitivity, + aspirate_position_above_z_touch_off=[ + round(ap * 10) for ap in aspirate_position_above_z_touch_off + ], + detection_height_difference_for_dual_lld=[ + round(dh * 10) for dh in detection_height_difference_for_dual_lld + ], + swap_speed=[round(ss * 10) for ss in swap_speed], + settling_time=[round(st * 10) for st in settling_time], + mix_volume=[round(hv * 10) for hv in mix_volume], + mix_cycles=mix_cycles, + mix_position_from_liquid_surface=[ + round(hp * 10) for hp in mix_position_from_liquid_surface + ], + mix_speed=[round(hs * 10) for hs in mix_speed], + mix_surface_following_distance=[round(hsd * 10) for hsd in mix_surface_following_distance], + limit_curve_index=limit_curve_index, + use_2nd_section_aspiration=use_2nd_section_aspiration, + retract_height_over_2nd_section_to_empty_tip=[ + round(rh * 10) for rh in retract_height_over_2nd_section_to_empty_tip + ], + dispensation_speed_during_emptying_tip=[ + round(ds * 10) for ds in dispensation_speed_during_emptying_tip + ], + dosing_drive_speed_during_2nd_section_search=[ + round(ds * 10) for ds in dosing_drive_speed_during_2nd_section_search + ], + z_drive_speed_during_2nd_section_search=[ + round(zs * 10) for zs in z_drive_speed_during_2nd_section_search + ], + cup_upper_edge=[round(cue * 10) for cue in cup_upper_edge], + minimum_traverse_height_at_beginning_of_a_command=round( + (minimum_traverse_height_at_beginning_of_a_command or self._channel_traversal_height) * 10 + ), + min_z_endpos=round((min_z_endpos or self._channel_traversal_height) * 10), + ) + except STARFirmwareError as e: + if plr_e := convert_star_firmware_error_to_plr_error(e): + raise plr_e from e + raise e + + @action(auto_prefix=True, description="执行通道分液。") + async def dispense( + self, + ops: List[SingleChannelDispense], + use_channels: List[int], + lld_search_height: Optional[List[float]] = None, + liquid_surface_no_lld: Optional[List[float]] = None, + pull_out_distance_transport_air: Optional[List[float]] = None, + second_section_height: Optional[List[float]] = None, + second_section_ratio: Optional[List[float]] = None, + minimum_height: Optional[List[float]] = None, + immersion_depth: Optional[List[float]] = None, + surface_following_distance: Optional[List[float]] = None, + cut_off_speed: Optional[List[float]] = None, + stop_back_volume: Optional[List[float]] = None, + transport_air_volume: Optional[List[float]] = None, + lld_mode: Optional[List[LLDMode]] = None, + dispense_position_above_z_touch_off: Optional[List[float]] = None, + gamma_lld_sensitivity: Optional[List[int]] = None, + dp_lld_sensitivity: Optional[List[int]] = None, + swap_speed: Optional[List[float]] = None, + settling_time: Optional[List[float]] = None, + mix_position_from_liquid_surface: Optional[List[float]] = None, + mix_surface_following_distance: Optional[List[float]] = None, + limit_curve_index: Optional[List[int]] = None, + minimum_traverse_height_at_beginning_of_a_command: Optional[int] = None, + min_z_endpos: Optional[float] = None, + side_touch_off_distance: float = 0, + jet: Optional[List[bool]] = None, + blow_out: Optional[List[bool]] = None, # "empty" in the VENUS liquid editor + empty: Optional[List[bool]] = None, # truly "empty", does not exist in liquid editor, dm4 + # PLR specific + probe_liquid_height: bool = False, + auto_surface_following_distance: bool = False, + hamilton_liquid_classes: Optional[List[Optional[HamiltonLiquidClass]]] = None, + disable_volume_correction: Optional[List[bool]] = None, + # remove in the future + immersion_depth_direction: Optional[List[int]] = None, + mix_volume: Optional[List[float]] = None, + mix_cycles: Optional[List[int]] = None, + mix_speed: Optional[List[float]] = None, + dispensing_mode: Optional[List[int]] = None, + ): + _unilab_logger.debug("[UNILAB] STARBackend.dispense() called") + """Dispense liquid from the specified channels. + + For all parameters where `None` is the default value, STAR will use the default value, based on + the dispenses. For all list parameters, the length of the list must be equal to the number of + operations. + + Args: + ops: The dispense operations to perform. + use_channels: The channels to use for the dispense operations. + lld_search_height: The height to start searching for the liquid level when using LLD. + liquid_surface_no_lld: Liquid surface at function without LLD. + pull_out_distance_transport_air: The distance to pull out the tip for aspirating transport air if LLD is disabled. + second_section_height: The height of the second section. + second_section_ratio: The ratio of [the bottom of the container * 10000] / [the height top of the container]. + minimum_height: The minimum height at the end of the dispense. + immersion_depth: The distance above or below to liquid level to start dispensing. + surface_following_distance: The distance to follow the liquid surface. + cut_off_speed: Unknown. + stop_back_volume: Unknown. + transport_air_volume: The volume of air to dispense before dispensing the liquid. + lld_mode: The liquid level detection mode to use. + dispense_position_above_z_touch_off: The height to move after LLD mode found the Z touch off + position. + gamma_lld_sensitivity: The gamma LLD sensitivity. (1 = high, 4 = low) + dp_lld_sensitivity: The dp LLD sensitivity. (1 = high, 4 = low) + swap_speed: Swap speed (on leaving liquid) [mm/s]. Must be between 3 and 1600. Default 100. + settling_time: The settling time. + mix_position_from_liquid_surface: The height to move above the liquid surface for + mix. + mix_surface_following_distance: The distance to follow the liquid surface for mix. + limit_curve_index: The limit curve to use for the dispense. + minimum_traverse_height_at_beginning_of_a_command: The minimum height to move to before + starting a dispense. + min_z_endpos: The minimum height to move to after a dispense. + side_touch_off_distance: The distance to move to the side from the well for a dispense. + + hamilton_liquid_classes: Override the default liquid classes. See + pylabrobot/liquid_handling/liquid_classes/hamilton/STARBackend.py + disable_volume_correction: Whether to disable liquid class volume correction for each operation. + + jet: Whether to use jetting for each dispense. Defaults to `False` for all. Used for + determining the dispense mode. True for dispense mode 0 or 1. + blow_out: Whether to use "blow out" dispense mode for each dispense. Defaults to `False` for + all. This is labelled as "empty" in the VENUS liquid editor, but "blow out" in the firmware + documentation. True for dispense mode 1 or 3. + empty: Whether to use "empty" dispense mode for each dispense. Defaults to `False` for all. + Truly empty the tip, not available in the VENUS liquid editor, but is in the firmware + documentation. Dispense mode 4. + + probe_liquid_height: PLR-specific parameter. If True, probe the liquid height using cLLD before aspirating to set the liquid_height of every operation instead of using the default 0. Liquid heights must not be set when using this function. + auto_surface_following_distance: automatically compute the surface following distance based on the container height<->volume functions. Requires liquid height to be specified or `probe_liquid_height=True`. + """ + + self.ensure_can_reach_position(use_channels, ops, "dispense") + + n = len(ops) + + if jet is None: + jet = [False] * n + if empty is None: + empty = [False] * n + if blow_out is None: + blow_out = [False] * n + + # # # TODO: delete > 2026-01 # # # + if mix_volume is not None or mix_cycles is not None or mix_speed is not None: + raise NotImplementedError( + "Mixing through backend kwargs is deprecated. Use the `mix` parameter of LiquidHandler.dispense instead. " + "https://docs.pylabrobot.org/user_guide/00_liquid-handling/mixing.html" + ) + + if immersion_depth_direction is not None: + warnings.warn( + "The immersion_depth_direction parameter is deprecated and will be removed in the future. " + "Use positive values for immersion_depth to move into the liquid, and negative values to move " + "out of the liquid.", + DeprecationWarning, + ) + + if dispensing_mode is not None: + warnings.warn( + "The dispensing_mode parameter is deprecated and will be removed in the future. " + "Use the jet, blow_out and empty parameters instead. " + "dispensing_mode currently supersedes the other three parameters if both are provided.", + DeprecationWarning, + ) + dispensing_modes = dispensing_mode + else: + dispensing_modes = [ + _dispensing_mode_for_op(empty=empty[i], jet=jet[i], blow_out=blow_out[i]) + for i in range(len(ops)) + ] + # # # delete # # # + + x_positions, y_positions, channels_involved = self._ops_to_fw_positions(ops, use_channels) + + if hamilton_liquid_classes is None: + hamilton_liquid_classes = [] + for i, op in enumerate(ops): + hamilton_liquid_classes.append( + get_star_liquid_class( + tip_volume=op.tip.maximal_volume, + is_core=False, + is_tip=True, + has_filter=op.tip.has_filter, + liquid=Liquid.WATER, # default to WATER + jet=jet[i], + blow_out=blow_out[i], + ) + ) + + # correct volumes using the liquid class + disable_volume_correction = fill_in_defaults(disable_volume_correction, [False] * n) + volumes = [ + hlc.compute_corrected_volume(op.volume) if hlc is not None and not disabled else op.volume + for op, hlc, disabled in zip(ops, hamilton_liquid_classes, disable_volume_correction) + ] + + well_bottoms = [ + op.resource.get_location_wrt(self.deck).z + op.offset.z + op.resource.material_z_thickness + for op in ops + ] + if lld_search_height is None: + lld_search_height = [ + ( + wb + op.resource.get_absolute_size_z() + (2.7 if isinstance(op.resource, Well) else 5) + ) # ? + for wb, op in zip(well_bottoms, ops) + ] + else: + lld_search_height = [wb + sh for wb, sh in zip(well_bottoms, lld_search_height)] + + pull_out_distance_transport_air = fill_in_defaults(pull_out_distance_transport_air, [10.0] * n) + second_section_height = fill_in_defaults(second_section_height, [3.2] * n) + second_section_ratio = fill_in_defaults(second_section_ratio, [618.0] * n) + minimum_height = fill_in_defaults(minimum_height, well_bottoms) + if immersion_depth is None: + immersion_depth = [0.0] * n + immersion_depth_direction = immersion_depth_direction or [ + 0 if (id_ >= 0) else 1 for id_ in immersion_depth + ] + immersion_depth = [ + im * (-1 if immersion_depth_direction[i] else 1) for i, im in enumerate(immersion_depth) + ] + flow_rates = [ + op.flow_rate or (hlc.dispense_flow_rate if hlc is not None else 120.0) + for op, hlc in zip(ops, hamilton_liquid_classes) + ] + cut_off_speed = fill_in_defaults(cut_off_speed, [5.0] * n) + stop_back_volume = fill_in_defaults( + stop_back_volume, + default=[ + hlc.dispense_stop_back_volume if hlc is not None else 0.0 for hlc in hamilton_liquid_classes + ], + ) + transport_air_volume = fill_in_defaults( + transport_air_volume, + default=[ + hlc.dispense_air_transport_volume if hlc is not None else 0.0 + for hlc in hamilton_liquid_classes + ], + ) + blow_out_air_volumes = [ + (op.blow_out_air_volume or (hlc.dispense_blow_out_volume if hlc is not None else 0.0)) + for op, hlc in zip(ops, hamilton_liquid_classes) + ] + lld_mode = fill_in_defaults(lld_mode, [self.__class__.LLDMode.OFF] * n) + dispense_position_above_z_touch_off = fill_in_defaults( + dispense_position_above_z_touch_off, default=[0] * n + ) + gamma_lld_sensitivity = fill_in_defaults(gamma_lld_sensitivity, [1] * n) + dp_lld_sensitivity = fill_in_defaults(dp_lld_sensitivity, [1] * n) + swap_speed = fill_in_defaults( + swap_speed, + default=[ + hlc.dispense_swap_speed if hlc is not None else 10.0 for hlc in hamilton_liquid_classes + ], + ) + settling_time = fill_in_defaults( + settling_time, + default=[ + hlc.dispense_settling_time if hlc is not None else 0.0 for hlc in hamilton_liquid_classes + ], + ) + mix_volume = [op.mix.volume if op.mix is not None else 0.0 for op in ops] + mix_cycles = [op.mix.repetitions if op.mix is not None else 0 for op in ops] + mix_position_from_liquid_surface = fill_in_defaults(mix_position_from_liquid_surface, [0.0] * n) + mix_speed = [op.mix.flow_rate if op.mix is not None else 1.0 for op in ops] + mix_surface_following_distance = fill_in_defaults(mix_surface_following_distance, [0.0] * n) + limit_curve_index = fill_in_defaults(limit_curve_index, [0] * n) + + if probe_liquid_height: + if any(op.liquid_height is not None for op in ops): + raise ValueError("Cannot use probe_liquid_height when liquid heights are set.") + + liquid_heights = await self.probe_liquid_heights( + containers=[op.resource for op in ops], + use_channels=use_channels, + resource_offsets=[op.offset for op in ops], + move_to_z_safety_after=False, + ) + + # override minimum traversal height because we don't want to move channels up. we are already above the liquid. + minimum_traverse_height_at_beginning_of_a_command = 100 + logger.info(f"Detected liquid heights: {liquid_heights}") + else: + liquid_heights = [op.liquid_height or 0 for op in ops] + + if auto_surface_following_distance: + if any(op.liquid_height is None for op in ops) and not probe_liquid_height: + raise ValueError( + "To use auto_surface_following_distance all liquid heights must be set or probe_liquid_height must be True." + ) + + if any(not op.resource.supports_compute_height_volume_functions() for op in ops): + raise ValueError( + "automatic_surface_following can only be used with containers that support height<->volume functions." + ) + + current_volumes = [ + op.resource.compute_volume_from_height(liquid_heights[i]) for i, op in enumerate(ops) + ] + + # compute new liquid_height after aspiration + liquid_height_after_aspiration = [ + op.resource.compute_height_from_volume(current_volumes[i] + op.volume) + for i, op in enumerate(ops) + ] + + # compute new surface_following_distance + surface_following_distance = [ + liquid_height_after_aspiration[i] - liquid_heights[i] + for i in range(len(liquid_height_after_aspiration)) + ] + else: + surface_following_distance = fill_in_defaults(surface_following_distance, [0.0] * n) + + liquid_surfaces_no_lld = liquid_surface_no_lld or [ + wb + lh for wb, lh in zip(well_bottoms, liquid_heights) + ] + + try: + ret = await self.dispense_pip( + tip_pattern=channels_involved, + x_positions=x_positions, + y_positions=y_positions, + dispensing_mode=dispensing_modes, + dispense_volumes=[round(vol * 10) for vol in volumes], + lld_search_height=[round(lsh * 10) for lsh in lld_search_height], + liquid_surface_no_lld=[round(ls * 10) for ls in liquid_surfaces_no_lld], + pull_out_distance_transport_air=[round(po * 10) for po in pull_out_distance_transport_air], + second_section_height=[round(sh * 10) for sh in second_section_height], + second_section_ratio=[round(sr * 10) for sr in second_section_ratio], + minimum_height=[round(mh * 10) for mh in minimum_height], + immersion_depth=[round(id_ * 10) for id_ in immersion_depth], + immersion_depth_direction=immersion_depth_direction, + surface_following_distance=[round(sfd * 10) for sfd in surface_following_distance], + dispense_speed=[round(fr * 10) for fr in flow_rates], + cut_off_speed=[round(cs * 10) for cs in cut_off_speed], + stop_back_volume=[round(sbv * 10) for sbv in stop_back_volume], + transport_air_volume=[round(tav * 10) for tav in transport_air_volume], + blow_out_air_volume=[round(boa * 10) for boa in blow_out_air_volumes], + lld_mode=[mode.value for mode in lld_mode], + dispense_position_above_z_touch_off=[ + round(dp * 10) for dp in dispense_position_above_z_touch_off + ], + gamma_lld_sensitivity=gamma_lld_sensitivity, + dp_lld_sensitivity=dp_lld_sensitivity, + swap_speed=[round(ss * 10) for ss in swap_speed], + settling_time=[round(st * 10) for st in settling_time], + mix_volume=[round(mv * 10) for mv in mix_volume], + mix_cycles=mix_cycles, + mix_position_from_liquid_surface=[ + round(mp * 10) for mp in mix_position_from_liquid_surface + ], + mix_speed=[round(ms * 10) for ms in mix_speed], + mix_surface_following_distance=[ + round(msfd * 10) for msfd in mix_surface_following_distance + ], + limit_curve_index=limit_curve_index, + minimum_traverse_height_at_beginning_of_a_command=round( + (minimum_traverse_height_at_beginning_of_a_command or self._channel_traversal_height) * 10 + ), + min_z_endpos=round((min_z_endpos or self._channel_traversal_height) * 10), + side_touch_off_distance=round(side_touch_off_distance * 10), + ) + except STARFirmwareError as e: + if plr_e := convert_star_firmware_error_to_plr_error(e): + raise plr_e from e + raise e + + return ret + + @_requires_head96 + @action(auto_prefix=True, description="用96头拾取吸头。") + async def pick_up_tips96( + self, + pickup: PickupTipRack, + tip_pickup_method: Literal["from_rack", "from_waste", "full_blowout"] = "from_rack", + minimum_height_command_end: Optional[float] = None, + minimum_traverse_height_at_beginning_of_a_command: Optional[float] = None, + experimental_alignment_tipspot_identifier: str = "A1", + ): + _unilab_logger.debug("[UNILAB] STARBackend.pick_up_tips96() called") + """Pick up tips using the 96 head. + + `tip_pickup_method` can be one of the following: + - "from_rack": standard tip pickup from a tip rack. this moves the plunger all the way down before mounting tips. + - "from_waste": + 1. it actually moves the plunger all the way up + 2. mounts tips + 3. moves up like 10mm + 4. moves plunger all the way down + 5. moves to traversal height (tips out of rack) + - "full_blowout": + 1. it actually moves the plunger all the way up + 2. mounts tips + 3. moves to traversal height (tips out of rack) + + Args: + pickup: The standard `PickupTipRack` operation. + tip_pickup_method: The method to use for picking up tips. One of "from_rack", "from_waste", "full_blowout". + minimum_height_command_end: The minimum height to move to at the end of the command. + minimum_traverse_height_at_beginning_of_a_command: The minimum height to move to at the beginning of the command. + experimental_alignment_tipspot_identifier: The tipspot to use for alignment with head's A1 channel. Defaults to "tipspot A1". allowed range is A1 to H12. + """ + + if isinstance(tip_pickup_method, int): + warnings.warn( + "tip_pickup_method as int is deprecated and will be removed in the future. Use string literals instead.", + DeprecationWarning, + ) + tip_pickup_method = {0: "from_rack", 1: "from_waste", 2: "full_blowout"}[tip_pickup_method] + + if tip_pickup_method not in {"from_rack", "from_waste", "full_blowout"}: + raise ValueError(f"Invalid tip_pickup_method: '{tip_pickup_method}'.") + + prototypical_tip = next((tip for tip in pickup.tips if tip is not None), None) + if prototypical_tip is None: + raise ValueError("No tips found in the tip rack.") + if not isinstance(prototypical_tip, HamiltonTip): + raise TypeError("Tip type must be HamiltonTip.") + + ttti = await self.get_or_assign_tip_type_index(prototypical_tip) + + tip_length = prototypical_tip.total_tip_length + fitting_depth = prototypical_tip.fitting_depth + tip_engage_height_from_tipspot = tip_length - fitting_depth + + # Adjust tip engage height based on tip size + if prototypical_tip.tip_size == TipSize.LOW_VOLUME: + tip_engage_height_from_tipspot += 2 + elif prototypical_tip.tip_size != TipSize.STANDARD_VOLUME: + tip_engage_height_from_tipspot -= 2 + + # Compute pickup Z + alignment_tipspot = pickup.resource.get_item(experimental_alignment_tipspot_identifier) + tip_spot_z = alignment_tipspot.get_location_wrt(self.deck).z + pickup.offset.z + z_pickup_position = tip_spot_z + tip_engage_height_from_tipspot + + # Compute full position (used for x/y) + pickup_position = ( + alignment_tipspot.get_location_wrt(self.deck) + alignment_tipspot.center() + pickup.offset + ) + pickup_position.z = round(z_pickup_position, 2) + + self._check_96_position_legal(pickup_position, skip_z=True) + + if tip_pickup_method == "from_rack": + # the STAR will not automatically move the dispensing drive down if it is still up + # so we need to move it down here + # see https://github.com/PyLabRobot/pylabrobot/pull/835 + lowest_dispensing_drive_height_no_tips = 218.19 + await self.head96_dispensing_drive_move_to_position(lowest_dispensing_drive_height_no_tips) + + try: + await self.pick_up_tips_core96( + x_position=abs(round(pickup_position.x * 10)), + x_direction=0 if pickup_position.x >= 0 else 1, + y_position=round(pickup_position.y * 10), + tip_type_idx=ttti, + tip_pickup_method={ + "from_rack": 0, + "from_waste": 1, + "full_blowout": 2, + }[tip_pickup_method], + z_deposit_position=round(pickup_position.z * 10), + minimum_traverse_height_at_beginning_of_a_command=round( + (minimum_traverse_height_at_beginning_of_a_command or self._channel_traversal_height) * 10 + ), + minimum_height_command_end=round( + (minimum_height_command_end or self._channel_traversal_height) * 10 + ), + ) + except STARFirmwareError as e: + if plr_e := convert_star_firmware_error_to_plr_error(e): + raise plr_e from e + raise e + + @_requires_head96 + @action(auto_prefix=True, description="用96头卸下吸头。") + async def drop_tips96( + self, + drop: DropTipRack, + minimum_height_command_end: Optional[float] = None, + minimum_traverse_height_at_beginning_of_a_command: Optional[float] = None, + experimental_alignment_tipspot_identifier: str = "A1", + ): + _unilab_logger.debug("[UNILAB] STARBackend.drop_tips96() called") + """Drop tips from the 96 head.""" + + if isinstance(drop.resource, TipRack): + tip_spot_a1 = drop.resource.get_item(experimental_alignment_tipspot_identifier) + position = tip_spot_a1.get_location_wrt(self.deck) + tip_spot_a1.center() + drop.offset + tip_rack = tip_spot_a1.parent + assert tip_rack is not None + position.z = tip_rack.get_location_wrt(self.deck).z + 1.45 + # This should be the case for all normal hamilton tip carriers + racks + # In the future, we might want to make this more flexible + assert abs(position.z - 216.4) < 1e-6, f"z position must be 216.4, got {position.z}" + else: + position = self._position_96_head_in_resource(drop.resource) + drop.offset + + self._check_96_position_legal(position, skip_z=True) + + x_direction = 0 if position.x >= 0 else 1 + + return await self.discard_tips_core96( + x_position=abs(round(position.x * 10)), + x_direction=x_direction, + y_position=round(position.y * 10), + z_deposit_position=round(position.z * 10), + minimum_traverse_height_at_beginning_of_a_command=round( + (minimum_traverse_height_at_beginning_of_a_command or self._channel_traversal_height) * 10 + ), + minimum_height_command_end=round( + (minimum_height_command_end or self._channel_traversal_height) * 10 + ), + ) + + @_requires_head96 + @action(auto_prefix=True, description="使用96头吸液。") + async def aspirate96( + self, + aspiration: Union[MultiHeadAspirationPlate, MultiHeadAspirationContainer], + jet: bool = False, + blow_out: bool = False, + use_lld: bool = False, + pull_out_distance_transport_air: float = 10, + hlc: Optional[HamiltonLiquidClass] = None, + aspiration_type: int = 0, + minimum_traverse_height_at_beginning_of_a_command: Optional[float] = None, + min_z_endpos: Optional[float] = None, + lld_search_height: float = 199.9, + minimum_height: Optional[float] = None, + second_section_height: float = 3.2, + second_section_ratio: float = 618.0, + immersion_depth: float = 0, + surface_following_distance: float = 0, + transport_air_volume: float = 5.0, + pre_wetting_volume: float = 5.0, + gamma_lld_sensitivity: int = 1, + swap_speed: float = 2.0, + settling_time: float = 1.0, + mix_position_from_liquid_surface: float = 0, + mix_surface_following_distance: float = 0, + limit_curve_index: int = 0, + disable_volume_correction: bool = False, + # Deprecated parameters, to be removed in future versions + # rm: >2026-01 + liquid_surface_sink_distance_at_the_end_of_aspiration: float = 0, + minimal_end_height: Optional[float] = None, + air_transport_retract_dist: Optional[float] = None, + maximum_immersion_depth: Optional[float] = None, + surface_following_distance_during_mix: float = 0, + tube_2nd_section_height_measured_from_zm: float = 3.2, + tube_2nd_section_ratio: float = 618.0, + immersion_depth_direction: Optional[int] = None, + mix_volume: float = 0, + mix_cycles: int = 0, + speed_of_mix: float = 0.0, + ): + _unilab_logger.debug("[UNILAB] STARBackend.aspirate96() called") + """Aspirate using the Core96 head. + + Args: + aspiration: The aspiration to perform. + + jet: Whether to search for a jet liquid class. Only used on dispense. + blow_out: Whether to use "blow out" dispense mode. Only used on dispense. Note that this is + labelled as "empty" in the VENUS liquid editor, but "blow out" in the firmware + documentation. + hlc: The Hamiltonian liquid class to use. If `None`, the liquid class will be determined + automatically. + + use_lld: If True, use gamma liquid level detection. If False, use liquid height. + pull_out_distance_transport_air: The distance to retract after aspirating, in millimeters. + + aspiration_type: The type of aspiration to perform. (0 = simple; 1 = sequence; 2 = cup emptied) + minimum_traverse_height_at_beginning_of_a_command: The minimum height to move to before + starting the command. + min_z_endpos: The minimum height to move to after the command. + lld_search_height: The height to search for the liquid level. + minimum_height: Minimum height (maximum immersion depth) + second_section_height: Height of the second section. + second_section_ratio: Ratio of [the diameter of the bottom * 10000] / [the diameter of the top] + immersion_depth: The immersion depth above or below the liquid level. + surface_following_distance: The distance to follow the liquid surface when aspirating. + transport_air_volume: The volume of air to aspirate after the liquid. + pre_wetting_volume: The volume of liquid to use for pre-wetting. + gamma_lld_sensitivity: The sensitivity of the gamma liquid level detection. + swap_speed: Swap speed (on leaving liquid) [1mm/s]. Must be between 0.3 and 160. Default 2. + settling_time: The time to wait after aspirating. + mix_position_from_liquid_surface: The position of the mix from the liquid surface. + mix_surface_following_distance: The distance to follow the liquid surface during mix. + limit_curve_index: The index of the limit curve to use. + disable_volume_correction: Whether to disable liquid class volume correction. + """ + + # # # TODO: delete > 2026-01 # # # + if mix_volume != 0 or mix_cycles != 0 or speed_of_mix != 0: + raise NotImplementedError( + "Mixing through backend kwargs is deprecated. Use the `mix` parameter of LiquidHandler.aspirate96 instead. " + "https://docs.pylabrobot.org/user_guide/00_liquid-handling/mixing.html" + ) + + if immersion_depth_direction is not None: + warnings.warn( + "The immersion_depth_direction parameter is deprecated and will be removed in the future. " + "Use positive values for immersion_depth to move into the liquid, and negative values to move " + "out of the liquid.", + DeprecationWarning, + ) + + if liquid_surface_sink_distance_at_the_end_of_aspiration != 0: + surface_following_distance = liquid_surface_sink_distance_at_the_end_of_aspiration + warnings.warn( + "The liquid_surface_sink_distance_at_the_end_of_aspiration parameter is deprecated and will be removed in the future. " + "Use the Hamilton-standard surface_following_distance parameter instead.\n" + "liquid_surface_sink_distance_at_the_end_of_aspiration currently superseding surface_following_distance.", + DeprecationWarning, + ) + + if minimal_end_height is not None: + min_z_endpos = minimal_end_height + warnings.warn( + "The minimal_end_height parameter is deprecated and will be removed in the future. " + "Use the Hamilton-standard min_z_endpos parameter instead.\n" + "min_z_endpos currently superseding minimal_end_height.", + DeprecationWarning, + ) + + if air_transport_retract_dist is not None: + pull_out_distance_transport_air = air_transport_retract_dist + warnings.warn( + "The air_transport_retract_dist parameter is deprecated and will be removed in the future. " + "Use the Hamilton-standard pull_out_distance_transport_air parameter instead.\n" + "pull_out_distance_transport_air currently superseding air_transport_retract_dist.", + DeprecationWarning, + ) + + if maximum_immersion_depth is not None: + minimum_height = maximum_immersion_depth + warnings.warn( + "The maximum_immersion_depth parameter is deprecated and will be removed in the future. " + "Use the Hamilton-standard minimum_height parameter instead.\n" + "minimum_height currently superseding maximum_immersion_depth.", + DeprecationWarning, + ) + + if surface_following_distance_during_mix != 0: + mix_surface_following_distance = surface_following_distance_during_mix + warnings.warn( + "The surface_following_distance_during_mix parameter is deprecated and will be removed in the future. " + "Use the Hamilton-standard mix_surface_following_distance parameter instead.\n" + "mix_surface_following_distance currently superseding surface_following_distance_during_mix.", + DeprecationWarning, + ) + + if tube_2nd_section_height_measured_from_zm != 3.2: + second_section_height = tube_2nd_section_height_measured_from_zm + warnings.warn( + "The tube_2nd_section_height_measured_from_zm parameter is deprecated and will be removed in the future. " + "Use the Hamilton-standard second_section_height parameter instead.\n" + "second_section_height_measured_from_zm currently superseding second_section_height.", + DeprecationWarning, + ) + + if tube_2nd_section_ratio != 618.0: + second_section_ratio = tube_2nd_section_ratio + warnings.warn( + "The tube_2nd_section_ratio parameter is deprecated and will be removed in the future. " + "Use the Hamilton-standard second_section_ratio parameter instead.\n" + "second_section_ratio currently superseding tube_2nd_section_ratio.", + DeprecationWarning, + ) + # # # delete # # # + + # get the first well and tip as representatives + if isinstance(aspiration, MultiHeadAspirationPlate): + plate = aspiration.wells[0].parent + assert isinstance(plate, Plate), "MultiHeadAspirationPlate well parent must be a Plate" + rot = plate.get_absolute_rotation() + if rot.x % 360 != 0 or rot.y % 360 != 0: + raise ValueError("Plate rotation around x or y is not supported for 96 head operations") + if rot.z % 360 == 180: + ref_well = aspiration.wells[-1] + elif rot.z % 360 == 0: + ref_well = aspiration.wells[0] + else: + raise ValueError("96 head only supports plate rotations of 0 or 180 degrees around z") + + position = ( + ref_well.get_location_wrt(self.deck) + + ref_well.center() + + Coordinate(z=ref_well.material_z_thickness) + + aspiration.offset + ) + else: + x_width = (12 - 1) * 9 # 12 tips in a row, 9 mm between them + y_width = (8 - 1) * 9 # 8 tips in a column, 9 mm between them + x_position = (aspiration.container.get_absolute_size_x() - x_width) / 2 + y_position = (aspiration.container.get_absolute_size_y() - y_width) / 2 + y_width + position = ( + aspiration.container.get_location_wrt(self.deck, z="cavity_bottom") + + Coordinate(x=x_position, y=y_position) + + aspiration.offset + ) + self._check_96_position_legal(position, skip_z=True) + + tip = next(tip for tip in aspiration.tips if tip is not None) + + liquid_height = position.z + (aspiration.liquid_height or 0) + + hlc = hlc or get_star_liquid_class( + tip_volume=tip.maximal_volume, + is_core=True, + is_tip=True, + has_filter=tip.has_filter, + # get last liquid in pipette, first to be dispensed + liquid=Liquid.WATER, # default to WATER + jet=jet, + blow_out=blow_out, # see comment in method docstring + ) + + if disable_volume_correction or hlc is None: + volume = aspiration.volume + else: # hlc is not None and not disable_volume_correction + volume = hlc.compute_corrected_volume(aspiration.volume) + + # Get better default values from the HLC if available + transport_air_volume = transport_air_volume or ( + hlc.aspiration_air_transport_volume if hlc is not None else 0 + ) + blow_out_air_volume = aspiration.blow_out_air_volume or ( + hlc.aspiration_blow_out_volume if hlc is not None else 0 + ) + flow_rate = aspiration.flow_rate or (hlc.aspiration_flow_rate if hlc is not None else 250) + swap_speed = swap_speed or (hlc.aspiration_swap_speed if hlc is not None else 100) + settling_time = settling_time or (hlc.aspiration_settling_time if hlc is not None else 0.5) + + x_direction = 0 if position.x >= 0 else 1 + return await self.aspirate_core_96( + x_position=abs(round(position.x * 10)), + x_direction=x_direction, + y_positions=round(position.y * 10), + aspiration_type=aspiration_type, + minimum_traverse_height_at_beginning_of_a_command=round( + (minimum_traverse_height_at_beginning_of_a_command or self._channel_traversal_height) * 10 + ), + min_z_endpos=round((min_z_endpos or self._channel_traversal_height) * 10), + lld_search_height=round(lld_search_height * 10), + liquid_surface_no_lld=round(liquid_height * 10), + pull_out_distance_transport_air=round(pull_out_distance_transport_air * 10), + minimum_height=round((minimum_height or position.z) * 10), + second_section_height=round(second_section_height * 10), + second_section_ratio=round(second_section_ratio * 10), + immersion_depth=round(immersion_depth * 10), + immersion_depth_direction=immersion_depth_direction or (0 if (immersion_depth >= 0) else 1), + surface_following_distance=round(surface_following_distance * 10), + aspiration_volumes=round(volume * 10), + aspiration_speed=round(flow_rate * 10), + transport_air_volume=round(transport_air_volume * 10), + blow_out_air_volume=round(blow_out_air_volume * 10), + pre_wetting_volume=round(pre_wetting_volume * 10), + lld_mode=int(use_lld), + gamma_lld_sensitivity=gamma_lld_sensitivity, + swap_speed=round(swap_speed * 10), + settling_time=round(settling_time * 10), + mix_volume=round(aspiration.mix.volume * 10) if aspiration.mix is not None else 0, + mix_cycles=aspiration.mix.repetitions if aspiration.mix is not None else 0, + mix_position_from_liquid_surface=round(mix_position_from_liquid_surface * 10), + mix_surface_following_distance=round(mix_surface_following_distance * 10), + speed_of_mix=round(aspiration.mix.flow_rate * 10) if aspiration.mix is not None else 1200, + channel_pattern=[True] * 12 * 8, + limit_curve_index=limit_curve_index, + tadm_algorithm=False, + recording_mode=0, + ) + + @_requires_head96 + @action(auto_prefix=True, description="使用96头分液。") + async def dispense96( + self, + dispense: Union[MultiHeadDispensePlate, MultiHeadDispenseContainer], + jet: bool = False, + empty: bool = False, + blow_out: bool = False, + hlc: Optional[HamiltonLiquidClass] = None, + pull_out_distance_transport_air=10, + use_lld: bool = False, + minimum_traverse_height_at_beginning_of_a_command: Optional[float] = None, + min_z_endpos: Optional[float] = None, + lld_search_height: float = 199.9, + minimum_height: Optional[float] = None, + second_section_height: float = 3.2, + second_section_ratio: float = 618.0, + immersion_depth: float = 0, + surface_following_distance: float = 0, + transport_air_volume: float = 5.0, + gamma_lld_sensitivity: int = 1, + swap_speed: float = 2.0, + settling_time: float = 0, + mix_position_from_liquid_surface: float = 0, + mix_surface_following_distance: float = 0, + limit_curve_index: int = 0, + cut_off_speed: float = 5.0, + stop_back_volume: float = 0, + disable_volume_correction: bool = False, + # Deprecated parameters, to be removed in future versions + # rm: >2026-01 + liquid_surface_sink_distance_at_the_end_of_dispense: float = 0, # surface_following_distance! + maximum_immersion_depth: Optional[float] = None, + minimal_end_height: Optional[float] = None, + mixing_position_from_liquid_surface: float = 0, + surface_following_distance_during_mixing: float = 0, + air_transport_retract_dist=10, + tube_2nd_section_ratio: float = 618.0, + tube_2nd_section_height_measured_from_zm: float = 3.2, + immersion_depth_direction: Optional[int] = None, + mixing_volume: float = 0, + mixing_cycles: int = 0, + speed_of_mixing: float = 0.0, + dispense_mode: Optional[int] = None, + ): + _unilab_logger.debug("[UNILAB] STARBackend.dispense96() called") + """Dispense using the Core96 head. + + Args: + dispense: The Dispense command to execute. + jet: Whether to use jet dispense mode. + empty: Whether to use empty dispense mode. + blow_out: Whether to blow out after dispensing. + pull_out_distance_transport_air: The distance to retract after dispensing, in mm. + use_lld: Whether to use gamma LLD. + + minimum_traverse_height_at_beginning_of_a_command: Minimum traverse height at beginning of a + command, in mm. + min_z_endpos: Minimal end height, in mm. + lld_search_height: LLD search height, in mm. + minimum_height: Maximum immersion depth, in mm. Equals Minimum height during command. + second_section_height: Height of the second section, in mm. + second_section_ratio: Ratio of [the diameter of the bottom * 10000] / [the diameter of the top]. + immersion_depth: Immersion depth, in mm. + surface_following_distance: Surface following distance, in mm. Default 0. + transport_air_volume: Transport air volume, to dispense before aspiration. + gamma_lld_sensitivity: Gamma LLD sensitivity. + swap_speed: Swap speed (on leaving liquid) [mm/s]. Must be between 0.3 and 160. Default 10. + settling_time: Settling time, in seconds. + mix_position_from_liquid_surface: Mixing position from liquid surface, in mm. + mix_surface_following_distance: Surface following distance during mixing, in mm. + limit_curve_index: Limit curve index. + cut_off_speed: Unknown. + stop_back_volume: Unknown. + disable_volume_correction: Whether to disable liquid class volume correction. + """ + + # # # TODO: delete > 2026-01 # # # + if mixing_volume != 0 or mixing_cycles != 0 or speed_of_mixing != 0: + raise NotImplementedError( + "Mixing through backend kwargs is deprecated. Use the `mix` parameter of LiquidHandler.dispense instead. " + "https://docs.pylabrobot.org/user_guide/00_liquid-handling/mixing.html" + ) + + if immersion_depth_direction is not None: + warnings.warn( + "The immersion_depth_direction parameter is deprecated and will be removed in the future. " + "Use positive values for immersion_depth to move into the liquid, and negative values to move " + "out of the liquid.", + DeprecationWarning, + ) + + if liquid_surface_sink_distance_at_the_end_of_dispense != 0: + surface_following_distance = liquid_surface_sink_distance_at_the_end_of_dispense + warnings.warn( + "The liquid_surface_sink_distance_at_the_end_of_dispense parameter is deprecated and will be removed in the future. " + "Use the Hamilton-standard surface_following_distance parameter instead.\n" + "liquid_surface_sink_distance_at_the_end_of_dispense currently superseding surface_following_distance.", + DeprecationWarning, + ) + + if maximum_immersion_depth is not None: + minimum_height = maximum_immersion_depth + warnings.warn( + "The maximum_immersion_depth parameter is deprecated and will be removed in the future. " + "Use the Hamilton-standard minimum_height parameter instead.\n" + "minimum_height currently superseding maximum_immersion_depth.", + DeprecationWarning, + ) + + if minimal_end_height is not None: + min_z_endpos = minimal_end_height + warnings.warn( + "The minimal_end_height parameter is deprecated and will be removed in the future. " + "Use the Hamilton-standard min_z_endpos parameter instead.\n" + "min_z_endpos currently superseding minimal_end_height.", + DeprecationWarning, + ) + + if mixing_position_from_liquid_surface != 0: + mix_position_from_liquid_surface = mixing_position_from_liquid_surface + warnings.warn( + "The mixing_position_from_liquid_surface parameter is deprecated and will be removed in the future " + "Use the Hamilton-standard mix_position_from_liquid_surface parameter instead.\n" + "mix_position_from_liquid_surface currently superseding mixing_position_from_liquid_surface.", + DeprecationWarning, + ) + + if surface_following_distance_during_mixing != 0: + mix_surface_following_distance = surface_following_distance_during_mixing + warnings.warn( + "The surface_following_distance_during_mixing parameter is deprecated and will be removed in the future. " + "Use the Hamilton-standard mix_surface_following_distance parameter instead.\n" + "mix_surface_following_distance currently superseding surface_following_distance_during_mixing.", + DeprecationWarning, + ) + + if air_transport_retract_dist != 10: + pull_out_distance_transport_air = air_transport_retract_dist + warnings.warn( + "The air_transport_retract_dist parameter is deprecated and will be removed in the future. " + "Use the Hamilton-standard pull_out_distance_transport_air parameter instead.\n" + "pull_out_distance_transport_air currently superseding air_transport_retract_dist.", + DeprecationWarning, + ) + + if tube_2nd_section_ratio != 618.0: + second_section_ratio = tube_2nd_section_ratio + warnings.warn( + "The tube_2nd_section_ratio parameter is deprecated and will be removed in the future. " + "Use the Hamilton-standard second_section_ratio parameter instead.\n" + "second_section_ratio currently superseding tube_2nd_section_ratio.", + DeprecationWarning, + ) + + if tube_2nd_section_height_measured_from_zm != 3.2: + second_section_height = tube_2nd_section_height_measured_from_zm + warnings.warn( + "The tube_2nd_section_height_measured_from_zm parameter is deprecated and will be removed in the future. " + "Use the Hamilton-standard second_section_height parameter instead.\n" + "second_section_height currently superseding tube_2nd_section_height_measured_from_zm.", + DeprecationWarning, + ) + + if dispense_mode is not None: + warnings.warn( + "The dispense_mode parameter is deprecated and will be removed in the future. " + "Use the combination of the `jet`, `empty` and `blow_out` parameters instead. " + "dispense_mode currently superseding those parameters.", + DeprecationWarning, + ) + else: + dispense_mode = _dispensing_mode_for_op(empty=empty, jet=jet, blow_out=blow_out) + # # # delete # # # + + # get the first well and tip as representatives + if isinstance(dispense, MultiHeadDispensePlate): + plate = dispense.wells[0].parent + assert isinstance(plate, Plate), "MultiHeadDispensePlate well parent must be a Plate" + rot = plate.get_absolute_rotation() + if rot.x % 360 != 0 or rot.y % 360 != 0: + raise ValueError("Plate rotation around x or y is not supported for 96 head operations") + if rot.z % 360 == 180: + ref_well = dispense.wells[-1] + elif rot.z % 360 == 0: + ref_well = dispense.wells[0] + else: + raise ValueError("96 head only supports plate rotations of 0 or 180 degrees around z") + + position = ( + ref_well.get_location_wrt(self.deck) + + ref_well.center() + + Coordinate(z=ref_well.material_z_thickness) + + dispense.offset + ) + else: + # dispense in the center of the container + # but we have to get the position of the center of tip A1 + x_width = (12 - 1) * 9 # 12 tips in a row, 9 mm between them + y_width = (8 - 1) * 9 # 8 tips in a column, 9 mm between them + x_position = (dispense.container.get_absolute_size_x() - x_width) / 2 + y_position = (dispense.container.get_absolute_size_y() - y_width) / 2 + y_width + position = ( + dispense.container.get_location_wrt(self.deck, z="cavity_bottom") + + Coordinate(x=x_position, y=y_position) + + dispense.offset + ) + self._check_96_position_legal(position, skip_z=True) + tip = next(tip for tip in dispense.tips if tip is not None) + + liquid_height = position.z + (dispense.liquid_height or 0) + + hlc = hlc or get_star_liquid_class( + tip_volume=tip.maximal_volume, + is_core=True, + is_tip=True, + has_filter=tip.has_filter, + # get last liquid in pipette, first to be dispensed + liquid=Liquid.WATER, # default to WATER + jet=jet, + blow_out=blow_out, # see comment in method docstring + ) + + if disable_volume_correction or hlc is None: + volume = dispense.volume + else: # hlc is not None and not disable_volume_correction + volume = hlc.compute_corrected_volume(dispense.volume) + + transport_air_volume = transport_air_volume or ( + hlc.dispense_air_transport_volume if hlc is not None else 0 + ) + blow_out_air_volume = dispense.blow_out_air_volume or ( + hlc.dispense_blow_out_volume if hlc is not None else 0 + ) + flow_rate = dispense.flow_rate or (hlc.dispense_flow_rate if hlc is not None else 120) + swap_speed = swap_speed or (hlc.dispense_swap_speed if hlc is not None else 100) + settling_time = settling_time or (hlc.dispense_settling_time if hlc is not None else 5) + + return await self.dispense_core_96( + dispensing_mode=dispense_mode, + x_position=abs(round(position.x * 10)), + x_direction=0 if position.x >= 0 else 1, + y_position=round(position.y * 10), + minimum_traverse_height_at_beginning_of_a_command=round( + (minimum_traverse_height_at_beginning_of_a_command or self._channel_traversal_height) * 10 + ), + min_z_endpos=round((min_z_endpos or self._channel_traversal_height) * 10), + lld_search_height=round(lld_search_height * 10), + liquid_surface_no_lld=round(liquid_height * 10), + pull_out_distance_transport_air=round(pull_out_distance_transport_air * 10), + minimum_height=round((minimum_height or position.z) * 10), + second_section_height=round(second_section_height * 10), + second_section_ratio=round(second_section_ratio * 10), + immersion_depth=round(immersion_depth * 10), + immersion_depth_direction=immersion_depth_direction or (0 if (immersion_depth >= 0) else 1), + surface_following_distance=round(surface_following_distance * 10), + dispense_volume=round(volume * 10), + dispense_speed=round(flow_rate * 10), + transport_air_volume=round(transport_air_volume * 10), + blow_out_air_volume=round(blow_out_air_volume * 10), + lld_mode=int(use_lld), + gamma_lld_sensitivity=gamma_lld_sensitivity, + swap_speed=round(swap_speed * 10), + settling_time=round(settling_time * 10), + mixing_volume=round(dispense.mix.volume * 10) if dispense.mix is not None else 0, + mixing_cycles=dispense.mix.repetitions if dispense.mix is not None else 0, + mix_position_from_liquid_surface=round(mix_position_from_liquid_surface * 10), + mix_surface_following_distance=round(mix_surface_following_distance * 10), + speed_of_mixing=round(dispense.mix.flow_rate * 10) if dispense.mix is not None else 1200, + channel_pattern=[True] * 12 * 8, + limit_curve_index=limit_curve_index, + tadm_algorithm=False, + recording_mode=0, + cut_off_speed=round(cut_off_speed * 10), + stop_back_volume=round(stop_back_volume * 10), + ) + + @action(auto_prefix=True, description="移动iSWAP夹持中的资源。") + async def iswap_move_picked_up_resource( + self, + center: Coordinate, + grip_direction: GripDirection, + minimum_traverse_height_at_beginning_of_a_command: Optional[float] = None, + collision_control_level: int = 1, + acceleration_index_high_acc: int = 4, + acceleration_index_low_acc: int = 1, + ): + _unilab_logger.debug("[UNILAB] STARBackend.iswap_move_picked_up_resource() called") + """After a resource is picked up, move it to a new location but don't release it yet. + Low level component of :meth:`move_resource` + """ + + assert self.iswap_installed, "iswap must be installed" + + x_direction = 0 if center.x >= 0 else 1 + y_direction = 0 if center.y >= 0 else 1 + + await self.move_plate_to_position( + x_position=round(abs(center.x) * 10), + x_direction=x_direction, + y_position=round(abs(center.y) * 10), + y_direction=y_direction, + z_position=round(center.z * 10), + z_direction=0, + grip_direction={ + GripDirection.FRONT: 1, + GripDirection.RIGHT: 2, + GripDirection.BACK: 3, + GripDirection.LEFT: 4, + }[grip_direction], + minimum_traverse_height_at_beginning_of_a_command=round( + (minimum_traverse_height_at_beginning_of_a_command or self._iswap_traversal_height) * 10 + ), + collision_control_level=collision_control_level, + acceleration_index_high_acc=acceleration_index_high_acc, + acceleration_index_low_acc=acceleration_index_low_acc, + ) + + @action(auto_prefix=True, description="用CoRe夹爪拾取资源。") + async def core_pick_up_resource( + self, + resource: Resource, + pickup_distance_from_top: float, + offset: Coordinate = Coordinate.zero(), + minimum_traverse_height_at_beginning_of_a_command: Optional[float] = None, + minimum_z_position_at_the_command_end: Optional[float] = None, + grip_strength: int = 15, + z_speed: float = 50.0, + y_gripping_speed: float = 5.0, + front_channel: int = 7, + ): + _unilab_logger.debug("[UNILAB] STARBackend.core_pick_up_resource() called") + """Pick up resource with CoRe gripper tool + Low level component of :meth:`move_resource` + + Args: + resource: Resource to pick up. + offset: Offset from resource position in mm. + pickup_distance_from_top: Distance from top of resource to pick up. + minimum_traverse_height_at_beginning_of_a_command: Minimum traverse height at beginning of a + command [mm] (refers to all channels independent of tip pattern parameter 'tm'). Must be + between 0 and 360. + grip_strength: Grip strength (0 = weak, 99 = strong). Must be between 0 and 99. Default 15. + z_speed: Z speed [mm/s]. Must be between 0.4 and 128.7. Default 50.0. + y_gripping_speed: Y gripping speed [mm/s]. Must be between 0 and 370.0. Default 5.0. + front_channel: Channel 1. Must be between 1 and self._num_channels - 1. Default 7. + """ + + # Get center of source plate. Also gripping height and plate width. + center = resource.get_location_wrt(self.deck, x="c", y="c", z="b") + offset + grip_height = center.z + resource.get_absolute_size_z() - pickup_distance_from_top + grip_width = resource.get_absolute_size_y() # grip width is y size of resource + + if self.core_parked: + await self.pick_up_core_gripper_tools(front_channel=front_channel) + + await self.core_get_plate( + x_position=round(center.x * 10), + x_direction=0, + y_position=round(center.y * 10), + y_gripping_speed=round(y_gripping_speed * 10), + z_position=round(grip_height * 10), + z_speed=round(z_speed * 10), + open_gripper_position=round(grip_width * 10) + 30, + plate_width=round(grip_width * 10) - 30, + grip_strength=grip_strength, + minimum_traverse_height_at_beginning_of_a_command=round( + (minimum_traverse_height_at_beginning_of_a_command or self._iswap_traversal_height) * 10 + ), + minimum_z_position_at_the_command_end=round( + (minimum_z_position_at_the_command_end or self._iswap_traversal_height) * 10 + ), + ) + + @action(auto_prefix=True, description="移动CoRe夹持中的资源。") + async def core_move_picked_up_resource( + self, + center: Coordinate, + minimum_traverse_height_at_beginning_of_a_command: Optional[float] = None, + acceleration_index: int = 4, + z_speed: float = 50.0, + ): + _unilab_logger.debug("[UNILAB] STARBackend.core_move_picked_up_resource() called") + """After a resource is picked up, move it to a new location but don't release it yet. + Low level component of :meth:`move_resource` + + Args: + location: Location to move to. + minimum_traverse_height_at_beginning_of_a_command: Minimum traverse height at beginning of a + command [0.1mm] (refers to all channels independent of tip pattern parameter 'tm'). Must be + between 0 and 3600. Default 3600. + acceleration_index: Acceleration index (0 = 0.1 mm/s2, 1 = 0.2 mm/s2, 2 = 0.5 mm/s2, + 3 = 1.0 mm/s2, 4 = 2.0 mm/s2, 5 = 5.0 mm/s2, 6 = 10.0 mm/s2, 7 = 20.0 mm/s2). Must be + between 0 and 7. Default 4. + z_speed: Z speed [0.1mm/s]. Must be between 3 and 1600. Default 500. + """ + + await self.core_move_plate_to_position( + x_position=round(center.x * 10), + x_direction=0, + x_acceleration_index=acceleration_index, + y_position=round(center.y * 10), + z_position=round(center.z * 10), + z_speed=round(z_speed * 10), + minimum_traverse_height_at_beginning_of_a_command=round( + (minimum_traverse_height_at_beginning_of_a_command or self._iswap_traversal_height) * 10 + ), + ) + + @action(auto_prefix=True, description="释放CoRe夹持中的资源。") + async def core_release_picked_up_resource( + self, + location: Coordinate, + resource: Resource, + pickup_distance_from_top: float, + minimum_traverse_height_at_beginning_of_a_command: Optional[float] = None, + z_position_at_the_command_end: Optional[float] = None, + return_tool: bool = True, + ): + _unilab_logger.debug("[UNILAB] STARBackend.core_release_picked_up_resource() called") + """Place resource with CoRe gripper tool + Low level component of :meth:`move_resource` + + Args: + resource: Location to place. + pickup_distance_from_top: Distance from top of resource to place. + offset: Offset from resource position in mm. + minimum_traverse_height_at_beginning_of_a_command: Minimum traverse height at beginning of a + command [mm] (refers to all channels independent of tip pattern parameter 'tm'). Must be + between 0 and 360.0. + z_position_at_the_command_end: Minimum z-Position at end of a command [mm] (refers to all + channels independent of tip pattern parameter 'tm'). Must be between 0 and 360.0 + return_tool: Return tool to wasteblock mount after placing. Default True. + """ + + # Get center of destination location. Also gripping height and plate width. + grip_height = location.z + resource.get_absolute_size_z() - pickup_distance_from_top + grip_width = resource.get_absolute_size_y() + + await self.core_put_plate( + x_position=round(location.x * 10), + x_direction=0, + y_position=round(location.y * 10), + z_position=round(grip_height * 10), + z_press_on_distance=0, + z_speed=500, + open_gripper_position=round(grip_width * 10) + 30, + minimum_traverse_height_at_beginning_of_a_command=round( + (minimum_traverse_height_at_beginning_of_a_command or self._iswap_traversal_height) * 10 + ), + z_position_at_the_command_end=round( + (z_position_at_the_command_end or self._iswap_traversal_height) * 10 + ), + return_tool=return_tool, + ) + + @action(auto_prefix=True, description="拾取资源。") + async def pick_up_resource( + self, + pickup: ResourcePickup, + use_arm: Literal["iswap", "core"] = "iswap", + core_front_channel: int = 7, + iswap_grip_strength: int = 4, + core_grip_strength: int = 15, + minimum_traverse_height_at_beginning_of_a_command: Optional[float] = None, + z_position_at_the_command_end: Optional[float] = None, + plate_width_tolerance: float = 2.0, + open_gripper_position: Optional[float] = None, + hotel_depth=160.0, + hotel_clearance_height=7.5, + high_speed=False, + plate_width: Optional[float] = None, + use_unsafe_hotel: bool = False, + iswap_collision_control_level: int = 0, + iswap_fold_up_sequence_at_the_end_of_process: bool = False, + # deprecated + channel_1: Optional[int] = None, + channel_2: Optional[int] = None, + ): + _unilab_logger.debug("[UNILAB] STARBackend.pick_up_resource() called") + if use_arm == "iswap": + assert ( + pickup.resource.get_absolute_rotation().x == 0 + and pickup.resource.get_absolute_rotation().y == 0 + ) + assert pickup.resource.get_absolute_rotation().z % 90 == 0 + if plate_width is None: + if pickup.direction in (GripDirection.FRONT, GripDirection.BACK): + plate_width = pickup.resource.get_absolute_size_x() + else: + plate_width = pickup.resource.get_absolute_size_y() + + center_in_absolute_space = pickup.resource.center().rotated( + pickup.resource.get_absolute_rotation() + ) + x, y, z = ( + pickup.resource.get_location_wrt(self.deck, "l", "f", "t") + + center_in_absolute_space + + pickup.offset + ) + z -= pickup.pickup_distance_from_top + + traverse_height_at_beginning = ( + minimum_traverse_height_at_beginning_of_a_command or self._iswap_traversal_height + ) + z_position_at_the_command_end = z_position_at_the_command_end or self._iswap_traversal_height + + if open_gripper_position is None: + if use_unsafe_hotel: + open_gripper_position = plate_width + 5 + else: + open_gripper_position = plate_width + 3 + + if use_unsafe_hotel: + await self.unsafe.get_from_hotel( + hotel_center_x_coord=round(abs(x) * 10), + hotel_center_y_coord=round(abs(y) * 10), + # hotel_center_z_coord=int((z * 10)+0.5), # use sensible rounding (.5 goes up) + hotel_center_z_coord=round(abs(z) * 10), + hotel_center_x_direction=0 if x >= 0 else 1, + hotel_center_y_direction=0 if y >= 0 else 1, + hotel_center_z_direction=0 if z >= 0 else 1, + clearance_height=round(hotel_clearance_height * 10), + hotel_depth=round(hotel_depth * 10), + grip_direction=pickup.direction, + open_gripper_position=round(open_gripper_position * 10), + traverse_height_at_beginning=round(traverse_height_at_beginning * 10), + z_position_at_end=round(z_position_at_the_command_end * 10), + high_acceleration_index=4 if high_speed else 1, + low_acceleration_index=1, + plate_width=round(plate_width * 10), + plate_width_tolerance=round(plate_width_tolerance * 10), + ) + else: + await self.iswap_get_plate( + x_position=round(abs(x) * 10), + y_position=round(abs(y) * 10), + z_position=round(abs(z) * 10), + x_direction=0 if x >= 0 else 1, + y_direction=0 if y >= 0 else 1, + z_direction=0 if z >= 0 else 1, + grip_direction={ + GripDirection.FRONT: 1, + GripDirection.RIGHT: 2, + GripDirection.BACK: 3, + GripDirection.LEFT: 4, + }[pickup.direction], + minimum_traverse_height_at_beginning_of_a_command=round( + traverse_height_at_beginning * 10 + ), + z_position_at_the_command_end=round(z_position_at_the_command_end * 10), + grip_strength=iswap_grip_strength, + open_gripper_position=round(open_gripper_position * 10), + plate_width=round(plate_width * 10) - 33, + plate_width_tolerance=round(plate_width_tolerance * 10), + collision_control_level=iswap_collision_control_level, + acceleration_index_high_acc=4 if high_speed else 1, + acceleration_index_low_acc=1, + iswap_fold_up_sequence_at_the_end_of_process=iswap_fold_up_sequence_at_the_end_of_process, + ) + elif use_arm == "core": + if use_unsafe_hotel: + raise ValueError("Cannot use iswap hotel mode with core grippers") + + if pickup.direction != GripDirection.FRONT: + raise NotImplementedError("Core grippers only support FRONT (default)") + + if channel_1 is not None or channel_2 is not None: + warnings.warn( + "The channel_1 and channel_2 parameters are deprecated and will be removed in future versions. " + "Please use the core_front_channel parameter instead.", + DeprecationWarning, + ) + assert ( + channel_1 is not None and channel_2 is not None + ), "Both channel_1 and channel_2 must be provided" + assert channel_1 + 1 == channel_2, "channel_2 must be channel_1 + 1" + core_front_channel = ( + channel_2 - 1 + ) # core_front_channel is the first channel of the gripper tool + + await self.core_pick_up_resource( + resource=pickup.resource, + pickup_distance_from_top=pickup.pickup_distance_from_top, + offset=pickup.offset, + minimum_traverse_height_at_beginning_of_a_command=self._iswap_traversal_height, + minimum_z_position_at_the_command_end=self._iswap_traversal_height, + front_channel=core_front_channel, + grip_strength=core_grip_strength, + ) + else: + raise ValueError(f"use_arm must be either 'iswap' or 'core', not {use_arm}") + + @action(auto_prefix=True, description="移动已抓取资源。") + async def move_picked_up_resource( + self, move: ResourceMove, use_arm: Literal["iswap", "core"] = "iswap" + ): + _unilab_logger.debug("[UNILAB] STARBackend.move_picked_up_resource() called") + center = ( + move.location + + move.resource.get_anchor("c", "c", "t") + - Coordinate(z=move.pickup_distance_from_top) + + move.offset + ) + + if use_arm == "iswap": + await self.iswap_move_picked_up_resource( + center=center, + grip_direction=move.gripped_direction, + minimum_traverse_height_at_beginning_of_a_command=self._iswap_traversal_height, + collision_control_level=1, + acceleration_index_high_acc=4, + acceleration_index_low_acc=1, + ) + else: + await self.core_move_picked_up_resource( + center=center, + minimum_traverse_height_at_beginning_of_a_command=self._iswap_traversal_height, + acceleration_index=4, + ) + + @action(auto_prefix=True, description="放下已抓取资源。") + async def drop_resource( + self, + drop: ResourceDrop, + use_arm: Literal["iswap", "core"] = "iswap", + return_core_gripper: bool = True, + minimum_traverse_height_at_beginning_of_a_command: Optional[float] = None, + z_position_at_the_command_end: Optional[float] = None, + open_gripper_position: Optional[float] = None, + hotel_depth=160.0, + hotel_clearance_height=7.5, + hotel_high_speed=False, + use_unsafe_hotel: bool = False, + iswap_collision_control_level: int = 0, + iswap_fold_up_sequence_at_the_end_of_process: bool = False, + ): + _unilab_logger.debug("[UNILAB] STARBackend.drop_resource() called") + # Get center of source plate in absolute space. + # The computation of the center has to be rotated so that the offset is in absolute space. + # center_in_absolute_space will be the vector pointing from the destination origin to the + # center of the moved the resource after drop. + # This means that the center vector has to be rotated from the child local space by the + # new child absolute rotation. The moved resource's rotation will be the original child + # rotation plus the rotation applied by the movement. + # The resource is moved by drop.rotation + # The new resource absolute location is + # drop.resource.get_absolute_rotation().z + drop.rotation + center_in_absolute_space = drop.resource.center().rotated( + Rotation(z=drop.resource.get_absolute_rotation().z + drop.rotation) + ) + x, y, z = drop.destination + center_in_absolute_space + drop.offset + + if use_arm == "iswap": + traversal_height_start = ( + minimum_traverse_height_at_beginning_of_a_command or self._iswap_traversal_height + ) + z_position_at_the_command_end = z_position_at_the_command_end or self._iswap_traversal_height + assert ( + drop.resource.get_absolute_rotation().x == 0 + and drop.resource.get_absolute_rotation().y == 0 + ) + assert drop.resource.get_absolute_rotation().z % 90 == 0 + + # Use the pickup direction to determine how wide the plate is gripped. + # Note that the plate is still in the original orientation at this point, + # so get_absolute_size_{x,y}() will return the size of the plate in the original orientation. + if ( + drop.pickup_direction == GripDirection.FRONT or drop.pickup_direction == GripDirection.BACK + ): + plate_width = drop.resource.get_absolute_size_x() + elif ( + drop.pickup_direction == GripDirection.RIGHT or drop.pickup_direction == GripDirection.LEFT + ): + plate_width = drop.resource.get_absolute_size_y() + else: + raise ValueError("Invalid grip direction") + + z = z + drop.resource.get_absolute_size_z() - drop.pickup_distance_from_top + + if open_gripper_position is None: + if use_unsafe_hotel: + open_gripper_position = plate_width + 5 + else: + open_gripper_position = plate_width + 3 + + if use_unsafe_hotel: + # hotel: down forward down. + # down to level of the destination + the clearance height (so clearance height can be subtracted) + # hotel_depth is forward. + # clearance height is second down. + + await self.unsafe.put_in_hotel( + hotel_center_x_coord=round(abs(x) * 10), + hotel_center_y_coord=round(abs(y) * 10), + hotel_center_z_coord=round(abs(z) * 10), + hotel_center_x_direction=0 if x >= 0 else 1, + hotel_center_y_direction=0 if y >= 0 else 1, + hotel_center_z_direction=0 if z >= 0 else 1, + clearance_height=round(hotel_clearance_height * 10), + hotel_depth=round(hotel_depth * 10), + grip_direction=drop.direction, + open_gripper_position=round(open_gripper_position * 10), + traverse_height_at_beginning=round(traversal_height_start * 10), + z_position_at_end=round(z_position_at_the_command_end * 10), + high_acceleration_index=4 if hotel_high_speed else 1, + low_acceleration_index=1, + ) + else: + await self.iswap_put_plate( + x_position=round(abs(x) * 10), + y_position=round(abs(y) * 10), + z_position=round(abs(z) * 10), + x_direction=0 if x >= 0 else 1, + y_direction=0 if y >= 0 else 1, + z_direction=0 if z >= 0 else 1, + grip_direction={ + GripDirection.FRONT: 1, + GripDirection.RIGHT: 2, + GripDirection.BACK: 3, + GripDirection.LEFT: 4, + }[drop.direction], + minimum_traverse_height_at_beginning_of_a_command=round(traversal_height_start * 10), + z_position_at_the_command_end=round(z_position_at_the_command_end * 10), + open_gripper_position=round(open_gripper_position * 10), + collision_control_level=iswap_collision_control_level, + iswap_fold_up_sequence_at_the_end_of_process=iswap_fold_up_sequence_at_the_end_of_process, + ) + elif use_arm == "core": + if use_unsafe_hotel: + raise ValueError("Cannot use iswap hotel mode with core grippers") + + if drop.direction != GripDirection.FRONT: + raise NotImplementedError("Core grippers only support FRONT direction (default)") + + await self.core_release_picked_up_resource( + location=Coordinate(x, y, z), + resource=drop.resource, + pickup_distance_from_top=drop.pickup_distance_from_top, + minimum_traverse_height_at_beginning_of_a_command=self._iswap_traversal_height, + z_position_at_the_command_end=self._iswap_traversal_height, + # int(previous_location.z + move.resource.get_size_z() / 2) * 10, + return_tool=return_core_gripper, + ) + else: + raise ValueError(f"use_arm must be either 'iswap' or 'core', not {use_arm}") + + @action(auto_prefix=True, description="准备手动通道操作。") + async def prepare_for_manual_channel_operation(self, channel: int): + _unilab_logger.debug("[UNILAB] STARBackend.prepare_for_manual_channel_operation() called") + """Prepare for manual operation.""" + + await self.position_max_free_y_for_n(pipetting_channel_index=channel) + + @action(auto_prefix=True, description="移动单通道X轴。") + async def move_channel_x(self, channel: int, x: float): + _unilab_logger.debug("[UNILAB] STARBackend.move_channel_x() called") + """Move a channel in the x direction.""" + await self.position_left_x_arm_(round(x * 10)) + + @need_iswap_parked + @action(auto_prefix=True, description="移动单通道Y轴。") + async def move_channel_y(self, channel: int, y: float): + _unilab_logger.debug("[UNILAB] STARBackend.move_channel_y() called") + """Move a channel safely in the y direction.""" + + # Anti-channel-crash feature + if channel > 0: + max_y_pos = await self.request_y_pos_channel_n(channel - 1) + if y > max_y_pos: + raise ValueError( + f"channel {channel} y-target must be <= {max_y_pos} mm " + f"(channel {channel - 1} y-position is {round(y, 2)} mm)" + ) + else: + if self.iswap_installed: + max_y_pos = await self.iswap_rotation_drive_request_y() + limit = "iswap module y-position" + else: + # STAR machines do not allow channels y > 635 mm + max_y_pos = 635 + limit = "machine limit" + if y > max_y_pos: + raise ValueError(f"channel {channel} y-target must be <= {max_y_pos} mm ({limit})") + + if channel < (self.num_channels - 1): + min_y_pos = await self.request_y_pos_channel_n(channel + 1) + if y < min_y_pos: + raise ValueError( + f"channel {channel} y-target must be >= {min_y_pos} mm " + f"(channel {channel + 1} y-position is {round(y, 2)} mm)" + ) + else: + # STAR machines do not allow channels y < 6 mm + min_y_pos = 6 + if y < min_y_pos: + raise ValueError(f"channel {channel} y-target must be >= {min_y_pos} mm (machine limit)") + + await self.position_single_pipetting_channel_in_y_direction( + pipetting_channel_index=channel + 1, y_position=round(y * 10) + ) + + @action(auto_prefix=True, description="移动单通道Z轴。") + async def move_channel_z(self, channel: int, z: float): + _unilab_logger.debug("[UNILAB] STARBackend.move_channel_z() called") + """Move a channel in the z direction.""" + await self.position_single_pipetting_channel_in_z_direction( + pipetting_channel_index=channel + 1, z_position=round(z * 10) + ) + + @action(auto_prefix=True, description="相对移动单通道X轴。") + async def move_channel_x_relative(self, channel: int, distance: float): + _unilab_logger.debug("[UNILAB] STARBackend.move_channel_x_relative() called") + """Move a channel in the x direction by a relative amount.""" + current_x = await self.request_x_pos_channel_n(channel) + await self.move_channel_x(channel, current_x + distance) + + @action(auto_prefix=True, description="相对移动单通道Y轴。") + async def move_channel_y_relative(self, channel: int, distance: float): + _unilab_logger.debug("[UNILAB] STARBackend.move_channel_y_relative() called") + """Move a channel in the y direction by a relative amount.""" + current_y = await self.request_y_pos_channel_n(channel) + await self.move_channel_y(channel, current_y + distance) + + @action(auto_prefix=True, description="相对移动单通道Z轴。") + async def move_channel_z_relative(self, channel: int, distance: float): + _unilab_logger.debug("[UNILAB] STARBackend.move_channel_z_relative() called") + """Move a channel in the z direction by a relative amount.""" + current_z = await self.request_z_pos_channel_n(channel) + await self.move_channel_z(channel, current_z + distance) + + @action(auto_prefix=True, description="检查通道是否可拾取吸头。") + def can_pick_up_tip(self, channel_idx: int, tip: Tip) -> bool: + _unilab_logger.debug("[UNILAB] STARBackend.can_pick_up_tip() called") + if not isinstance(tip, HamiltonTip): + return False + if tip.tip_size in {TipSize.XL}: + return False + return True + + @action(auto_prefix=True, description="用CoRe夹爪检查位置中心是否有资源。") + async def core_check_resource_exists_at_location_center( + self, + location: Coordinate, + resource: Resource, + gripper_y_margin: float = 0.5, + offset: Coordinate = Coordinate.zero(), + minimum_traverse_height_at_beginning_of_a_command: float = 275.0, + z_position_at_the_command_end: float = 275.0, + enable_recovery: bool = True, + audio_feedback: bool = True, + ) -> bool: + _unilab_logger.debug("[UNILAB] STARBackend.core_check_resource_exists_at_location_center() called") + """Check existence of resource with CoRe gripper tool + a "Get plate using CO-RE gripper" + error handling + Which channels are used for resource check is dependent on which channels have been used for + `STARBackend.get_core(p1: int, p2: int)` (channel indices are 0-based) which is a prerequisite + for this check function. + + Args: + location: Location to check for resource + resource: Resource to check for. + gripper_y_margin = Distance between the front / back wall of the resource + and the grippers during "bumping" / checking + offset: Offset from resource position in mm. + minimum_traverse_height_at_beginning_of_a_command: Minimum traverse height at beginning of + a command [mm] (refers to all channels independent of tip pattern parameter 'tm'). + Must be between 0 and 360.0. + z_position_at_the_command_end: Minimum z-Position at end of a command [mm] (refers to + all channels independent of tip pattern parameter 'tm'). Must be between 0 and 360.0. + enable_recovery: if True will ask for user input if resource was not found + audio_feedback: enable controlling computer to emit different sounds when + finding/not finding the resource + + Returns: + True if resource was found, False if resource was not found + """ + + center = location + resource.centers()[0] + offset + y_width_to_gripper_bump = resource.get_absolute_size_y() - gripper_y_margin * 2 + assert ( + self._channel_minimum_y_spacing + <= y_width_to_gripper_bump + <= round(resource.get_absolute_size_y()) + ), ( + f"width between channels must be between {self._channel_minimum_y_spacing} and " + f"{resource.get_absolute_size_y()} mm" + " (i.e. the minimal distance between channels and the max y size of the resource" + ) + + # Check if CoRe gripper currently in use + cores_used = not self._core_parked + if not cores_used: + raise ValueError("CoRe grippers not yet picked up.") + + # Enable recovery of failed checks + resource_found = False + try_counter = 0 + while not resource_found: + try: + await self.core_get_plate( + x_position=round(center.x * 10), + y_position=round(center.y * 10), + z_position=round(center.z * 10), + open_gripper_position=round(y_width_to_gripper_bump * 10), + plate_width=round(y_width_to_gripper_bump * 10), + # Set default values based on VENUS check_plate commands + y_gripping_speed=50, + x_direction=0, + z_speed=600, + grip_strength=20, + # Enable mods of channel z position for check acceleration + minimum_traverse_height_at_beginning_of_a_command=round( + minimum_traverse_height_at_beginning_of_a_command * 10 + ), + minimum_z_position_at_the_command_end=round(z_position_at_the_command_end * 10), + ) + except STARFirmwareError as exc: + for module_error in exc.errors.values(): + if module_error.trace_information == 62: + resource_found = True + else: + raise ValueError(f"Unexpected error encountered: {exc}") from exc + else: + if audio_feedback: + audio.play_not_found() + if enable_recovery: + print( + f"\nWARNING: Resource '{resource.name}' not found at center" + f" location {(center.x, center.y, center.z)} during check no {try_counter}." + ) + user_prompt = input( + "Have you checked resource is present?" + "\n [ yes ] -> machine will check location again" + "\n [ abort ] -> machine will abort run\n Answer:" + ) + if user_prompt == "yes": + try_counter += 1 + elif user_prompt == "abort": + raise ValueError( + f"Resource '{resource.name}' not found at center" + f" location {(center.x,center.y,center.z)}" + " & error not resolved -> aborted resource movement." + ) + else: + # Resource was not found + return False + + # Resource was found + if audio_feedback: + audio.play_got_item() + return True + + def _position_96_head_in_resource(self, resource: Resource) -> Coordinate: + _unilab_logger.debug("[UNILAB] STARBackend._position_96_head_in_resource() called") + """The firmware command expects location of tip A1 of the head. We center the head in the given + resource.""" + head_size_x = 9 * 11 # 12 channels, 9mm spacing in between + head_size_y = 9 * 7 # 8 channels, 9mm spacing in between + channel_size = 9 + loc = resource.get_location_wrt(self.deck) + loc.x += (resource.get_size_x() - head_size_x) / 2 + channel_size / 2 + loc.y += (resource.get_size_y() - head_size_y) / 2 + channel_size / 2 + return loc + + def _check_96_position_legal(self, c: Coordinate, skip_z=False) -> None: + _unilab_logger.debug("[UNILAB] STARBackend._check_96_position_legal() called") + """Validate that a coordinate is within the allowed range for the 96 head. + + Args: + c: The coordinate of the A1 position of the head. + skip_z: If True, the z coordinate is not checked. This is useful for commands that handle + the z coordinate separately, such as the big four. + + Raises: + ValueError: If one or more components are out of range. The error message contains all offending components. + """ + + # TODO: these are values for a STARBackend. Find them for a STARlet. + + errors = [] + if not (-271.0 <= c.x <= 974.0): + errors.append(f"x={c.x}") + if not (108.0 <= c.y <= 560.0): + errors.append(f"y={c.y}") + if not (180.5 <= c.z <= 342.5) and not skip_z: + errors.append(f"z={c.z}") + + if len(errors) > 0: + raise ValueError( + "Illegal 96 head position: " + + ", ".join(errors) + + " (allowed ranges: x [-271, 974], y [108, 560], z [180.5, 342.5])" + ) + + # ============== Firmware Commands ============== + + # -------------- 3.2 System general commands -------------- + + @action(auto_prefix=True, description="预初始化整机。") + async def pre_initialize_instrument(self): + _unilab_logger.debug("[UNILAB] STARBackend.pre_initialize_instrument() called") + """Pre-initialize instrument""" + return await self.send_command(module="C0", command="VI", read_timeout=300) + + @action(auto_prefix=True, description="定义吸头或针参数。") + async def define_tip_needle( + self, + tip_type_table_index: int, + has_filter: bool, + tip_length: int, + maximum_tip_volume: int, + tip_size: TipSize, + pickup_method: TipPickupMethod, + ): + _unilab_logger.debug("[UNILAB] STARBackend.define_tip_needle() called") + """Tip/needle definition. + + Args: + tip_type_table_index: tip_table_index + has_filter: with(out) filter + tip_length: Tip length [0.1mm] + maximum_tip_volume: Maximum volume of tip [0.1ul] + Note! it's automatically limited to max. channel capacity + tip_type: Type of tip collar (Tip type identification) + pickup_method: pick up method. + Attention! The values set here are temporary and apply only until + power OFF or RESET. After power ON the default values apply. (see Table 3) + """ + + assert 0 <= tip_type_table_index <= 99, "tip_type_table_index must be between 0 and 99" + assert 0 <= tip_type_table_index <= 99, "tip_type_table_index must be between 0 and 99" + assert 1 <= tip_length <= 1999, "tip_length must be between 1 and 1999" + assert 1 <= maximum_tip_volume <= 56000, "maximum_tip_volume must be between 1 and 56000" + + return await self.send_command( + module="C0", + command="TT", + tt=f"{tip_type_table_index:02}", + tf=has_filter, + tl=f"{tip_length:04}", + tv=f"{maximum_tip_volume:05}", + tg=tip_size.value, + tu=pickup_method.value, + ) + + # -------------- 3.2.1 System query -------------- + + @action(auto_prefix=True, description="获取错误代码。") + async def request_error_code(self): + _unilab_logger.debug("[UNILAB] STARBackend.request_error_code() called") + """Request error code + + Here the last saved error messages can be retrieved. The error buffer is automatically voided + when a new command is started. All configured nodes are displayed. + + Returns: + TODO: + X0##/##: X0 slave + ..##/## see node definitions ( chapter 5) + """ + + return await self.send_command(module="C0", command="RE") + + @action(auto_prefix=True, description="获取固件版本。") + async def request_firmware_version(self): + _unilab_logger.debug("[UNILAB] STARBackend.request_firmware_version() called") + """Request firmware version + + Returns: TODO: Rfid0001rf1.0S 2009-06-24 A + """ + + return await self.send_command(module="C0", command="RF") + + @action(auto_prefix=True, description="获取参数值。") + async def request_parameter_value(self): + _unilab_logger.debug("[UNILAB] STARBackend.request_parameter_value() called") + """Request parameter value + + Returns: TODO: Raid1111er00/00yg1200 + """ + + return await self.send_command(module="C0", command="RA") + + class BoardType(enum.Enum): + C167CR_SINGLE_PROCESSOR_BOARD = 0 + C167CR_DUAL_PROCESSOR_BOARD = 1 + LPC2468_XE167_DUAL_PROCESSOR_BOARD = 2 + LPC2468_SINGLE_PROCESSOR_BOARD = 5 + UNKNOWN = -1 + + @action(auto_prefix=True, description="获取电子板类型。") + async def request_electronic_board_type(self): + _unilab_logger.debug("[UNILAB] STARBackend.request_electronic_board_type() called") + """Request electronic board type + + Returns: + The board type. + """ + + resp = await self.send_command(module="C0", command="QB") + try: + return STARBackend.BoardType(resp["qb"]) + except ValueError: + return STARBackend.BoardType.UNKNOWN + + # TODO: parse response. + @action(auto_prefix=True, description="获取供电电压。") + async def request_supply_voltage(self): + _unilab_logger.debug("[UNILAB] STARBackend.request_supply_voltage() called") + """Request supply voltage + + Request supply voltage (for LDPB only) + """ + + return await self.send_command(module="C0", command="MU") + + @action(auto_prefix=True, description="获取整机初始化状态。") + async def request_instrument_initialization_status(self) -> bool: + _unilab_logger.debug("[UNILAB] STARBackend.request_instrument_initialization_status() called") + """Request instrument initialization status""" + + resp = await self.send_command(module="C0", command="QW", fmt="qw#") + return resp is not None and resp["qw"] == 1 + + @action(auto_prefix=True, description="获取autoload初始化状态。") + async def request_autoload_initialization_status(self) -> bool: + _unilab_logger.debug("[UNILAB] STARBackend.request_autoload_initialization_status() called") + """Request autoload initialization status""" + + resp = await self.send_command(module="I0", command="QW", fmt="qw#") + return resp is not None and resp["qw"] == 1 + + @action(auto_prefix=True, description="获取上次错误参数名称。") + async def request_name_of_last_faulty_parameter(self): + _unilab_logger.debug("[UNILAB] STARBackend.request_name_of_last_faulty_parameter() called") + """Request name of last faulty parameter + + Returns: TODO: + Name of last parameter with syntax error + (optional) received value separated with blank + (optional) minimal permitted value separated with blank (optional) + maximal permitted value separated with blank example with min max data: + Vpid2233er00/00vpth 00000 03500 example without min max data: Vpid2233er00/00vpcd + """ + + return await self.send_command(module="C0", command="VP", fmt="vp&&") + + @action(auto_prefix=True, description="获取主控状态。") + async def request_master_status(self): + _unilab_logger.debug("[UNILAB] STARBackend.request_master_status() called") + """Request master status + + Returns: TODO: see page 19 (SFCO.0036) + """ + + return await self.send_command(module="C0", command="RQ") + + @action(auto_prefix=True, description="获取已安装存在传感器数量。") + async def request_number_of_presence_sensors_installed(self): + _unilab_logger.debug("[UNILAB] STARBackend.request_number_of_presence_sensors_installed() called") + """Request number of presence sensors installed + + Returns: + number of sensors installed (1...103) + """ + + resp = await self.send_command(module="C0", command="SR") + return resp["sr"] + + @action(auto_prefix=True, description="获取EEPROM数据正确性状态。") + async def request_eeprom_data_correctness(self): + _unilab_logger.debug("[UNILAB] STARBackend.request_eeprom_data_correctness() called") + """Request EEPROM data correctness + + Returns: TODO: (SFCO.0149) + """ + + return await self.send_command(module="C0", command="QV") + + # -------------- 3.3 Settings -------------- + + # -------------- 3.3.1 Volatile Settings -------------- + + @action(auto_prefix=True, description="设置单步模式。") + async def set_single_step_mode(self, single_step_mode: bool = False): + _unilab_logger.debug("[UNILAB] STARBackend.set_single_step_mode() called") + """Set Single step mode + + Args: + single_step_mode: Single Step Mode. Default False. + """ + + return await self.send_command( + module="C0", + command="AM", + am=single_step_mode, + ) + + @action(auto_prefix=True, description="触发下一单步。") + async def trigger_next_step(self): + _unilab_logger.debug("[UNILAB] STARBackend.trigger_next_step() called") + """Trigger next step (Single step mode)""" + + # TODO: this command has no reply!!!! + return await self.send_command(module="C0", command="NS") + + @action(auto_prefix=True, description="停止后续命令并完成当前序列。") + async def halt(self): + _unilab_logger.debug("[UNILAB] STARBackend.halt() called") + """Halt + + Intermediate sequences not yet carried out and the commands in + the command stack are discarded. Sequence already in process is + completed. + """ + + return await self.send_command(module="C0", command="HD") + + @action(auto_prefix=True, description="保存所有循环计数器。") + async def save_all_cycle_counters(self): + _unilab_logger.debug("[UNILAB] STARBackend.save_all_cycle_counters() called") + """Save all cycle counters + + Save all cycle counters of the instrument + """ + + return await self.send_command(module="C0", command="AZ") + + @action(auto_prefix=True, description="设置不停机模式。") + async def set_not_stop(self, non_stop): + _unilab_logger.debug("[UNILAB] STARBackend.set_not_stop() called") + """Set not stop mode + + Args: + non_stop: True if non stop mode should be turned on after command is sent. + """ + + if non_stop: + # TODO: this command has no reply!!!! + return await self.send_command(module="C0", command="AB") + else: + return await self.send_command(module="C0", command="AW") + + # -------------- 3.3.2 Non volatile settings (stored in EEPROM) -------------- + + @action(auto_prefix=True, description="存储安装数据。") + async def store_installation_data( + self, + date: datetime.datetime = datetime.datetime.now(), + serial_number: str = "0000", + ): + _unilab_logger.debug("[UNILAB] STARBackend.store_installation_data() called") + """Store installation data + + Args: + date: installation date. + """ + + assert len(serial_number) == 4, "serial number must be 4 chars long" + + return await self.send_command(module="C0", command="SI", si=date, sn=serial_number) + + @action(auto_prefix=True, description="存储验证数据。") + async def store_verification_data( + self, + verification_subject: int = 0, + date: datetime.datetime = datetime.datetime.now(), + verification_status: bool = False, + ): + _unilab_logger.debug("[UNILAB] STARBackend.store_verification_data() called") + """Store verification data + + Args: + verification_subject: verification subject. Default 0. Must be between 0 and 24. + date: verification date. + verification_status: verification status. + """ + + assert 0 <= verification_subject <= 24, "verification_subject must be between 0 and 24" + + return await self.send_command( + module="C0", + command="AV", + vo=verification_subject, + vd=date, + vs=verification_status, + ) + + @action(auto_prefix=True, description="写入附加时间戳。") + async def additional_time_stamp(self): + _unilab_logger.debug("[UNILAB] STARBackend.additional_time_stamp() called") + """Additional time stamp""" + + return await self.send_command(module="C0", command="AT") + + @action(auto_prefix=True, description="设置X轴与iSWAP的X偏移。") + async def set_x_offset_x_axis_iswap(self, x_offset: int): + _unilab_logger.debug("[UNILAB] STARBackend.set_x_offset_x_axis_iswap() called") + """Set X-offset X-axis <-> iSWAP + + Args: + x_offset: X-offset [0.1mm] + """ + + return await self.send_command(module="C0", command="AG", x_offset=x_offset) + + @action(auto_prefix=True, description="设置X轴与CoRe 96头的X偏移。") + async def set_x_offset_x_axis_core_96_head(self, x_offset: int): + _unilab_logger.debug("[UNILAB] STARBackend.set_x_offset_x_axis_core_96_head() called") + """Set X-offset X-axis <-> CoRe 96 head + + Args: + x_offset: X-offset [0.1mm] + """ + + return await self.send_command(module="C0", command="AF", x_offset=x_offset) + + @action(auto_prefix=True, description="设置X轴与纳升级移液头的X偏移。") + async def set_x_offset_x_axis_core_nano_pipettor_head(self, x_offset: int): + _unilab_logger.debug("[UNILAB] STARBackend.set_x_offset_x_axis_core_nano_pipettor_head() called") + """Set X-offset X-axis <-> CoRe 96 head + + Args: + x_offset: X-offset [0.1mm] + """ + + return await self.send_command(module="C0", command="AF", x_offset=x_offset) + + @action(auto_prefix=True, description="保存下载日期。") + async def save_download_date(self, date: datetime.datetime = datetime.datetime.now()): + _unilab_logger.debug("[UNILAB] STARBackend.save_download_date() called") + """Save Download date + + Args: + date: download date. Default now. + """ + + return await self.send_command( + module="C0", + command="AO", + ao=date, + ) + + @action(auto_prefix=True, description="保存组件技术状态。") + async def save_technical_status_of_assemblies(self, processor_board: str, power_supply: str): + _unilab_logger.debug("[UNILAB] STARBackend.save_technical_status_of_assemblies() called") + """Save technical status of assemblies + + Args: + processor_board: Processor board. Art.Nr./Rev./Ser.No. (000000/00/0000) + power_supply: Power supply. Art.Nr./Rev./Ser.No. (000000/00/0000) + """ + + return await self.send_command( + module="C0", + command="BT", + qt=processor_board + " " + power_supply, + ) + + @action(auto_prefix=True, description="设置仪器配置。") + async def set_instrument_configuration( + self, + configuration_data_1: Optional[str] = None, # TODO: configuration byte + configuration_data_2: Optional[str] = None, # TODO: configuration byte + configuration_data_3: Optional[str] = None, # TODO: configuration byte + instrument_size_in_slots_x_range: int = 54, + auto_load_size_in_slots: int = 54, + tip_waste_x_position: int = 13400, + right_x_drive_configuration_byte_1: int = 0, + right_x_drive_configuration_byte_2: int = 0, + minimal_iswap_collision_free_position: int = 3500, + maximal_iswap_collision_free_position: int = 11400, + left_x_arm_width: int = 3700, + right_x_arm_width: int = 3700, + num_pip_channels: int = 0, + num_xl_channels: int = 0, + num_robotic_channels: int = 0, + minimal_raster_pitch_of_pip_channels: int = 90, + minimal_raster_pitch_of_xl_channels: int = 360, + minimal_raster_pitch_of_robotic_channels: int = 360, + pip_maximal_y_position: int = 6065, + left_arm_minimal_y_position: int = 60, + right_arm_minimal_y_position: int = 60, + ): + _unilab_logger.debug("[UNILAB] STARBackend.set_instrument_configuration() called") + """Set instrument configuration + + Args: + configuration_data_1: configuration data 1. + configuration_data_2: configuration data 2. + configuration_data_3: configuration data 3. + instrument_size_in_slots_x_range: instrument size in slots (X range). + Must be between 10 and 99. Default 54. + auto_load_size_in_slots: auto load size in slots. Must be between 10 + and 54. Default 54. + tip_waste_x_position: tip waste X-position. Must be between 1000 and + 25000. Default 13400. + right_x_drive_configuration_byte_1: right X drive configuration byte 1 (see + xl parameter bits). Must be between 0 and 1. Default 0. # TODO: this. + right_x_drive_configuration_byte_2: right X drive configuration byte 2 (see + xn parameter bits). Must be between 0 and 1. Default 0. # TODO: this. + minimal_iswap_collision_free_position: minimal iSWAP collision free position for + direct X access. For explanation of calculation see Fig. 4. Must be between 0 and 30000. + Default 3500. + maximal_iswap_collision_free_position: maximal iSWAP collision free position for + direct X access. For explanation of calculation see Fig. 4. Must be between 0 and 30000. + Default 11400 + left_x_arm_width: width of left X arm [0.1 mm]. Must be between 0 and 9999. Default 3700. + right_x_arm_width: width of right X arm [0.1 mm]. Must be between 0 and 9999. Default 3700. + num_pip_channels: number of PIP channels. Must be between 0 and 16. Default 0. + num_xl_channels: number of XL channels. Must be between 0 and 8. Default 0. + num_robotic_channels: number of Robotic channels. Must be between 0 and 8. Default 0. + minimal_raster_pitch_of_pip_channels: minimal raster pitch of PIP channels [0.1 mm]. Must + be between 0 and 999. Default 90. + minimal_raster_pitch_of_xl_channels: minimal raster pitch of XL channels [0.1 mm]. Must be + between 0 and 999. Default 360. + minimal_raster_pitch_of_robotic_channels: minimal raster pitch of Robotic channels [0.1 mm]. + Must be between 0 and 999. Default 360. + pip_maximal_y_position: PIP maximal Y position [0.1 mm]. Must be between 0 and 9999. + Default 6065. + left_arm_minimal_y_position: left arm minimal Y position [0.1 mm]. Must be between 0 and 9999. + Default 60. + right_arm_minimal_y_position: right arm minimal Y position [0.1 mm]. Must be between 0 + and 9999. Default 60. + """ + + assert ( + 1 <= instrument_size_in_slots_x_range <= 9 + ), "instrument_size_in_slots_x_range must be between 1 and 99" + assert 1 <= auto_load_size_in_slots <= 54, "auto_load_size_in_slots must be between 1 and 54" + assert 1000 <= tip_waste_x_position <= 25000, "tip_waste_x_position must be between 1 and 25000" + assert ( + 0 <= right_x_drive_configuration_byte_1 <= 1 + ), "right_x_drive_configuration_byte_1 must be between 0 and 1" + assert ( + 0 <= right_x_drive_configuration_byte_2 <= 1 + ), "right_x_drive_configuration_byte_2 must be between 0 and must1" + assert ( + 0 <= minimal_iswap_collision_free_position <= 30000 + ), "minimal_iswap_collision_free_position must be between 0 and 30000" + assert ( + 0 <= maximal_iswap_collision_free_position <= 30000 + ), "maximal_iswap_collision_free_position must be between 0 and 30000" + assert 0 <= left_x_arm_width <= 9999, "left_x_arm_width must be between 0 and 9999" + assert 0 <= right_x_arm_width <= 9999, "right_x_arm_width must be between 0 and 9999" + assert 0 <= num_pip_channels <= 16, "num_pip_channels must be between 0 and 16" + assert 0 <= num_xl_channels <= 8, "num_xl_channels must be between 0 and 8" + assert 0 <= num_robotic_channels <= 8, "num_robotic_channels must be between 0 and 8" + assert ( + 0 <= minimal_raster_pitch_of_pip_channels <= 999 + ), "minimal_raster_pitch_of_pip_channels must be between 0 and 999" + assert ( + 0 <= minimal_raster_pitch_of_xl_channels <= 999 + ), "minimal_raster_pitch_of_xl_channels must be between 0 and 999" + assert ( + 0 <= minimal_raster_pitch_of_robotic_channels <= 999 + ), "minimal_raster_pitch_of_robotic_channels must be between 0 and 999" + assert 0 <= pip_maximal_y_position <= 9999, "pip_maximal_y_position must be between 0 and 9999" + assert ( + 0 <= left_arm_minimal_y_position <= 9999 + ), "left_arm_minimal_y_position must be between 0 and 9999" + assert ( + 0 <= right_arm_minimal_y_position <= 9999 + ), "right_arm_minimal_y_position must be between 0 and 9999" + + return await self.send_command( + module="C0", + command="AK", + kb=configuration_data_1, + ka=configuration_data_2, + ke=configuration_data_3, + xt=instrument_size_in_slots_x_range, + xa=auto_load_size_in_slots, + xw=tip_waste_x_position, + xr=right_x_drive_configuration_byte_1, + xo=right_x_drive_configuration_byte_2, + xm=minimal_iswap_collision_free_position, + xx=maximal_iswap_collision_free_position, + xu=left_x_arm_width, + xv=right_x_arm_width, + kp=num_pip_channels, + kc=num_xl_channels, + kr=num_robotic_channels, + ys=minimal_raster_pitch_of_pip_channels, + kl=minimal_raster_pitch_of_xl_channels, + km=minimal_raster_pitch_of_robotic_channels, + ym=pip_maximal_y_position, + yu=left_arm_minimal_y_position, + yx=right_arm_minimal_y_position, + ) + + @action(auto_prefix=True, description="保存PIP通道验证状态。") + async def save_pip_channel_validation_status(self, validation_status: bool = False): + _unilab_logger.debug("[UNILAB] STARBackend.save_pip_channel_validation_status() called") + """Save PIP channel validation status + + Args: + validation_status: PIP channel validation status. Default False. + """ + + return await self.send_command( + module="C0", + command="AJ", + tq=validation_status, + ) + + @action(auto_prefix=True, description="保存XL通道验证状态。") + async def save_xl_channel_validation_status(self, validation_status: bool = False): + _unilab_logger.debug("[UNILAB] STARBackend.save_xl_channel_validation_status() called") + """Save XL channel validation status + + Args: + validation_status: XL channel validation status. Default False. + """ + + return await self.send_command( + module="C0", + command="AE", + tx=validation_status, + ) + + # TODO: response + @action(auto_prefix=True, description="配置节点名称。") + async def configure_node_names(self): + _unilab_logger.debug("[UNILAB] STARBackend.configure_node_names() called") + """Configure node names""" + + return await self.send_command(module="C0", command="AJ") + + @action(auto_prefix=True, description="设置deck数据。") + async def set_deck_data(self, data_index: int = 0, data_stream: str = "0"): + _unilab_logger.debug("[UNILAB] STARBackend.set_deck_data() called") + """set deck data + + Args: + data_index: data index. Must be between 0 and 9. Default 0. + data_stream: data stream (12 characters). Default . + """ + + assert 0 <= data_index <= 9, "data_index must be between 0 and 9" + assert len(data_stream) == 12, "data_stream must be 12 chars" + + return await self.send_command( + module="C0", + command="DD", + vi=data_index, + vj=data_stream, + ) + + # -------------- 3.3.3 Settings query (stored in EEPROM) -------------- + + @action(auto_prefix=True, description="获取组件技术状态。") + async def request_technical_status_of_assemblies(self): + _unilab_logger.debug("[UNILAB] STARBackend.request_technical_status_of_assemblies() called") + """Request Technical status of assemblies""" + + # TODO: parse res + return await self.send_command(module="C0", command="QT") + + @action(auto_prefix=True, description="获取安装数据。") + async def request_installation_data(self): + _unilab_logger.debug("[UNILAB] STARBackend.request_installation_data() called") + """Request installation data""" + + # TODO: parse res + return await self.send_command(module="C0", command="RI") + + @action(auto_prefix=True, description="获取设备序列号。") + async def request_device_serial_number(self) -> str: + _unilab_logger.debug("[UNILAB] STARBackend.request_device_serial_number() called") + """Request device serial number""" + return (await self.send_command("C0", "RI", fmt="si####sn&&&&sn&&&&"))["sn"] # type: ignore + + @action(auto_prefix=True, description="获取下载日期。") + async def request_download_date(self): + _unilab_logger.debug("[UNILAB] STARBackend.request_download_date() called") + """Request download date""" + + # TODO: parse res + return await self.send_command(module="C0", command="RO") + + @action(auto_prefix=True, description="获取验证数据。") + async def request_verification_data(self, verification_subject: int = 0): + _unilab_logger.debug("[UNILAB] STARBackend.request_verification_data() called") + """Request download date + + Args: + verification_subject: verification subject. Must be between 0 and 24. Default 0. + """ + + assert 0 <= verification_subject <= 24, "verification_subject must be between 0 and 24" + + # TODO: parse results. + return await self.send_command(module="C0", command="RO", vo=verification_subject) + + @action(auto_prefix=True, description="获取附加时间戳数据。") + async def request_additional_timestamp_data(self): + _unilab_logger.debug("[UNILAB] STARBackend.request_additional_timestamp_data() called") + """Request additional timestamp data""" + + # TODO: parse res + return await self.send_command(module="C0", command="RS") + + @action(auto_prefix=True, description="获取PIP通道验证状态。") + async def request_pip_channel_validation_status(self): + _unilab_logger.debug("[UNILAB] STARBackend.request_pip_channel_validation_status() called") + """Request PIP channel validation status""" + + # TODO: parse res + return await self.send_command(module="C0", command="RJ") + + @action(auto_prefix=True, description="获取XL通道验证状态。") + async def request_xl_channel_validation_status(self): + _unilab_logger.debug("[UNILAB] STARBackend.request_xl_channel_validation_status() called") + """Request XL channel validation status""" + + # TODO: parse res + return await self.send_command(module="C0", command="UJ") + + @action(auto_prefix=True, description="获取机器配置。") + async def request_machine_configuration(self): + _unilab_logger.debug("[UNILAB] STARBackend.request_machine_configuration() called") + """Request machine configuration""" + + # TODO: parse res + return await self.send_command(module="C0", command="RM", fmt="kb**kp**") + + @action(auto_prefix=True, description="获取扩展配置。") + async def request_extended_configuration(self): + _unilab_logger.debug("[UNILAB] STARBackend.request_extended_configuration() called") + """Request extended configuration""" + + return await self.send_command( + module="C0", + command="QM", + fmt="ka******ke********xt##xa##xw#####xl**xn**xr**xo**xm#####xx#####xu####xv####kc#kr#ys###" + + "kl###km###ym####yu####yx####", + ) + + @action(auto_prefix=True, description="获取节点名称。") + async def request_node_names(self): + _unilab_logger.debug("[UNILAB] STARBackend.request_node_names() called") + """Request node names""" + + # TODO: parse res + return await self.send_command(module="C0", command="RK") + + @action(auto_prefix=True, description="获取平台deck数据。") + async def request_deck_data(self): + _unilab_logger.debug("[UNILAB] STARBackend.request_deck_data() called") + """Request deck data""" + + # TODO: parse res + return await self.send_command(module="C0", command="VD") + + # -------------- 3.4 X-Axis control -------------- + + # -------------- 3.4.1 Movements -------------- + + @action(auto_prefix=True, description="定位左X臂。") + async def position_left_x_arm_(self, x_position: int = 0): + _unilab_logger.debug("[UNILAB] STARBackend.position_left_x_arm_() called") + """Position left X-Arm + + Collision risk! + + Args: + x_position: X-Position [0.1mm]. Must be between 0 and 30000. Default 0. + """ + + assert 0 <= x_position <= 30000, "x_position_ must be between 0 and 30000" + + return await self.send_command( + module="C0", + command="JX", + xs=f"{x_position:05}", + ) + + @action(auto_prefix=True, description="定位右X臂。") + async def position_right_x_arm_(self, x_position: int = 0): + _unilab_logger.debug("[UNILAB] STARBackend.position_right_x_arm_() called") + """Position right X-Arm + + Collision risk! + + Args: + x_position: X-Position [0.1mm]. Must be between 0 and 30000. Default 0. + """ + + assert 0 <= x_position <= 30000, "x_position_ must be between 0 and 30000" + + return await self.send_command( + module="C0", + command="JS", + xs=f"{x_position:05}", + ) + + @action(auto_prefix=True, description="在附件处于Z安全位时移动左X臂。") + async def move_left_x_arm_to_position_with_all_attached_components_in_z_safety_position( + self, x_position: int = 0 + ): + _unilab_logger.debug("[UNILAB] STARBackend.move_left_x_arm_to_position_with_all_attached_components_in_z_safety_position() called") + """Move left X-arm to position with all attached components in Z-safety position + + Args: + x_position: X-Position [0.1mm]. Must be between 0 and 30000. Default 0. + """ + + assert 0 <= x_position <= 30000, "x_position must be between 0 and 30000" + + return await self.send_command( + module="C0", + command="KX", + xs=x_position, + ) + + @action(auto_prefix=True, description="在附件处于Z安全位时移动右X臂。") + async def move_right_x_arm_to_position_with_all_attached_components_in_z_safety_position( + self, x_position: int = 0 + ): + _unilab_logger.debug("[UNILAB] STARBackend.move_right_x_arm_to_position_with_all_attached_components_in_z_safety_position() called") + """Move right X-arm to position with all attached components in Z-safety position + + Args: + x_position: X-Position [0.1mm]. Must be between 0 and 30000. Default 0. + """ + + assert 0 <= x_position <= 30000, "x_position must be between 0 and 30000" + + return await self.send_command( + module="C0", + command="KR", + xs=x_position, + ) + + # -------------- 3.4.2 X-Area reservation for external access -------------- + + @action(auto_prefix=True, description="预留区域供外部访问。") + async def occupy_and_provide_area_for_external_access( + self, + taken_area_identification_number: int = 0, + taken_area_left_margin: int = 0, + taken_area_left_margin_direction: int = 0, + taken_area_size: int = 0, + arm_preposition_mode_related_to_taken_areas: int = 0, + ): + _unilab_logger.debug("[UNILAB] STARBackend.occupy_and_provide_area_for_external_access() called") + """Occupy and provide area for external access + + Args: + taken_area_identification_number: taken area identification number. Must be between 0 and + 9999. Default 0. + taken_area_left_margin: taken area left margin. Must be between 0 and 99. Default 0. + taken_area_left_margin_direction: taken area left margin direction. 1 = negative. Must be + between 0 and 1. Default 0. + taken_area_size: taken area size. Must be between 0 and 50000. Default 0. + arm_preposition_mode_related_to_taken_areas: 0) left arm to left & right arm to right. + 1) all arms left. 2) all arms right. + """ + + assert ( + 0 <= taken_area_identification_number <= 9999 + ), "taken_area_identification_number must be between 0 and 9999" + assert 0 <= taken_area_left_margin <= 99, "taken_area_left_margin must be between 0 and 99" + assert ( + 0 <= taken_area_left_margin_direction <= 1 + ), "taken_area_left_margin_direction must be between 0 and 1" + assert 0 <= taken_area_size <= 50000, "taken_area_size must be between 0 and 50000" + assert ( + 0 <= arm_preposition_mode_related_to_taken_areas <= 2 + ), "arm_preposition_mode_related_to_taken_areas must be between 0 and 2" + + return await self.send_command( + module="C0", + command="BA", + aq=taken_area_identification_number, + al=taken_area_left_margin, + ad=taken_area_left_margin_direction, + ar=taken_area_size, + ap=arm_preposition_mode_related_to_taken_areas, + ) + + @action(auto_prefix=True, description="释放指定预留区域。") + async def release_occupied_area(self, taken_area_identification_number: int = 0): + _unilab_logger.debug("[UNILAB] STARBackend.release_occupied_area() called") + """Release occupied area + + Args: + taken_area_identification_number: taken area identification number. + Must be between 0 and 9999. Default 0. + """ + + assert ( + 0 <= taken_area_identification_number <= 999 + ), "taken_area_identification_number must be between 0 and 9999" + + return await self.send_command( + module="C0", + command="BB", + aq=taken_area_identification_number, + ) + + @action(auto_prefix=True, description="释放全部预留区域。") + async def release_all_occupied_areas(self): + _unilab_logger.debug("[UNILAB] STARBackend.release_all_occupied_areas() called") + """Release all occupied areas""" + + return await self.send_command(module="C0", command="BC") + + # -------------- 3.4.3 X-query -------------- + + @action(auto_prefix=True, description="获取左X臂位置。") + async def request_left_x_arm_position(self) -> float: + _unilab_logger.debug("[UNILAB] STARBackend.request_left_x_arm_position() called") + """Request left X-Arm position""" + resp_dmm = await self.send_command(module="C0", command="RX", fmt="rx#####") + return cast(float, resp_dmm["rx"]) / 10 + + @action(auto_prefix=True, description="获取右X臂位置。") + async def request_right_x_arm_position(self) -> float: + _unilab_logger.debug("[UNILAB] STARBackend.request_right_x_arm_position() called") + """Request right X-Arm position""" + + resp_dmm = await self.send_command(module="C0", command="QX", fmt="rx#####") + return cast(float, resp_dmm["rx"]) / 10 + + @action(auto_prefix=True, description="获取X驱动最大行程范围。") + async def request_maximal_ranges_of_x_drives(self): + _unilab_logger.debug("[UNILAB] STARBackend.request_maximal_ranges_of_x_drives() called") + """Request maximal ranges of X drives""" + + return await self.send_command(module="C0", command="RU") + + @action(auto_prefix=True, description="获取已安装机械臂的当前包络尺寸。") + async def request_present_wrap_size_of_installed_arms(self): + _unilab_logger.debug("[UNILAB] STARBackend.request_present_wrap_size_of_installed_arms() called") + """Request present wrap size of installed arms""" + + return await self.send_command(module="C0", command="UA") + + @action(auto_prefix=True, description="获取左X臂上次碰撞类型。") + async def request_left_x_arm_last_collision_type(self): + _unilab_logger.debug("[UNILAB] STARBackend.request_left_x_arm_last_collision_type() called") + """Request left X-Arm last collision type (after error 27) + + Returns: + False if present positions collide (not reachable), + True if position is never reachable. + """ + + resp = await self.send_command(module="C0", command="XX", fmt="xq#") + return resp["xq"] == 1 + + @action(auto_prefix=True, description="获取右X臂上次碰撞类型。") + async def request_right_x_arm_last_collision_type(self) -> bool: + _unilab_logger.debug("[UNILAB] STARBackend.request_right_x_arm_last_collision_type() called") + """Request right X-Arm last collision type (after error 27) + + Returns: + False if present positions collide (not reachable), + True if position is never reachable. + """ + + resp = await self.send_command(module="C0", command="XR", fmt="xq#") + return cast(int, resp["xq"]) == 1 + + # -------------- 3.5 Pipetting channel commands -------------- + + # -------------- 3.5.1 Initialization -------------- + + @action(auto_prefix=True, description="初始化移液通道。") + async def initialize_pip(self): + _unilab_logger.debug("[UNILAB] STARBackend.initialize_pip() called") + """Wrapper around initialize_pipetting_channels firmware command.""" + dy = (4050 - 2175) // (self.num_channels - 1) + y_positions = [4050 - i * dy for i in range(self.num_channels)] + + await self.initialize_pipetting_channels( + x_positions=[self.extended_conf["xw"]], # Tip eject waste X position. + y_positions=y_positions, + begin_of_tip_deposit_process=int(self._channel_traversal_height * 10), + end_of_tip_deposit_process=1220, + z_position_at_end_of_a_command=3600, + tip_pattern=[True] * self.num_channels, + tip_type=4, # TODO: get from tip types + discarding_method=0, + ) + + @action(auto_prefix=True, description="初始化移液通道并处理吸头状态。") + async def initialize_pipetting_channels( + self, + x_positions: List[int] = [0], + y_positions: List[int] = [0], + begin_of_tip_deposit_process: int = 0, + end_of_tip_deposit_process: int = 0, + z_position_at_end_of_a_command: int = 3600, + tip_pattern: List[bool] = [True], + tip_type: int = 16, + discarding_method: int = 1, + ): + _unilab_logger.debug("[UNILAB] STARBackend.initialize_pipetting_channels() called") + """Initialize pipetting channels + + Initialize pipetting channels (discard tips) + + Args: + x_positions: X-Position [0.1mm] (discard position). Must be between 0 and 25000. Default 0. + y_positions: y-Position [0.1mm] (discard position). Must be between 0 and 6500. Default 0. + begin_of_tip_deposit_process: Begin of tip deposit process (Z-discard range) [0.1mm]. Must be + between 0 and 3600. Default 0. + end_of_tip_deposit_process: End of tip deposit process (Z-discard range) [0.1mm]. Must be + between 0 and 3600. Default 0. + z-position_at_end_of_a_command: Z-Position at end of a command [0.1mm]. Must be between 0 and + 3600. Default 3600. + tip_pattern: Tip pattern ( channels involved). Default True. + tip_type: Tip type (recommended is index of longest tip see command 'TT') [0.1mm]. Must be + between 0 and 99. Default 16. + discarding_method: discarding method. 0 = place & shift (tp/ tz = tip cone end height), 1 = + drop (no shift) (tp/ tz = stop disk height). Must be between 0 and 1. Default 1. + """ + + assert all(0 <= xp <= 25000 for xp in x_positions), "x_positions must be between 0 and 25000" + assert all(0 <= yp <= 6500 for yp in y_positions), "y_positions must be between 0 and 6500" + assert ( + 0 <= begin_of_tip_deposit_process <= 3600 + ), "begin_of_tip_deposit_process must be between 0 and 3600" + assert ( + 0 <= end_of_tip_deposit_process <= 3600 + ), "end_of_tip_deposit_process must be between 0 and 3600" + assert ( + 0 <= z_position_at_end_of_a_command <= 3600 + ), "z_position_at_end_of_a_command must be between 0 and 3600" + assert 0 <= tip_type <= 99, "tip must be between 0 and 99" + assert 0 <= discarding_method <= 1, "discarding_method must be between 0 and 1" + + return await self.send_command( + module="C0", + command="DI", + read_timeout=120, + xp=[f"{xp:05}" for xp in x_positions], + yp=[f"{yp:04}" for yp in y_positions], + tp=f"{begin_of_tip_deposit_process:04}", + tz=f"{end_of_tip_deposit_process:04}", + te=f"{z_position_at_end_of_a_command:04}", + tm=[f"{tm:01}" for tm in tip_pattern], + tt=f"{tip_type:02}", + ti=discarding_method, + ) + + # -------------- 3.5.2 Tip handling commands using PIP -------------- + + @need_iswap_parked + @action(auto_prefix=True, description="拾取单通道吸头。") + async def pick_up_tip( + self, + x_positions: List[int], + y_positions: List[int], + tip_pattern: List[bool], + tip_type_idx: int, + begin_tip_pick_up_process: int = 0, + end_tip_pick_up_process: int = 0, + minimum_traverse_height_at_beginning_of_a_command: int = 3600, + pickup_method: TipPickupMethod = TipPickupMethod.OUT_OF_RACK, + ): + _unilab_logger.debug("[UNILAB] STARBackend.pick_up_tip() called") + """Tip Pick-up + + Args: + x_positions: x positions [0.1mm]. Must be between 0 and 25000. Default 0. + y_positions: y positions [0.1mm]. Must be between 0 and 6500. Default 0. + tip_pattern: Tip pattern (channels involved). + tip_type_idx: Tip type. + begin_tip_pick_up_process: Begin of tip picking up process (Z- range) [0.1mm]. Must be + between 0 and 3600. Default 0. + end_tip_pick_up_process: End of tip picking up process (Z- range) [0.1mm]. Must be + between 0 and 3600. Default 0. + minimum_traverse_height_at_beginning_of_a_command: Minimum traverse height at beginning + of a command 0.1mm] (refers to all channels independent of tip pattern parameter 'tm'). + Must be between 0 and 3600. Default 3600. + pickup_method: Pick up method. + """ + + assert all(0 <= xp <= 25000 for xp in x_positions), "x_positions must be between 0 and 25000" + assert all(0 <= yp <= 6500 for yp in y_positions), "y_positions must be between 0 and 6500" + assert ( + 0 <= begin_tip_pick_up_process <= 3600 + ), "begin_tip_pick_up_process must be between 0 and 3600" + assert ( + 0 <= end_tip_pick_up_process <= 3600 + ), "end_tip_pick_up_process must be between 0 and 3600" + assert ( + 0 <= minimum_traverse_height_at_beginning_of_a_command <= 3600 + ), "minimum_traverse_height_at_beginning_of_a_command must be between 0 and 3600" + + return await self.send_command( + module="C0", + command="TP", + tip_pattern=tip_pattern, + read_timeout=max(120, self.read_timeout), + xp=[f"{x:05}" for x in x_positions], + yp=[f"{y:04}" for y in y_positions], + tm=tip_pattern, + tt=f"{tip_type_idx:02}", + tp=f"{begin_tip_pick_up_process:04}", + tz=f"{end_tip_pick_up_process:04}", + th=f"{minimum_traverse_height_at_beginning_of_a_command:04}", + td=pickup_method.value, + ) + + @need_iswap_parked + @action(auto_prefix=True, description="丢弃单通道吸头。") + async def discard_tip( + self, + x_positions: List[int], + y_positions: List[int], + tip_pattern: List[bool], + begin_tip_deposit_process: int = 0, + end_tip_deposit_process: int = 0, + minimum_traverse_height_at_beginning_of_a_command: int = 3600, + z_position_at_end_of_a_command: int = 3600, + discarding_method: TipDropMethod = TipDropMethod.DROP, + ): + _unilab_logger.debug("[UNILAB] STARBackend.discard_tip() called") + """discard tip + + Args: + x_positions: x positions [0.1mm]. Must be between 0 and 25000. Default 0. + y_positions: y positions [0.1mm]. Must be between 0 and 6500. Default 0. + tip_pattern: Tip pattern (channels involved). Must be between 0 and 1. Default 1. + begin_tip_deposit_process: Begin of tip deposit process (Z- range) [0.1mm]. Must be between + 0 and 3600. Default 0. + end_tip_deposit_process: End of tip deposit process (Z- range) [0.1mm]. Must be between 0 + and 3600. + minimum_traverse_height_at_beginning_of_a_command: Minimum traverse height at beginning of a + command 0.1mm] (refers to all channels independent of tip pattern parameter 'tm'). Must + be between 0 and 3600. + z-position_at_end_of_a_command: Z-Position at end of a command [0.1mm]. + Must be between 0 and 3600. + discarding_method: Pick up method Pick up method. 0 = auto selection (see command TT + parameter tu) 1 = pick up out of rack. 2 = pick up out of wash liquid (slowly). Must be + between 0 and 2. + + If discarding is PLACE_SHIFT (0), tp/ tz = tip cone end height. + Otherwise, tp/ tz = stop disk height. + """ + + assert all(0 <= xp <= 25000 for xp in x_positions), "x_positions must be between 0 and 25000" + assert all(0 <= yp <= 6500 for yp in y_positions), "y_positions must be between 0 and 6500" + assert ( + 0 <= begin_tip_deposit_process <= 3600 + ), "begin_tip_deposit_process must be between 0 and 3600" + assert ( + 0 <= end_tip_deposit_process <= 3600 + ), "end_tip_deposit_process must be between 0 and 3600" + assert ( + 0 <= minimum_traverse_height_at_beginning_of_a_command <= 3600 + ), "minimum_traverse_height_at_beginning_of_a_command must be between 0 and 3600" + assert ( + 0 <= z_position_at_end_of_a_command <= 3600 + ), "z_position_at_end_of_a_command must be between 0 and 3600" + + return await self.send_command( + module="C0", + command="TR", + tip_pattern=tip_pattern, + read_timeout=max(120, self.read_timeout), + xp=[f"{x:05}" for x in x_positions], + yp=[f"{y:04}" for y in y_positions], + tm=tip_pattern, + tp=begin_tip_deposit_process, + tz=end_tip_deposit_process, + th=minimum_traverse_height_at_beginning_of_a_command, + te=z_position_at_end_of_a_command, + ti=discarding_method.value, + ) + + # TODO:(command:TW) Tip Pick-up for DC wash procedure + + # -------------- 3.5.3 Liquid handling commands using PIP -------------- + + # TODO:(command:DC) Set multiple dispense values using PIP + + @need_iswap_parked + @action(auto_prefix=True, description="执行底层单通道吸液。") + async def aspirate_pip( + self, + aspiration_type: List[int] = [0], + tip_pattern: List[bool] = [True], + x_positions: List[int] = [0], + y_positions: List[int] = [0], + minimum_traverse_height_at_beginning_of_a_command: int = 3600, + min_z_endpos: int = 3600, + lld_search_height: List[int] = [0], + clot_detection_height: List[int] = [60], + liquid_surface_no_lld: List[int] = [3600], + pull_out_distance_transport_air: List[int] = [50], + second_section_height: List[int] = [0], + second_section_ratio: List[int] = [0], + minimum_height: List[int] = [3600], + immersion_depth: List[int] = [0], + immersion_depth_direction: List[int] = [0], + surface_following_distance: List[int] = [0], + aspiration_volumes: List[int] = [0], + aspiration_speed: List[int] = [500], + transport_air_volume: List[int] = [0], + blow_out_air_volume: List[int] = [200], + pre_wetting_volume: List[int] = [0], + lld_mode: List[int] = [1], + gamma_lld_sensitivity: List[int] = [1], + dp_lld_sensitivity: List[int] = [1], + aspirate_position_above_z_touch_off: List[int] = [5], + detection_height_difference_for_dual_lld: List[int] = [0], + swap_speed: List[int] = [100], + settling_time: List[int] = [5], + mix_volume: List[int] = [0], + mix_cycles: List[int] = [0], + mix_position_from_liquid_surface: List[int] = [250], + mix_speed: List[int] = [500], + mix_surface_following_distance: List[int] = [0], + limit_curve_index: List[int] = [0], + tadm_algorithm: bool = False, + recording_mode: int = 0, + # For second section aspiration only + use_2nd_section_aspiration: List[bool] = [False], + retract_height_over_2nd_section_to_empty_tip: List[int] = [60], + dispensation_speed_during_emptying_tip: List[int] = [468], + dosing_drive_speed_during_2nd_section_search: List[int] = [468], + z_drive_speed_during_2nd_section_search: List[int] = [215], + cup_upper_edge: List[int] = [3600], + # deprecated, remove >2026-06 + ratio_liquid_rise_to_tip_deep_in: Optional[List[int]] = None, + immersion_depth_2nd_section: Optional[List[int]] = None, + ): + _unilab_logger.debug("[UNILAB] STARBackend.aspirate_pip() called") + """aspirate pip + + Aspiration of liquid using PIP. + + It's not really clear what second section aspiration is, but it does not seem to be used + very often. Probably safe to ignore it. + + LLD restrictions! + - "dP and Dual LLD" are used in aspiration only. During dispensation LLD is set to OFF. + - "side touch off" turns LLD & "Z touch off" to OFF , is not available for simultaneous + Asp/Disp. command + + Args: + aspiration_type: Type of aspiration (0 = simple;1 = sequence; 2 = cup emptied). + Must be between 0 and 2. Default 0. + tip_pattern: Tip pattern (channels involved). Default True. + x_positions: x positions [0.1mm]. Must be between 0 and 25000. Default 0. + y_positions: y positions [0.1mm]. Must be between 0 and 6500. Default 0. + minimum_traverse_height_at_beginning_of_a_command: Minimum traverse height at beginning of + a command 0.1mm] (refers to all channels independent of tip pattern parameter 'tm'). + Must be between 0 and 3600. Default 3600. + min_z_endpos: Minimum z-Position at end of a command [0.1 mm] (refers to all channels + independent of tip pattern parameter 'tm'). Must be between 0 and 3600. Default 3600. + lld_search_height: LLD search height [0.1 mm]. Must be between 0 and 3600. Default 0. + clot_detection_height: Check height of clot detection above current surface (as computed) + of the liquid [0.1mm]. Must be between 0 and 500. Default 60. + liquid_surface_no_lld: Liquid surface at function without LLD [0.1mm]. Must be between 0 + and 3600. Default 3600. + pull_out_distance_transport_air: pull out distance to take transport air in function + without LLD [0.1mm]. Must be between 0 and 3600. Default 50. + second_section_height: Tube 2nd section height measured from "zx" [0.1mm]. Must be + between 0 and 3600. Default 0. + second_section_ratio: Tube 2nd section ratio (see Fig. 2 in fw guide). Must be between + 0 and 10000. Default 0. + minimum_height: Minimum height (maximum immersion depth) [0.1 mm]. Must be between 0 and + 3600. Default 3600. + immersion_depth: Immersion depth [0.1mm]. Must be between 0 and 3600. Default 0. + immersion_depth_direction: Direction of immersion depth (0 = go deeper, 1 = go up out + of liquid). Must be between 0 and 1. Default 0. + surface_following_distance: Surface following distance during aspiration [0.1mm]. Must + be between 0 and 3600. Default 0. + aspiration_volumes: Aspiration volume [0.1ul]. Must be between 0 and 12500. Default 0. + aspiration_speed: Aspiration speed [0.1ul/s]. Must be between 4 and 5000. Default 500. + transport_air_volume: Transport air volume [0.1ul]. Must be between 0 and 500. Default 0. + blow_out_air_volume: Blow-out air volume [0.1ul]. Must be between 0 and 9999. Default 200. + pre_wetting_volume: Pre-wetting volume. Must be between 0 and 999. Default 0. + lld_mode: LLD mode (0 = off, 1 = gamma, 2 = dP, 3 = dual, 4 = Z touch off). Must be + between 0 and 4. Default 1. + gamma_lld_sensitivity: gamma LLD sensitivity (1= high, 4=low). Must be between 1 and + 4. Default 1. + dp_lld_sensitivity: delta p LLD sensitivity (1= high, 4=low). Must be between 1 and + 4. Default 1. + aspirate_position_above_z_touch_off: aspirate position above Z touch off [0.1mm]. Must + be between 0 and 100. Default 5. + detection_height_difference_for_dual_lld: Difference in detection height for dual + LLD [0.1 mm]. Must be between 0 and 99. Default 0. + swap_speed: Swap speed (on leaving liquid) [0.1mm/s]. Must be between 3 and 1600. + Default 100. + settling_time: Settling time [0.1s]. Must be between 0 and 99. Default 5. + mix_volume: mix volume [0.1ul]. Must be between 0 and 12500. Default 0 + mix_cycles: Number of mix cycles. Must be between 0 and 99. Default 0. + mix_position_from_liquid_surface: mix position in Z- direction from + liquid surface (LLD or absolute terms) [0.1mm]. Must be between 0 and 900. Default 250. + mix_speed: Speed of mix [0.1ul/s]. Must be between 4 and 5000. + Default 500. + mix_surface_following_distance: Surface following distance during + mix [0.1mm]. Must be between 0 and 3600. Default 0. + limit_curve_index: limit curve index. Must be between 0 and 999. Default 0. + tadm_algorithm: TADM algorithm. Default False. + recording_mode: Recording mode 0 : no 1 : TADM errors only 2 : all TADM measurement. Must + be between 0 and 2. Default 0. + use_2nd_section_aspiration: 2nd section aspiration. Default False. + retract_height_over_2nd_section_to_empty_tip: Retract height over 2nd section to empty + tip [0.1mm]. Must be between 0 and 3600. Default 60. + dispensation_speed_during_emptying_tip: Dispensation speed during emptying tip [0.1ul/s] + Must be between 4 and 5000. Default 468. + dosing_drive_speed_during_2nd_section_search: Dosing drive speed during 2nd section + search [0.1ul/s]. Must be between 4 and 5000. Default 468. + z_drive_speed_during_2nd_section_search: Z drive speed during 2nd section search [0.1mm/s]. + Must be between 3 and 1600. Default 215. + cup_upper_edge: Cup upper edge [0.1mm]. Must be between 0 and 3600. Default 3600. + """ + + if ratio_liquid_rise_to_tip_deep_in is not None: + warnings.warn( + "ratio_liquid_rise_to_tip_deep_in is deprecated and will be removed in a future version.", + DeprecationWarning, + stacklevel=2, + ) + if immersion_depth_2nd_section is not None: + warnings.warn( + "immersion_depth_2nd_section is deprecated and will be removed in a future version.", + DeprecationWarning, + stacklevel=2, + ) + + assert all(0 <= x <= 2 for x in aspiration_type), "aspiration_type must be between 0 and 2" + assert all(0 <= xp <= 25000 for xp in x_positions), "x_positions must be between 0 and 25000" + assert all(0 <= yp <= 6500 for yp in y_positions), "y_positions must be between 0 and 6500" + assert ( + 0 <= minimum_traverse_height_at_beginning_of_a_command <= 3600 + ), "minimum_traverse_height_at_beginning_of_a_command must be between 0 and 3600" + assert 0 <= min_z_endpos <= 3600, "min_z_endpos must be between 0 and 3600" + assert all( + 0 <= x <= 3600 for x in lld_search_height + ), "lld_search_height must be between 0 and 3600" + assert all( + 0 <= x <= 500 for x in clot_detection_height + ), "clot_detection_height must be between 0 and 500" + assert all( + 0 <= x <= 3600 for x in liquid_surface_no_lld + ), "liquid_surface_no_lld must be between 0 and 3600" + assert all( + 0 <= x <= 3600 for x in pull_out_distance_transport_air + ), "pull_out_distance_transport_air must be between 0 and 3600" + assert all( + 0 <= x <= 3600 for x in second_section_height + ), "second_section_height must be between 0 and 3600" + assert all( + 0 <= x <= 10000 for x in second_section_ratio + ), "second_section_ratio must be between 0 and 10000" + assert all(0 <= x <= 3600 for x in minimum_height), "minimum_height must be between 0 and 3600" + assert all( + 0 <= x <= 3600 for x in immersion_depth + ), "immersion_depth must be between 0 and 3600" + assert all( + 0 <= x <= 1 for x in immersion_depth_direction + ), "immersion_depth_direction must be between 0 and 1" + assert all( + 0 <= x <= 3600 for x in surface_following_distance + ), "surface_following_distance must be between 0 and 3600" + assert all( + 0 <= x <= 12500 for x in aspiration_volumes + ), "aspiration_volumes must be between 0 and 12500" + assert all( + 4 <= x <= 5000 for x in aspiration_speed + ), "aspiration_speed must be between 4 and 5000" + assert all( + 0 <= x <= 500 for x in transport_air_volume + ), "transport_air_volume must be between 0 and 500" + assert all( + 0 <= x <= 9999 for x in blow_out_air_volume + ), "blow_out_air_volume must be between 0 and 9999" + assert all( + 0 <= x <= 999 for x in pre_wetting_volume + ), "pre_wetting_volume must be between 0 and 999" + assert all(0 <= x <= 4 for x in lld_mode), "lld_mode must be between 0 and 4" + assert all( + 1 <= x <= 4 for x in gamma_lld_sensitivity + ), "gamma_lld_sensitivity must be between 1 and 4" + assert all( + 1 <= x <= 4 for x in dp_lld_sensitivity + ), "dp_lld_sensitivity must be between 1 and 4" + assert all( + 0 <= x <= 100 for x in aspirate_position_above_z_touch_off + ), "aspirate_position_above_z_touch_off must be between 0 and 100" + assert all( + 0 <= x <= 99 for x in detection_height_difference_for_dual_lld + ), "detection_height_difference_for_dual_lld must be between 0 and 99" + assert all(3 <= x <= 1600 for x in swap_speed), "swap_speed must be between 3 and 1600" + assert all(0 <= x <= 99 for x in settling_time), "settling_time must be between 0 and 99" + assert all(0 <= x <= 12500 for x in mix_volume), "mix_volume must be between 0 and 12500" + assert all(0 <= x <= 99 for x in mix_cycles), "mix_cycles must be between 0 and 99" + assert all( + 0 <= x <= 900 for x in mix_position_from_liquid_surface + ), "mix_position_from_liquid_surface must be between 0 and 900" + assert all(4 <= x <= 5000 for x in mix_speed), "mix_speed must be between 4 and 5000" + assert all( + 0 <= x <= 3600 for x in mix_surface_following_distance + ), "mix_surface_following_distance must be between 0 and 3600" + assert all( + 0 <= x <= 999 for x in limit_curve_index + ), "limit_curve_index must be between 0 and 999" + assert 0 <= recording_mode <= 2, "recording_mode must be between 0 and 2" + assert all( + 0 <= x <= 3600 for x in retract_height_over_2nd_section_to_empty_tip + ), "retract_height_over_2nd_section_to_empty_tip must be between 0 and 3600" + assert all( + 4 <= x <= 5000 for x in dispensation_speed_during_emptying_tip + ), "dispensation_speed_during_emptying_tip must be between 4 and 5000" + assert all( + 4 <= x <= 5000 for x in dosing_drive_speed_during_2nd_section_search + ), "dosing_drive_speed_during_2nd_section_search must be between 4 and 5000" + assert all( + 3 <= x <= 1600 for x in z_drive_speed_during_2nd_section_search + ), "z_drive_speed_during_2nd_section_search must be between 3 and 1600" + assert all(0 <= x <= 3600 for x in cup_upper_edge), "cup_upper_edge must be between 0 and 3600" + + return await self.send_command( + module="C0", + command="AS", + tip_pattern=tip_pattern, + read_timeout=max(300, self.read_timeout), + at=[f"{at:01}" for at in aspiration_type], + tm=tip_pattern, + xp=[f"{xp:05}" for xp in x_positions], + yp=[f"{yp:04}" for yp in y_positions], + th=f"{minimum_traverse_height_at_beginning_of_a_command:04}", + te=f"{min_z_endpos:04}", + lp=[f"{lp:04}" for lp in lld_search_height], + ch=[f"{ch:03}" for ch in clot_detection_height], + zl=[f"{zl:04}" for zl in liquid_surface_no_lld], + po=[f"{po:04}" for po in pull_out_distance_transport_air], + zu=[f"{zu:04}" for zu in second_section_height], + zr=[f"{zr:05}" for zr in second_section_ratio], + zx=[f"{zx:04}" for zx in minimum_height], + ip=[f"{ip:04}" for ip in immersion_depth], + it=[f"{it}" for it in immersion_depth_direction], + fp=[f"{fp:04}" for fp in surface_following_distance], + av=[f"{av:05}" for av in aspiration_volumes], + as_=[f"{as_:04}" for as_ in aspiration_speed], + ta=[f"{ta:03}" for ta in transport_air_volume], + ba=[f"{ba:04}" for ba in blow_out_air_volume], + oa=[f"{oa:03}" for oa in pre_wetting_volume], + lm=[f"{lm}" for lm in lld_mode], + ll=[f"{ll}" for ll in gamma_lld_sensitivity], + lv=[f"{lv}" for lv in dp_lld_sensitivity], + zo=[f"{zo:03}" for zo in aspirate_position_above_z_touch_off], + ld=[f"{ld:02}" for ld in detection_height_difference_for_dual_lld], + de=[f"{de:04}" for de in swap_speed], + wt=[f"{wt:02}" for wt in settling_time], + mv=[f"{mv:05}" for mv in mix_volume], + mc=[f"{mc:02}" for mc in mix_cycles], + mp=[f"{mp:03}" for mp in mix_position_from_liquid_surface], + ms=[f"{ms:04}" for ms in mix_speed], + mh=[f"{mh:04}" for mh in mix_surface_following_distance], + gi=[f"{gi:03}" for gi in limit_curve_index], + gj=tadm_algorithm, + gk=recording_mode, + lk=[1 if lk else 0 for lk in use_2nd_section_aspiration], + ik=[f"{ik:04}" for ik in retract_height_over_2nd_section_to_empty_tip], + sd=[f"{sd:04}" for sd in dispensation_speed_during_emptying_tip], + se=[f"{se:04}" for se in dosing_drive_speed_during_2nd_section_search], + sz=[f"{sz:04}" for sz in z_drive_speed_during_2nd_section_search], + io=[f"{io:04}" for io in cup_upper_edge], + ) + + @need_iswap_parked + @action(auto_prefix=True, description="执行底层单通道分液。") + async def dispense_pip( + self, + tip_pattern: List[bool], + dispensing_mode: List[int] = [0], + x_positions: List[int] = [0], + y_positions: List[int] = [0], + minimum_height: List[int] = [3600], + lld_search_height: List[int] = [0], + liquid_surface_no_lld: List[int] = [3600], + pull_out_distance_transport_air: List[int] = [50], + immersion_depth: List[int] = [0], + immersion_depth_direction: List[int] = [0], + surface_following_distance: List[int] = [0], + second_section_height: List[int] = [0], + second_section_ratio: List[int] = [0], + minimum_traverse_height_at_beginning_of_a_command: int = 3600, + min_z_endpos: int = 3600, # + dispense_volumes: List[int] = [0], + dispense_speed: List[int] = [500], + cut_off_speed: List[int] = [250], + stop_back_volume: List[int] = [0], + transport_air_volume: List[int] = [0], + blow_out_air_volume: List[int] = [200], + lld_mode: List[int] = [1], + side_touch_off_distance: int = 1, + dispense_position_above_z_touch_off: List[int] = [5], + gamma_lld_sensitivity: List[int] = [1], + dp_lld_sensitivity: List[int] = [1], + swap_speed: List[int] = [100], + settling_time: List[int] = [5], + mix_volume: List[int] = [0], + mix_cycles: List[int] = [0], + mix_position_from_liquid_surface: List[int] = [250], + mix_speed: List[int] = [500], + mix_surface_following_distance: List[int] = [0], + limit_curve_index: List[int] = [0], + tadm_algorithm: bool = False, + recording_mode: int = 0, + ): + _unilab_logger.debug("[UNILAB] STARBackend.dispense_pip() called") + """dispense pip + + Dispensing of liquid using PIP. + + LLD restrictions! + - "dP and Dual LLD" are used in aspiration only. During dispensation all pressure-based + LLD is set to OFF. + - "side touch off" turns LLD & "Z touch off" to OFF , is not available for simultaneous + Asp/Disp. command + + Args: + dispensing_mode: Type of dispensing mode 0 = Partial volume in jet mode + 1 = Blow out in jet mode 2 = Partial volume at surface + 3 = Blow out at surface 4 = Empty tip at fix position. + tip_pattern: Tip pattern (channels involved). Default True. + x_positions: x positions [0.1mm]. Must be between 0 and 25000. Default 0. + y_positions: y positions [0.1mm]. Must be between 0 and 6500. Default 0. + minimum_height: Minimum height (maximum immersion depth) [0.1 mm]. Must be between 0 and + 3600. Default 3600. + lld_search_height: LLD search height [0.1 mm]. Must be between 0 and 3600. Default 0. + liquid_surface_no_lld: Liquid surface at function without LLD [0.1mm]. Must be between 0 and + 3600. Default 3600. + pull_out_distance_transport_air: pull out distance to take transport air in function without + LLD [0.1mm]. Must be between 0 and 3600. Default 50. + immersion_depth: Immersion depth [0.1mm]. Must be between 0 and 3600. Default 0. + immersion_depth_direction: Direction of immersion depth (0 = go deeper, 1 = go up out of + liquid). Must be between 0 and 1. Default 0. + surface_following_distance: Surface following distance during aspiration [0.1mm]. Must be + between 0 and 3600. Default 0. + second_section_height: Tube 2nd section height measured from "zx" [0.1mm]. Must be between + 0 and 3600. Default 0. + second_section_ratio: Tube 2nd section ratio (see Fig. 2 in fw guide). Must be between 0 and + 10000. Default 0. + minimum_traverse_height_at_beginning_of_a_command: Minimum traverse height at beginning of a + command 0.1mm] (refers to all channels independent of tip pattern parameter 'tm'). Must be + between 0 and 3600. Default 3600. + min_z_endpos: Minimum z-Position at end of a command [0.1 mm] (refers to all channels + independent of tip pattern parameter 'tm'). Must be between 0 and 3600. Default 3600. + dispense_volumes: Dispense volume [0.1ul]. Must be between 0 and 12500. Default 0. + dispense_speed: Dispense speed [0.1ul/s]. Must be between 4 and 5000. Default 500. + cut_off_speed: Cut-off speed [0.1ul/s]. Must be between 4 and 5000. Default 250. + stop_back_volume: Stop back volume [0.1ul]. Must be between 0 and 180. Default 0. + transport_air_volume: Transport air volume [0.1ul]. Must be between 0 and 500. Default 0. + blow_out_air_volume: Blow-out air volume [0.1ul]. Must be between 0 and 9999. Default 200. + lld_mode: LLD mode (0 = off, 1 = gamma, 2 = dP, 3 = dual, 4 = Z touch off). Must be between 0 + and 4. Default 1. + side_touch_off_distance: side touch off distance [0.1 mm] (0 = OFF). Must be between 0 and 45. + Default 1. + dispense_position_above_z_touch_off: dispense position above Z touch off [0.1 s] (0 = OFF) + Turns LLD & Z touch off to OFF if ON!. Must be between 0 and 100. Default 5. + gamma_lld_sensitivity: gamma LLD sensitivity (1= high, 4=low). Must be between 1 and 4. + Default 1. + dp_lld_sensitivity: delta p LLD sensitivity (1= high, 4=low). Must be between 1 and 4. + Default 1. + swap_speed: Swap speed (on leaving liquid) [0.1mm/s]. Must be between 3 and 1600. + Default 100. + settling_time: Settling time [0.1s]. Must be between 0 and 99. Default 5. + mix_volume: Mix volume [0.1ul]. Must be between 0 and 12500. Default 0. + mix_cycles: Number of mix cycles. Must be between 0 and 99. Default 0. + mix_position_from_liquid_surface: Mix position in Z- direction from liquid surface (LLD or + absolute terms) [0.1mm]. Must be between 0 and 900. Default 250. + mix_speed: Speed of mixing [0.1ul/s]. Must be between 4 and 5000. Default 500. + mix_surface_following_distance: Surface following distance during mixing [0.1mm]. Must be + between 0 and 3600. Default 0. + limit_curve_index: limit curve index. Must be between 0 and 999. Default 0. + tadm_algorithm: TADM algorithm. Default False. + recording_mode: Recording mode 0 : no 1 : TADM errors only 2 : all TADM measurement. Must + be between 0 and 2. Default 0. + """ + + assert all(0 <= x <= 4 for x in dispensing_mode), "dispensing_mode must be between 0 and 4" + assert all(0 <= xp <= 25000 for xp in x_positions), "x_positions must be between 0 and 25000" + assert all(0 <= yp <= 6500 for yp in y_positions), "y_positions must be between 0 and 6500" + assert any(0 <= x <= 3600 for x in minimum_height), "minimum_height must be between 0 and 3600" + assert any( + 0 <= x <= 3600 for x in lld_search_height + ), "lld_search_height must be between 0 and 3600" + assert any( + 0 <= x <= 3600 for x in liquid_surface_no_lld + ), "liquid_surface_no_lld must be between 0 and 3600" + assert any( + 0 <= x <= 3600 for x in pull_out_distance_transport_air + ), "pull_out_distance_transport_air must be between 0 and 3600" + assert any( + 0 <= x <= 3600 for x in immersion_depth + ), "immersion_depth must be between 0 and 3600" + assert any( + 0 <= x <= 1 for x in immersion_depth_direction + ), "immersion_depth_direction must be between 0 and 1" + assert any( + 0 <= x <= 3600 for x in surface_following_distance + ), "surface_following_distance must be between 0 and 3600" + assert any( + 0 <= x <= 3600 for x in second_section_height + ), "second_section_height must be between 0 and 3600" + assert any( + 0 <= x <= 10000 for x in second_section_ratio + ), "second_section_ratio must be between 0 and 10000" + assert ( + 0 <= minimum_traverse_height_at_beginning_of_a_command <= 3600 + ), "minimum_traverse_height_at_beginning_of_a_command must be between 0 and 3600" + assert 0 <= min_z_endpos <= 3600, "min_z_endpos must be between 0 and 3600" + assert any( + 0 <= x <= 12500 for x in dispense_volumes + ), "dispense_volume must be between 0 and 12500" + assert any(4 <= x <= 5000 for x in dispense_speed), "dispense_speed must be between 4 and 5000" + assert any(4 <= x <= 5000 for x in cut_off_speed), "cut_off_speed must be between 4 and 5000" + assert any( + 0 <= x <= 180 for x in stop_back_volume + ), "stop_back_volume must be between 0 and 180" + assert any( + 0 <= x <= 500 for x in transport_air_volume + ), "transport_air_volume must be between 0 and 500" + assert any( + 0 <= x <= 9999 for x in blow_out_air_volume + ), "blow_out_air_volume must be between 0 and 9999" + assert any(0 <= x <= 4 for x in lld_mode), "lld_mode must be between 0 and 4" + assert 0 <= side_touch_off_distance <= 45, "side_touch_off_distance must be between 0 and 45" + assert any( + 0 <= x <= 100 for x in dispense_position_above_z_touch_off + ), "dispense_position_above_z_touch_off must be between 0 and 100" + assert any( + 1 <= x <= 4 for x in gamma_lld_sensitivity + ), "gamma_lld_sensitivity must be between 1 and 4" + assert any( + 1 <= x <= 4 for x in dp_lld_sensitivity + ), "dp_lld_sensitivity must be between 1 and 4" + assert any(3 <= x <= 1600 for x in swap_speed), "swap_speed must be between 3 and 1600" + assert any(0 <= x <= 99 for x in settling_time), "settling_time must be between 0 and 99" + assert any(0 <= x <= 12500 for x in mix_volume), "mix_volume must be between 0 and 12500" + assert any(0 <= x <= 99 for x in mix_cycles), "mix_cycles must be between 0 and 99" + assert any( + 0 <= x <= 900 for x in mix_position_from_liquid_surface + ), "mix_position_from_liquid_surface must be between 0 and 900" + assert any(4 <= x <= 5000 for x in mix_speed), "mix_speed must be between 4 and 5000" + assert any( + 0 <= x <= 3600 for x in mix_surface_following_distance + ), "mix_surface_following_distance must be between 0 and 3600" + assert any( + 0 <= x <= 999 for x in limit_curve_index + ), "limit_curve_index must be between 0 and 999" + assert 0 <= recording_mode <= 2, "recording_mode must be between 0 and 2" + + return await self.send_command( + module="C0", + command="DS", + tip_pattern=tip_pattern, + read_timeout=max(300, self.read_timeout), + dm=[f"{dm:01}" for dm in dispensing_mode], + tm=[f"{tm:01}" for tm in tip_pattern], + xp=[f"{xp:05}" for xp in x_positions], + yp=[f"{yp:04}" for yp in y_positions], + zx=[f"{zx:04}" for zx in minimum_height], + lp=[f"{lp:04}" for lp in lld_search_height], + zl=[f"{zl:04}" for zl in liquid_surface_no_lld], + po=[f"{po:04}" for po in pull_out_distance_transport_air], + ip=[f"{ip:04}" for ip in immersion_depth], + it=[f"{it:01}" for it in immersion_depth_direction], + fp=[f"{fp:04}" for fp in surface_following_distance], + zu=[f"{zu:04}" for zu in second_section_height], + zr=[f"{zr:05}" for zr in second_section_ratio], + th=f"{minimum_traverse_height_at_beginning_of_a_command:04}", + te=f"{min_z_endpos:04}", + dv=[f"{dv:05}" for dv in dispense_volumes], + ds=[f"{ds:04}" for ds in dispense_speed], + ss=[f"{ss:04}" for ss in cut_off_speed], + rv=[f"{rv:03}" for rv in stop_back_volume], + ta=[f"{ta:03}" for ta in transport_air_volume], + ba=[f"{ba:04}" for ba in blow_out_air_volume], + lm=[f"{lm:01}" for lm in lld_mode], + dj=f"{side_touch_off_distance:02}", # + zo=[f"{zo:03}" for zo in dispense_position_above_z_touch_off], + ll=[f"{ll:01}" for ll in gamma_lld_sensitivity], + lv=[f"{lv:01}" for lv in dp_lld_sensitivity], + de=[f"{de:04}" for de in swap_speed], + wt=[f"{wt:02}" for wt in settling_time], + mv=[f"{mv:05}" for mv in mix_volume], + mc=[f"{mc:02}" for mc in mix_cycles], + mp=[f"{mp:03}" for mp in mix_position_from_liquid_surface], + ms=[f"{ms:04}" for ms in mix_speed], + mh=[f"{mh:04}" for mh in mix_surface_following_distance], + gi=[f"{gi:03}" for gi in limit_curve_index], + gj=tadm_algorithm, # + gk=recording_mode, # + ) + + # TODO:(command:DA) Simultaneous aspiration & dispensation of liquid + + # TODO:(command:DF) Dispense on fly using PIP (Partial volume in jet mode) + + # TODO:(command:LW) DC Wash procedure using PIP + + # -------------- 3.5.5 CoRe gripper commands -------------- + + def _get_core_front_back(self): + _unilab_logger.debug("[UNILAB] STARBackend._get_core_front_back() called") + core_grippers = self.deck.get_resource("core_grippers") + assert isinstance(core_grippers, HamiltonCoreGrippers), "core_grippers must be CoReGrippers" + back_channel_y_center = int( + ( + core_grippers.get_location_wrt(self.deck).y + + core_grippers.back_channel_y_center + + self.core_adjustment.y + ) + ) + front_channel_y_center = int( + ( + core_grippers.get_location_wrt(self.deck).y + + core_grippers.front_channel_y_center + + self.core_adjustment.y + ) + ) + assert ( + back_channel_y_center > front_channel_y_center + ), "back_channel_y_center must be greater than front_channel_y_center" + assert front_channel_y_center > 6, "front_channel_y_center must be less than 6mm" + return back_channel_y_center, front_channel_y_center + + def _get_core_x(self) -> float: + _unilab_logger.debug("[UNILAB] STARBackend._get_core_x() called") + """Get the X coordinate for the CoRe grippers based on deck size and adjustment.""" + core_grippers = self.deck.get_resource("core_grippers") + assert isinstance(core_grippers, HamiltonCoreGrippers), "core_grippers must be CoReGrippers" + return core_grippers.get_location_wrt(self.deck).x + self.core_adjustment.x + + @action(auto_prefix=True, description="获取CoRe组件信息。") + async def get_core(self, p1: int, p2: int): + _unilab_logger.debug("[UNILAB] STARBackend.get_core() called") + warnings.warn("Deprecated. Use pick_up_core_gripper_tools instead.", DeprecationWarning) + assert p1 + 1 == p2, "p2 must be p1 + 1" + return await self.pick_up_core_gripper_tools(front_channel=p2 - 1) # p1 here is 1-indexed + + @need_iswap_parked + @action(auto_prefix=True, description="拾取CoRe夹爪工具。") + async def pick_up_core_gripper_tools( + self, + front_channel: int, + front_offset: Optional[Coordinate] = None, + back_offset: Optional[Coordinate] = None, + ): + _unilab_logger.debug("[UNILAB] STARBackend.pick_up_core_gripper_tools() called") + """Get CoRe gripper tool from wasteblock mount.""" + + if not 0 < front_channel < self.num_channels: + raise ValueError(f"front_channel must be between 1 and {self.num_channels - 1} (inclusive)") + back_channel = front_channel - 1 + + # Only enforce x equality if both offsets are explicitly provided. + if front_offset is not None and back_offset is not None and front_offset.x != back_offset.x: + raise ValueError("front_offset.x and back_offset.x must be the same") + + xs = self._get_core_x() + (front_offset.x if front_offset is not None else 0) + + back_channel_y_center, front_channel_y_center = self._get_core_front_back() + if back_offset is not None: + back_channel_y_center += back_offset.y + if front_offset is not None: + front_channel_y_center += front_offset.y + + if front_offset is not None and back_offset is not None and front_offset.z != back_offset.z: + raise ValueError("front_offset.z and back_offset.z must be the same") + z_offset = 0 if front_offset is None else front_offset.z + begin_z_coord = round(235.0 + self.core_adjustment.z + z_offset) + end_z_coord = round(225.0 + self.core_adjustment.z + z_offset) + + command_output = await self.send_command( + module="C0", + command="ZT", + xs=f"{round(xs * 10):05}", + xd="0", + ya=f"{round(back_channel_y_center * 10):04}", + yb=f"{round(front_channel_y_center * 10):04}", + pa=f"{back_channel+1:02}", # star is 1-indexed + pb=f"{front_channel+1:02}", # star is 1-indexed + tp=f"{round(begin_z_coord * 10):04}", + tz=f"{round(end_z_coord * 10):04}", + th=round(self._iswap_traversal_height * 10), + tt="14", + ) + self._core_parked = False + return command_output + + @action(auto_prefix=True, description="放回CoRe夹爪工具。") + async def put_core(self): + _unilab_logger.debug("[UNILAB] STARBackend.put_core() called") + warnings.warn("Deprecated. Use return_core_gripper_tools instead.", DeprecationWarning) + return await self.return_core_gripper_tools() + + @need_iswap_parked + @action(auto_prefix=True, description="归还CoRe夹爪工具。") + async def return_core_gripper_tools( + self, + front_offset: Optional[Coordinate] = None, + back_offset: Optional[Coordinate] = None, + ): + _unilab_logger.debug("[UNILAB] STARBackend.return_core_gripper_tools() called") + """Put CoRe gripper tool at wasteblock mount.""" + + # Only enforce x equality if both offsets are explicitly provided. + if front_offset is not None and back_offset is not None and back_offset.x != front_offset.x: + raise ValueError("back_offset.x and front_offset.x must be the same") + + xs = self._get_core_x() + (front_offset.x if front_offset is not None else 0) + + back_channel_y_center, front_channel_y_center = self._get_core_front_back() + if back_offset is not None: + back_channel_y_center += back_offset.y + if front_offset is not None: + front_channel_y_center += front_offset.y + + if front_offset is not None and back_offset is not None and back_offset.z != front_offset.z: + raise ValueError("back_offset.z and front_offset.z must be the same") + z_offset = 0 if front_offset is None else front_offset.z + begin_z_coord = round(215.0 + self.core_adjustment.z + z_offset) + end_z_coord = round(205.0 + self.core_adjustment.z + z_offset) + + command_output = await self.send_command( + module="C0", + command="ZS", + xs=f"{round(xs * 10):05}", + xd="0", + ya=f"{round(back_channel_y_center * 10):04}", + yb=f"{round(front_channel_y_center * 10):04}", + tp=f"{round(begin_z_coord * 10):04}", + tz=f"{round(end_z_coord * 10):04}", + th=round(self._iswap_traversal_height * 10), + te=round(self._iswap_traversal_height * 10), + ) + self._core_parked = True + return command_output + + @action(auto_prefix=True, description="打开CoRe夹爪。") + async def core_open_gripper(self): + _unilab_logger.debug("[UNILAB] STARBackend.core_open_gripper() called") + """Open CoRe gripper tool.""" + return await self.send_command(module="C0", command="ZO") + + @need_iswap_parked + @action(auto_prefix=True, description="用CoRe夹爪抓取板。") + async def core_get_plate( + self, + x_position: int = 0, + x_direction: int = 0, + y_position: int = 0, + y_gripping_speed: int = 50, + z_position: int = 0, + z_speed: int = 500, + open_gripper_position: int = 0, + plate_width: int = 0, + grip_strength: int = 15, + minimum_traverse_height_at_beginning_of_a_command: int = 2750, + minimum_z_position_at_the_command_end: int = 2750, + ): + _unilab_logger.debug("[UNILAB] STARBackend.core_get_plate() called") + """Get plate with CoRe gripper tool from wasteblock mount.""" + + assert 0 <= x_position <= 30000, "x_position must be between 0 and 30000" + assert 0 <= x_direction <= 1, "x_direction must be between 0 and 1" + assert 0 <= y_position <= 6500, "y_position must be between 0 and 6500" + assert 0 <= y_gripping_speed <= 3700, "y_gripping_speed must be between 0 and 3700" + assert 0 <= z_position <= 3600, "z_position must be between 0 and 3600" + assert 0 <= z_speed <= 1287, "z_speed must be between 0 and 1287" + assert 0 <= open_gripper_position <= 9999, "open_gripper_position must be between 0 and 9999" + assert 0 <= plate_width <= 9999, "plate_width must be between 0 and 9999" + assert 0 <= grip_strength <= 99, "grip_strength must be between 0 and 99" + assert ( + 0 <= minimum_traverse_height_at_beginning_of_a_command <= 3600 + ), "minimum_traverse_height_at_beginning_of_a_command must be between 0 and 3600" + assert ( + 0 <= minimum_z_position_at_the_command_end <= 3600 + ), "minimum_z_position_at_the_command_end must be between 0 and 3600" + + command_output = await self.send_command( + module="C0", + command="ZP", + xs=f"{x_position:05}", + xd=x_direction, + yj=f"{y_position:04}", + yv=f"{y_gripping_speed:04}", + zj=f"{z_position:04}", + zy=f"{z_speed:04}", + yo=f"{open_gripper_position:04}", + yg=f"{plate_width:04}", + yw=f"{grip_strength:02}", + th=f"{minimum_traverse_height_at_beginning_of_a_command:04}", + te=f"{minimum_z_position_at_the_command_end:04}", + ) + + return command_output + + @need_iswap_parked + @action(auto_prefix=True, description="用CoRe夹爪放下板。") + async def core_put_plate( + self, + x_position: int = 0, + x_direction: int = 0, + y_position: int = 0, + z_position: int = 0, + z_press_on_distance: int = 0, + z_speed: int = 500, + open_gripper_position: int = 0, + minimum_traverse_height_at_beginning_of_a_command: int = 2750, + z_position_at_the_command_end: int = 2750, + return_tool: bool = True, + ): + _unilab_logger.debug("[UNILAB] STARBackend.core_put_plate() called") + """Put plate with CoRe gripper tool and return to wasteblock mount.""" + + assert 0 <= x_position <= 30000, "x_position must be between 0 and 30000" + assert 0 <= x_direction <= 1, "x_direction must be between 0 and 1" + assert 0 <= y_position <= 6500, "y_position must be between 0 and 6500" + assert 0 <= z_position <= 3600, "z_position must be between 0 and 3600" + assert 0 <= z_press_on_distance <= 50, "z_press_on_distance must be between 0 and 999" + assert 0 <= z_speed <= 1600, "z_speed must be between 0 and 1600" + assert 0 <= open_gripper_position <= 9999, "open_gripper_position must be between 0 and 9999" + assert ( + 0 <= minimum_traverse_height_at_beginning_of_a_command <= 3600 + ), "minimum_traverse_height_at_beginning_of_a_command must be between 0 and 3600" + assert ( + 0 <= z_position_at_the_command_end <= 3600 + ), "z_position_at_the_command_end must be between 0 and 3600" + + command_output = await self.send_command( + module="C0", + command="ZR", + xs=f"{x_position:05}", + xd=x_direction, + yj=f"{y_position:04}", + zj=f"{z_position:04}", + zi=f"{z_press_on_distance:03}", + zy=f"{z_speed:04}", + yo=f"{open_gripper_position:04}", + th=f"{minimum_traverse_height_at_beginning_of_a_command:04}", + te=f"{z_position_at_the_command_end:04}", + ) + + if return_tool: + await self.return_core_gripper_tools() + + return command_output + + @need_iswap_parked + @action(auto_prefix=True, description="用CoRe夹爪将板移动到指定位置。") + async def core_move_plate_to_position( + self, + x_position: int = 0, + x_direction: int = 0, + x_acceleration_index: int = 4, + y_position: int = 0, + z_position: int = 0, + z_speed: int = 500, + minimum_traverse_height_at_beginning_of_a_command: int = 3600, + ): + _unilab_logger.debug("[UNILAB] STARBackend.core_move_plate_to_position() called") + """Move a plate with CoRe gripper tool.""" + + command_output = await self.send_command( + module="C0", + command="ZM", + xs=f"{x_position:05}", + xd=x_direction, + xg=x_acceleration_index, + yj=f"{y_position:04}", + zj=f"{z_position:04}", + zy=f"{z_speed:04}", + th=f"{minimum_traverse_height_at_beginning_of_a_command:04}", + ) + + return command_output + + @action(auto_prefix=True, description="读取CoRe夹持资源的条码。") + async def core_read_barcode_of_picked_up_resource( + self, + rails: int, + reading_direction: Literal["vertical", "horizontal", "free"] = "horizontal", + minimal_z_position: float = 220.0, + traverse_height_at_beginning_of_a_command: float = 275.0, + z_speed: float = 128.7, + allow_manual_input: bool = False, + labware_description: Optional[str] = None, + ): + _unilab_logger.debug("[UNILAB] STARBackend.core_read_barcode_of_picked_up_resource() called") + """Read a 1D barcode using the CoRe gripper scanner. + + Args: + rails: Rail/slot number where the barcode to be read is located (1-54). + reading_direction: Direction of barcode reading: 'vertical', 'horizontal', or 'free'. Default is 'horizontal'. + minimal_z_position: Minimal Z position [mm] during barcode reading (220.0-360.0). Default is 220.0. + traverse_height_at_beginning_of_a_command: Traverse height at beginning of command [mm] (0.0-360.0). Default is 275.0. + z_speed: Z speed [mm/s] during barcode reading (0.0-128.7). Default is 128.7. + allow_manual_input: If True, allows the user to manually input a barcode if scanning fails. Default is False. + labware_description: Optional description of the labware being scanned, used in the manual input + prompt to provide context to the user. + + Returns: + A Barcode if one is successfully read, either by the scanner or via manual user input. + + Raises: + STARFirmwareError: if the firmware reports an error in the response. + ValueError: if the response format is unexpected or if no barcode is present and + ``allow_manual_input`` is False, or if manual input is enabled but the user does not + provide a barcode. + """ + + assert 1 <= rails <= 54, "rails must be between 1 and 54" + assert 0 <= minimal_z_position <= 3600, "minimal_z_position must be between 0 and 3600" + assert ( + 0 <= traverse_height_at_beginning_of_a_command <= 3600 + ), "traverse_height_at_beginning_of_a_command must be between 0 and 3600" + assert 0 <= z_speed <= 1287, "z_speed must be between 0 and 1287" + + try: + reading_direction_int = { + "vertical": 0, + "horizontal": 1, + "free": 2, + }[reading_direction] + except KeyError as e: + raise ValueError( + "reading_direction must be one of 'vertical', 'horizontal', or 'free'" + ) from e + + command_output = cast( + str, + await self.send_command( + module="C0", + command="ZB", + cp=f"{rails:02}", + zb=f"{round(minimal_z_position*10):04}", + th=f"{round(traverse_height_at_beginning_of_a_command*10):04}", + zy=f"{round(z_speed*10):04}", + bd=reading_direction_int, + ma="0250 2100 0860 0200", + mr=0, + mo="000 000 000 000 000 000 000", + ), + ) + + if command_output is None: + raise RuntimeError("No response received from CoRe barcode read command.") + + resp = command_output.strip() + er_index = resp.find("er") + if er_index == -1: + # Unexpected format: no error section present. + raise ValueError(f"Unexpected CoRe barcode response (no error section): {resp}") + + self.check_fw_string_error(resp) + + # Parse barcode section: firmware returns `bb/LL` where LL is length (00..99). + bb_index = resp.find("bb/", er_index + 7) + if bb_index == -1: + # Unexpected layout of barcode section. + raise ValueError(f"Unexpected CoRe barcode response format: {resp}") + + if len(resp) < bb_index + 5: + # Need at least 'bb/LL'. + raise ValueError(f"Unexpected CoRe barcode response format: {resp}") + + bb_len_str = resp[bb_index + 3 : bb_index + 5] + try: + bb_len = int(bb_len_str) + except ValueError as e: + raise ValueError(f"Invalid CoRe barcode length field 'bb': {bb_len_str}") from e + + barcode_str = resp[bb_index + 5 :].strip() + + # No barcode present. + if bb_len == 0: + if allow_manual_input: + # Provide context and allow the user to recover by entering a barcode manually. + # Use ANSI color codes to make the prompt stand out in typical terminals. + YELLOW = "\033[93m" + BOLD = "\033[1m" + RESET = "\033[0m" + + lines = [ + f"{YELLOW}{BOLD}=== CoRe barcode scan failed ==={RESET}", + f"{YELLOW}No barcode read by CoRe scanner.{RESET}", + ] + if labware_description is not None: + lines.append(f"{YELLOW}Labware: {labware_description}{RESET}") + lines.append(f"{YELLOW}Enter barcode manually (leave blank to abort): {RESET}") + prompt = "\n".join(lines) + + # Blocking input is acceptable here because this helper is only intended for CLI usage. + user_barcode = input(prompt).strip() + if not user_barcode: + raise ValueError("No barcode read by CoRe scanner and no manual barcode provided.") + + return Barcode( + data=user_barcode, + symbology="code128", + position_on_resource="front", + ) + + raise ValueError("No barcode read by CoRe scanner.") + + if not barcode_str: + # Length > 0 but no data present. + raise ValueError(f"Unexpected CoRe barcode response format: {resp}") + + # If the firmware returns more characters than declared, truncate to the declared length. + if len(barcode_str) > bb_len: + barcode_str = barcode_str[:bb_len] + + return Barcode( + data=barcode_str, + symbology="code128", + position_on_resource="front", + ) + + # -------------- 3.5.6 Adjustment & movement commands -------------- + + @action(auto_prefix=True, description="定位单个移液通道Y位置。") + async def position_single_pipetting_channel_in_y_direction( + self, pipetting_channel_index: int, y_position: int + ): + _unilab_logger.debug("[UNILAB] STARBackend.position_single_pipetting_channel_in_y_direction() called") + """Position single pipetting channel in Y-direction. + + Args: + pipetting_channel_index: Index of pipetting channel. Must be between 1 and 16. + y_position: y position [0.1mm]. Must be between 0 and 6500. + """ + + assert ( + 1 <= pipetting_channel_index <= self.num_channels + ), "pipetting_channel_index must be between 1 and self" + assert 0 <= y_position <= 6500, "y_position must be between 0 and 6500" + + return await self.send_command( + module="C0", + command="KY", + pn=f"{pipetting_channel_index:02}", + yj=f"{y_position:04}", + ) + + @action(auto_prefix=True, description="定位单个移液通道Z位置。") + async def position_single_pipetting_channel_in_z_direction( + self, pipetting_channel_index: int, z_position: int + ): + _unilab_logger.debug("[UNILAB] STARBackend.position_single_pipetting_channel_in_z_direction() called") + """Position single pipetting channel in Z-direction. + + Note that this refers to the point of the tip if a tip is mounted! + + Args: + pipetting_channel_index: Index of pipetting channel. Must be between 1 and 16. + z_position: y position [0.1mm]. Must be between 0 and 3347. The docs say 3600,but empirically 3347 is the max. + """ + + assert ( + 1 <= pipetting_channel_index <= self.num_channels + ), "pipetting_channel_index must be between 1 and self.num_channels" + # docs say 3600, but empirically 3347 is the max + assert 0 <= z_position <= 3347, "z_position must be between 0 and 3347" + + return await self.send_command( + module="C0", + command="KZ", + pn=f"{pipetting_channel_index:02}", + zj=f"{z_position:04}", + ) + + @action(auto_prefix=True, description="用指定通道沿X方向搜索示教信号。") + async def search_for_teach_in_signal_using_pipetting_channel_n_in_x_direction( + self, pipetting_channel_index: int, x_position: int + ): + _unilab_logger.debug("[UNILAB] STARBackend.search_for_teach_in_signal_using_pipetting_channel_n_in_x_direction() called") + """Search for Teach in signal using pipetting channel n in X-direction. + + Args: + pipetting_channel_index: Index of pipetting channel. Must be between 1 and self.num_channels. + x_position: x position [0.1mm]. Must be between 0 and 30000. + """ + + assert ( + 1 <= pipetting_channel_index <= self.num_channels + ), "pipetting_channel_index must be between 1 and self.num_channels" + assert 0 <= x_position <= 30000, "x_position must be between 0 and 30000" + + return await self.send_command( + module="C0", + command="XL", + pn=f"{pipetting_channel_index:02}", + xs=f"{x_position:05}", + ) + + @action(auto_prefix=True, description="展开PIP通道间距。") + async def spread_pip_channels(self): + _unilab_logger.debug("[UNILAB] STARBackend.spread_pip_channels() called") + """Spread PIP channels""" + + return await self.send_command(module="C0", command="JE") + + @need_iswap_parked + @action(auto_prefix=True, description="将所有移液通道移动到指定位置。") + async def move_all_pipetting_channels_to_defined_position( + self, + tip_pattern: bool = True, + x_positions: int = 0, + y_positions: int = 0, + minimum_traverse_height_at_beginning_of_command: int = 3600, + z_endpos: int = 0, + ): + _unilab_logger.debug("[UNILAB] STARBackend.move_all_pipetting_channels_to_defined_position() called") + """Move all pipetting channels to defined position + + Args: + tip_pattern: Tip pattern (channels involved). Default True. + x_positions: x positions [0.1mm]. Must be between 0 and 25000. Default 0. + y_positions: y positions [0.1mm]. Must be between 0 and 6500. Default 0. + minimum_traverse_height_at_beginning_of_command: Minimum traverse height at beginning of a + command 0.1mm] (refers to all channels independent of tip pattern parameter 'tm'). Must be + between 0 and 3600. Default 3600. + z_endpos: Z-Position at end of a command [0.1 mm] (refers to all channels independent of tip + pattern parameter 'tm'). Must be between 0 and 3600. Default 0. + """ + + assert 0 <= x_positions <= 25000, "x_positions must be between 0 and 25000" + assert 0 <= y_positions <= 6500, "y_positions must be between 0 and 6500" + assert ( + 0 <= minimum_traverse_height_at_beginning_of_command <= 3600 + ), "minimum_traverse_height_at_beginning_of_command must be between 0 and 3600" + assert 0 <= z_endpos <= 3600, "z_endpos must be between 0 and 3600" + + return await self.send_command( + module="C0", + command="JM", + tm=tip_pattern, + xp=x_positions, + yp=y_positions, + th=minimum_traverse_height_at_beginning_of_command, + zp=z_endpos, + ) + + # TODO:(command:JR): teach rack using pipetting channel n + + @need_iswap_parked + @action(auto_prefix=True, description="为指定通道释放最大Y空间。") + async def position_max_free_y_for_n(self, pipetting_channel_index: int): + _unilab_logger.debug("[UNILAB] STARBackend.position_max_free_y_for_n() called") + """Position all pipetting channels so that there is maximum free Y range for channel n + + Args: + pipetting_channel_index: Index of pipetting channel. Must be between 0 and self.num_channels. + """ + + assert ( + 0 <= pipetting_channel_index < self.num_channels + ), "pipetting_channel_index must be between 1 and self.num_channels" + # convert Python's 0-based indexing to Hamilton firmware's 1-based indexing + pipetting_channel_index = pipetting_channel_index + 1 + + return await self.send_command( + module="C0", + command="JP", + pn=f"{pipetting_channel_index:02}", + ) + + @action(auto_prefix=True, description="将所有通道移至Z安全位。") + async def move_all_channels_in_z_safety(self): + _unilab_logger.debug("[UNILAB] STARBackend.move_all_channels_in_z_safety() called") + """Move all pipetting channels in Z-safety position""" + + return await self.send_command(module="C0", command="ZA") + + # -------------- 3.5.7 PIP query -------------- + + # TODO:(command:RY): Request Y-Positions of all pipetting channels + + @action(auto_prefix=True, description="获取指定通道X位置。") + async def request_x_pos_channel_n(self, pipetting_channel_index: int = 0) -> float: + _unilab_logger.debug("[UNILAB] STARBackend.request_x_pos_channel_n() called") + """Request X-Position of Pipetting channel n (in mm)""" + + resp = await self.request_left_x_arm_position() + # TODO: check validity for 2 X-arm system + + return round(resp, 1) + + @action(auto_prefix=True, description="获取指定通道Y位置。") + async def request_y_pos_channel_n(self, pipetting_channel_index: int) -> float: + _unilab_logger.debug("[UNILAB] STARBackend.request_y_pos_channel_n() called") + """Request Y-Position of Pipetting channel n + + Args: + pipetting_channel_index: Index of pipetting channel. Must be between 0 and 15. + 0 is the backmost channel. + """ + + assert ( + 0 <= pipetting_channel_index < self.num_channels + ), "pipetting_channel_index must be between 0 and self.num_channels" + # convert Python's 0-based indexing to Hamilton firmware's 1-based indexing + pipetting_channel_index = pipetting_channel_index + 1 + + y_pos_query = await self.send_command( + module="C0", + command="RB", + fmt="rb####", + pn=f"{pipetting_channel_index:02}", + ) + # Extract y-coordinate and convert to mm + return float(y_pos_query["rb"] / 10) + + # TODO:(command:RZ): Request Z-Positions of all pipetting channels + + @action(auto_prefix=True, description="获取指定通道Z位置。") + async def request_z_pos_channel_n(self, pipetting_channel_index: int) -> float: + _unilab_logger.debug("[UNILAB] STARBackend.request_z_pos_channel_n() called") + warnings.warn( + "Deprecated. Use either request_tip_bottom_z_position or request_probe_z_position. " + "Returning request_tip_bottom_z_position for now." + ) + return await self.request_tip_bottom_z_position(channel_idx=pipetting_channel_index) + + @action(auto_prefix=True, description="获取吸头底部Z位置。") + async def request_tip_bottom_z_position(self, channel_idx: int) -> float: + _unilab_logger.debug("[UNILAB] STARBackend.request_tip_bottom_z_position() called") + """Request Z-Position of the tip bottom of the tip mounted at on channel `channel_idx`. + + Requires a tip to be mounted and will raise if no tip is mounted. + + To get the z-position of the probe (irrespective of tip), use `request_probe_z_position`. + + Args: + channel_idx: Index of pipetting channel. Must be between 0 and 15. 0 is the backmost channel. + """ + + if not (await self.request_tip_presence())[channel_idx]: + raise RuntimeError(f"No tip mounted on channel {channel_idx}") + + if not 0 <= channel_idx <= self.num_channels - 1: + raise ValueError("channel_idx must be in [0, num_channels - 1]") + + z_pos_query = await self.send_command( + module="C0", + command="RD", + fmt="rd####", + # convert Python's 0-based indexing to Hamilton firmware's 1-based indexing + pn=f"{channel_idx + 1:02}", + ) + # Extract z-coordinate and convert to mm + return float(z_pos_query["rd"] / 10) + + @action(auto_prefix=True, description="获取各通道吸头存在状态。") + async def request_tip_presence(self) -> List[int]: + _unilab_logger.debug("[UNILAB] STARBackend.request_tip_presence() called") + """Request query tip presence on each channel + + Returns: + 0 = no tip, 1 = Tip in gripper (for each channel) + """ + warnings.warn( # TODO: remove 2026-06 + "`request_tip_presence` is deprecated and will be " + "removed in 2026-06 use `channels_sense_tip_presence` instead.", + DeprecationWarning, + stacklevel=2, + ) + return await self.channels_sense_tip_presence() + + @action(auto_prefix=True, description="检测各通道是否装有吸头。") + async def channels_sense_tip_presence(self) -> List[int]: + _unilab_logger.debug("[UNILAB] STARBackend.channels_sense_tip_presence() called") + """Measure tip presence on all single channels using their sleeve sensors. + + Returns: + List of integers where 0 = no tip, 1 = tip present (for each channel) + """ + + resp = await self.send_command(module="C0", command="RT", fmt="rt# (n)") + return cast(List[int], resp.get("rt")) + + @action(auto_prefix=True, description="获取最近一次LLD液面高度。") + async def request_pip_height_last_lld(self) -> List[float]: + _unilab_logger.debug("[UNILAB] STARBackend.request_pip_height_last_lld() called") + """ + Return the absolute liquid heights measured during the most recent + liquid-level detection (LLD) event for all channels. + + This value is maintained internally by the STAR/STARlet firmware and is + updated **whenever a liquid level is detected**, regardless of whether the + detection method used was: + - capacitive LLD (cLLD == 'STAR.LLDMode(1)'), or + - pressure-based LLD (pLLD == 'STAR.LLDMode(2)'). + + Heights are returned in millimeters, one value per channel, ordered by + channel index. + + Returns: + Absolute liquid heights (mm) from the last LLD event for each channel. + + Raises: + AssertionError: If the instrument response does not contain a valid ``"lh"`` list. + """ + resp = await self.send_command(module="C0", command="RL", fmt="lh#### (n)") + + liquid_levels = resp.get("lh") + + assert ( + len(liquid_levels) == self.num_channels + ), f"Expected {self.num_channels} liquid level values, got {len(liquid_levels)} instead" + + current_absolute_liquid_heights = [float(lld_channel / 10) for lld_channel in liquid_levels] + + return current_absolute_liquid_heights + + @action(auto_prefix=True, description="获取TADM状态。") + async def request_tadm_status(self): + _unilab_logger.debug("[UNILAB] STARBackend.request_tadm_status() called") + """Request PIP height of last LLD + + Returns: + TADM channel status 0 = off, 1 = on + """ + + return await self.send_command(module="C0", command="QS", fmt="qs# (n)") + + # TODO:(command:FS) Request PIP channel dispense on fly status + # TODO:(command:VE) Request PIP channel 2nd section aspiration data + + # -------------- 3.6 XL channel commands -------------- + + # TODO: all XL channel commands + + # -------------- 3.6.1 Initialization XL -------------- + + # TODO:(command:LI) + + # -------------- 3.6.2 Tip handling commands using XL -------------- + + # TODO:(command:LP) + # TODO:(command:LR) + + # -------------- 3.6.3 Liquid handling commands using XL -------------- + + # TODO:(command:LA) + # TODO:(command:LD) + # TODO:(command:LB) + # TODO:(command:LC) + + # -------------- 3.6.4 Wash commands using XL channel -------------- + + # TODO:(command:LE) + # TODO:(command:LF) + + # -------------- 3.6.5 XL CoRe gripper commands -------------- + + # TODO:(command:LT) + # TODO:(command:LS) + # TODO:(command:LU) + # TODO:(command:LV) + # TODO:(command:LM) + # TODO:(command:LO) + # TODO:(command:LG) + + # -------------- 3.6.6 Adjustment & movement commands CP -------------- + + # TODO:(command:LY) + # TODO:(command:LZ) + # TODO:(command:LH) + # TODO:(command:LJ) + # TODO:(command:XM) + # TODO:(command:LL) + # TODO:(command:LQ) + # TODO:(command:LK) + # TODO:(command:UE) + + # -------------- 3.6.7 XL channel query -------------- + + # TODO:(command:UY) + # TODO:(command:UB) + # TODO:(command:UZ) + # TODO:(command:UD) + # TODO:(command:UT) + # TODO:(command:UL) + # TODO:(command:US) + # TODO:(command:UF) + + # -------------- 3.7 Tube gripper commands -------------- + + # TODO: all tube gripper commands + + # -------------- 3.7.1 Movements -------------- + + # TODO:(command:FC) + # TODO:(command:FD) + # TODO:(command:FO) + # TODO:(command:FT) + # TODO:(command:FU) + # TODO:(command:FJ) + # TODO:(command:FM) + # TODO:(command:FW) + + # -------------- 3.7.2 Tube gripper query -------------- + + # TODO:(command:FQ) + # TODO:(command:FN) + + # -------------- 3.8 Imaging channel commands -------------- + + # TODO: all imaging commands + + # -------------- 3.8.1 Movements -------------- + + # TODO:(command:IC) + # TODO:(command:ID) + # TODO:(command:IM) + # TODO:(command:IJ) + + # -------------- 3.8.2 Imaging channel query -------------- + + # TODO:(command:IN) + + # -------------- 3.9 Robotic channel commands -------------- + + # -------------- 3.9.1 Initialization -------------- + + # TODO:(command:OI) + + # -------------- 3.9.2 Cap handling commands -------------- + + # TODO:(command:OP) + # TODO:(command:OQ) + + # -------------- 3.9.3 Adjustment & movement commands -------------- + + # TODO:(command:OY) + # TODO:(command:OZ) + # TODO:(command:OH) + # TODO:(command:OJ) + # TODO:(command:OX) + # TODO:(command:OM) + # TODO:(command:OF) + # TODO:(command:OG) + + # -------------- 3.9.4 Robotic channel query -------------- + + # TODO:(command:OA) + # TODO:(command:OB) + # TODO:(command:OC) + # TODO:(command:OD) + # TODO:(command:OT) + + # -------------- 3.10 96-Head commands -------------- + + @action(auto_prefix=True, description="获取96头固件版本。") + async def head96_request_firmware_version(self) -> datetime.date: + _unilab_logger.debug("[UNILAB] STARBackend.head96_request_firmware_version() called") + """Request 96 Head firmware version (MEM-READ command).""" + resp: str = await self.send_command(module="H0", command="RF") + return self._parse_firmware_version_datetime(resp) + + async def _head96_request_configuration(self) -> List[str]: + _unilab_logger.debug("[UNILAB] STARBackend._head96_request_configuration() called") + """Request the 96-head configuration (raw) using the QU command. + + The instrument returns a sequence of positional tokens. This method returns + those tokens without decoding them, but the following indices are currently + understood: + + - index 0: clot_monitoring_with_clld + - index 1: stop_disc_type (codes: 0=core_i, 1=core_ii) + - index 2: instrument_type (codes: 0=legacy, 1=FM-STAR) + - indices 3..9: reservable positions (positions 4..10) + + Returns: + Raw positional tokens extracted from the QU response (the portion after the last ``"au"`` marker). + """ + resp: str = await self.send_command(module="H0", command="QU") + return resp.split("au")[-1].split() + + @action(auto_prefix=True, description="获取96头类型。") + async def head96_request_type(self) -> Head96Information.HeadType: + _unilab_logger.debug("[UNILAB] STARBackend.head96_request_type() called") + """Send QG and return the 96-head type as a human-readable string.""" + type_map: Dict[int, Head96Information.HeadType] = { + 0: "Low volume head", + 1: "High volume head", + 2: "96 head II", + 3: "96 head TADM", + } + resp = await self.send_command(module="H0", command="QG", fmt="qg#") + return type_map.get(resp["qg"], "unknown") + + # -------------- 3.10.1 Initialization -------------- + + @action(auto_prefix=True, description="初始化CoRe 96头。") + async def initialize_core_96_head( + self, trash96: Trash, z_position_at_the_command_end: float = 245.0 + ): + _unilab_logger.debug("[UNILAB] STARBackend.initialize_core_96_head() called") + """Initialize CoRe 96 Head + + Args: + trash96: Trash object where tips should be disposed. The 96 head will be positioned in the + center of the trash. + z_position_at_the_command_end: Z position at the end of the command [mm]. + """ + + # The firmware command expects location of tip A1 of the head. + loc = self._position_96_head_in_resource(trash96) + self._check_96_position_legal(loc, skip_z=True) + + return await self.send_command( + module="C0", + command="EI", + read_timeout=60, + xs=f"{abs(round(loc.x * 10)):05}", + xd=0 if loc.x >= 0 else 1, + yh=f"{abs(round(loc.y * 10)):04}", + za=f"{round(loc.z * 10):04}", + ze=f"{round(z_position_at_the_command_end*10):04}", + ) + + @action(auto_prefix=True, description="获取CoRe 96头初始化状态。") + async def request_core_96_head_initialization_status(self) -> bool: + _unilab_logger.debug("[UNILAB] STARBackend.request_core_96_head_initialization_status() called") + # not available in the C0 docs, so get from module H0 itself instead + response = await self.send_command(module="H0", command="QW", fmt="qw#") + return bool(response.get("qw", 0) == 1) # type? + + @action(auto_prefix=True, description="初始化96头分液驱动和压紧驱动。") + async def head96_dispensing_drive_and_squeezer_driver_initialize( + self, + squeezer_speed: float = 15.0, # mm/sec + squeezer_acceleration: float = 62.0, # mm/sec**2, + squeezer_current_limit: int = 15, + dispensing_drive_current_limit: int = 7, + ): + _unilab_logger.debug("[UNILAB] STARBackend.head96_dispensing_drive_and_squeezer_driver_initialize() called") + """Initialize 96-head's dispensing drive AND squeezer drive + + This command... + - drops any tips that might be on the channel (in place, without moving to trash!) + - moves the dispense drive to volume position 215.92 uL + (after tip pickup it will be at 218.19 uL) + + Args: + squeezer_speed: Speed of the movement (mm/sec). Default is 15.0 mm/sec. + squeezer_acceleration: Acceleration of the movement (mm/sec**2). Default is 62.0 mm/sec**2. + squeezer_current_limit: Current limit for the squeezer drive (1-15). Default is 15. + dispensing_drive_current_limit: Current limit for the dispensing drive (1-15). Default is 7. + """ + + if not (0.01 <= squeezer_speed <= 16.69): + raise ValueError( + f"96-head squeezer drive speed must be between 0.01 and 16.69 mm/sec, is {squeezer_speed}" + ) + if not (1.04 <= squeezer_acceleration <= 62.6): + raise ValueError( + "96-head squeezer drive acceleration must be between 1.04 and " + f"62.6 mm/sec**2, is {squeezer_acceleration}" + ) + if not (1 <= squeezer_current_limit <= 15): + raise ValueError( + "96-head squeezer drive current limit must be between 1 and 15, " + f"is {squeezer_current_limit}" + ) + if not (1 <= dispensing_drive_current_limit <= 15): + raise ValueError( + "96-head dispensing drive current limit must be between 1 and 15, " + f"is {dispensing_drive_current_limit}" + ) + + squeezer_speed_increment = self._head96_squeezer_drive_mm_to_increment(squeezer_speed) + squeezer_acceleration_increment = self._head96_squeezer_drive_mm_to_increment( + squeezer_acceleration + ) + + resp = await self.send_command( + module="H0", + command="PI", + sv=f"{squeezer_speed_increment:05}", + sr=f"{squeezer_acceleration_increment:06}", + sw=f"{squeezer_current_limit:02}", + dw=f"{dispensing_drive_current_limit:02}", + ) + + return resp + + # -------------- 3.10.2 96-Head Movements -------------- + + # Conversion factors for 96-Head (mm per increment) + _head96_z_drive_mm_per_increment = 0.005 + _head96_y_drive_mm_per_increment = 0.015625 + _head96_dispensing_drive_mm_per_increment = 0.001025641026 + _head96_dispensing_drive_uL_per_increment = 0.019340933 + _head96_squeezer_drive_mm_per_increment = 0.0002086672009 + + # Z-axis conversions + + def _head96_z_drive_mm_to_increment(self, value_mm: float) -> int: + _unilab_logger.debug("[UNILAB] STARBackend._head96_z_drive_mm_to_increment() called") + """Convert mm to Z-axis hardware increments for 96-head.""" + return round(value_mm / self._head96_z_drive_mm_per_increment) + + def _head96_z_drive_increment_to_mm(self, value_increments: int) -> float: + _unilab_logger.debug("[UNILAB] STARBackend._head96_z_drive_increment_to_mm() called") + """Convert Z-axis hardware increments to mm for 96-head.""" + return round(value_increments * self._head96_z_drive_mm_per_increment, 2) + + # Y-axis conversions + + def _head96_y_drive_mm_to_increment(self, value_mm: float) -> int: + _unilab_logger.debug("[UNILAB] STARBackend._head96_y_drive_mm_to_increment() called") + """Convert mm to Y-axis hardware increments for 96-head.""" + return round(value_mm / self._head96_y_drive_mm_per_increment) + + def _head96_y_drive_increment_to_mm(self, value_increments: int) -> float: + _unilab_logger.debug("[UNILAB] STARBackend._head96_y_drive_increment_to_mm() called") + """Convert Y-axis hardware increments to mm for 96-head.""" + return round(value_increments * self._head96_y_drive_mm_per_increment, 2) + + # Dispensing drive conversions (mm and uL) + + def _head96_dispensing_drive_mm_to_increment(self, value_mm: float) -> int: + _unilab_logger.debug("[UNILAB] STARBackend._head96_dispensing_drive_mm_to_increment() called") + """Convert mm to dispensing drive hardware increments for 96-head.""" + return round(value_mm / self._head96_dispensing_drive_mm_per_increment) + + def _head96_dispensing_drive_increment_to_mm(self, value_increments: int) -> float: + _unilab_logger.debug("[UNILAB] STARBackend._head96_dispensing_drive_increment_to_mm() called") + """Convert dispensing drive hardware increments to mm for 96-head.""" + return round(value_increments * self._head96_dispensing_drive_mm_per_increment, 2) + + def _head96_dispensing_drive_uL_to_increment(self, value_uL: float) -> int: + _unilab_logger.debug("[UNILAB] STARBackend._head96_dispensing_drive_uL_to_increment() called") + """Convert uL to dispensing drive hardware increments for 96-head.""" + return round(value_uL / self._head96_dispensing_drive_uL_per_increment) + + def _head96_dispensing_drive_increment_to_uL(self, value_increments: int) -> float: + _unilab_logger.debug("[UNILAB] STARBackend._head96_dispensing_drive_increment_to_uL() called") + """Convert dispensing drive hardware increments to uL for 96-head.""" + return round(value_increments * self._head96_dispensing_drive_uL_per_increment, 2) + + def _head96_dispensing_drive_mm_to_uL(self, value_mm: float) -> float: + _unilab_logger.debug("[UNILAB] STARBackend._head96_dispensing_drive_mm_to_uL() called") + """Convert dispensing drive mm to uL for 96-head.""" + # Convert mm -> increment -> uL + increment = self._head96_dispensing_drive_mm_to_increment(value_mm) + return self._head96_dispensing_drive_increment_to_uL(increment) + + def _head96_dispensing_drive_uL_to_mm(self, value_uL: float) -> float: + _unilab_logger.debug("[UNILAB] STARBackend._head96_dispensing_drive_uL_to_mm() called") + """Convert dispensing drive uL to mm for 96-head.""" + # Convert uL -> increment -> mm + increment = self._head96_dispensing_drive_uL_to_increment(value_uL) + return self._head96_dispensing_drive_increment_to_mm(increment) + + # Squeezer drive conversions + + def _head96_squeezer_drive_mm_to_increment(self, value_mm: float) -> int: + _unilab_logger.debug("[UNILAB] STARBackend._head96_squeezer_drive_mm_to_increment() called") + """Convert mm to squeezer drive hardware increments for 96-head.""" + return round(value_mm / self._head96_squeezer_drive_mm_per_increment) + + def _head96_squeezer_drive_increment_to_mm(self, value_increments: int) -> float: + _unilab_logger.debug("[UNILAB] STARBackend._head96_squeezer_drive_increment_to_mm() called") + """Convert squeezer drive hardware increments to mm for 96-head.""" + return round(value_increments * self._head96_squeezer_drive_mm_per_increment, 2) + + # Movement commands + + @action(auto_prefix=True, description="将CoRe 96头移至安全位。") + async def move_core_96_to_safe_position(self): + _unilab_logger.debug("[UNILAB] STARBackend.move_core_96_to_safe_position() called") + """Move CoRe 96 Head to Z safe position.""" + warnings.warn( + "move_core_96_to_safe_position is deprecated. Use head96_move_to_z_safety instead. " + "This method will be removed in 2026-04", # TODO: remove 2026-04 + DeprecationWarning, + stacklevel=2, + ) + return await self.head96_move_to_z_safety() + + @_requires_head96 + @action(auto_prefix=True, description="将96头移动到Z安全位。") + async def head96_move_to_z_safety(self): + _unilab_logger.debug("[UNILAB] STARBackend.head96_move_to_z_safety() called") + """Move 96-Head to Z safety coordinate, i.e. z=342.5 mm.""" + return await self.send_command(module="C0", command="EV") + + @_requires_head96 + @action(auto_prefix=True, description="停放96头。") + async def head96_park( + self, + ): + _unilab_logger.debug("[UNILAB] STARBackend.head96_park() called") + """Park the 96-head. + + Uses firmware default speeds and accelerations. + """ + + return await self.send_command(module="H0", command="MO") + + @_requires_head96 + @action(auto_prefix=True, description="移动96头X轴。") + async def head96_move_x(self, x: float): + _unilab_logger.debug("[UNILAB] STARBackend.head96_move_x() called") + """Move the 96-head to a specified X-axis coordinate. + + Note: Unlike head96_move_y and head96_move_z, the X-axis movement does not have + dedicated speed/acceleration parameters - it uses the EM command which moves + all axes together. + + Args: + x: Target X coordinate in mm. Valid range: [-271.0, 974.0] + + Returns: + Response from the hardware command. + + Raises: + RuntimeError: If 96-head is not installed. + AssertionError: If parameter out of range. + """ + assert -271 <= x <= 974, "x must be between -271.0 and 974.0 mm" + + current_pos = await self.head96_request_position() + return await self.head96_move_to_coordinate( + Coordinate(x, current_pos.y, current_pos.z), + minimum_height_at_beginning_of_a_command=current_pos.z - 10, + ) + + @_requires_head96 + @action(auto_prefix=True, description="移动96头Y轴。") + async def head96_move_y( + self, + y: float, + speed: float = 300.0, + acceleration: float = 300.0, + current_protection_limiter: int = 15, + ): + _unilab_logger.debug("[UNILAB] STARBackend.head96_move_y() called") + """Move the 96-head to a specified Y-axis coordinate. + + Args: + y: Target Y coordinate in mm. Valid range: [93.75, 562.5] + speed: Movement speed in mm/sec. Valid range: [0.78125, 390.625 or 625.0]. Default: 300.0 + acceleration: Movement acceleration in mm/sec**2. Valid range: [78.125, 781.25]. Default: 300.0 + current_protection_limiter: Motor current limit (0-15, hardware units). Default: 15 + + Returns: + Response from the hardware command. + + Raises: + RuntimeError: If 96-head is not installed. + AssertionError: If firmware info missing or parameters out of range. + + Note: + Maximum speed varies by firmware version: + - Pre-2021: 390.625 mm/sec (25,000 increments) + - 2021+: 625.0 mm/sec (40,000 increments) + The exact firmware version introducing this change is undocumented. + """ + assert ( + self._head96_information is not None + ), "requires 96-head firmware version information for safe operation" + + fw_version = self._head96_information.fw_version + + # Determine speed limit based on firmware version + # Pre-2021 firmware appears to have lower speed capability or safety limits + # TODO: Verify exact firmware version and investigate the reason for this change + y_speed_upper_limit = 390.625 if fw_version.year <= 2021 else 625.0 # mm/sec + + # Validate parameters before hardware communication + assert 93.75 <= y <= 562.5, "y must be between 93.75 and 562.5 mm" + assert 0.78125 <= speed <= y_speed_upper_limit, ( + f"speed must be between 0.78125 and {y_speed_upper_limit} mm/sec for firmware version {fw_version}. " + f"Your firmware version: {self._head96_information.fw_version}. " + "If this limit seems incorrect, please test cautiously with an empty deck and report " + "accurate limits + firmware to PyLabRobot: https://github.com/PyLabRobot/pylabrobot/issues" + ) + assert ( + 78.125 <= acceleration <= 781.25 + ), "acceleration must be between 78.125 and 781.25 mm/sec**2" + assert isinstance(current_protection_limiter, int) and ( + 0 <= current_protection_limiter <= 15 + ), "current_protection_limiter must be an integer between 0 and 15" + + # Convert mm-based parameters to hardware increments using conversion methods + y_increment = self._head96_y_drive_mm_to_increment(y) + speed_increment = self._head96_y_drive_mm_to_increment(speed) + acceleration_increment = self._head96_y_drive_mm_to_increment(acceleration) + + resp = await self.send_command( + module="H0", + command="YA", + ya=f"{y_increment:05}", + yv=f"{speed_increment:05}", + yr=f"{acceleration_increment:05}", + yw=f"{current_protection_limiter:02}", + ) + + return resp + + @_requires_head96 + @action(auto_prefix=True, description="移动96头Z轴。") + async def head96_move_z( + self, + z: float, + speed: float = 80.0, + acceleration: float = 300.0, + current_protection_limiter: int = 15, + ): + _unilab_logger.debug("[UNILAB] STARBackend.head96_move_z() called") + """Move the 96-head to a specified Z-axis coordinate. + + Args: + z: Target Z coordinate in mm. Valid range: [180.5, 342.5] + speed: Movement speed in mm/sec. Valid range: [0.25, 100.0]. Default: 80.0 + acceleration: Movement acceleration in mm/sec^2. Valid range: [25.0, 500.0]. Default: 300.0 + current_protection_limiter: Motor current limit (0-15, hardware units). Default: 15 + + Returns: + Response from the hardware command. + + Raises: + RuntimeError: If 96-head is not installed. + AssertionError: If firmware info missing or parameters out of range. + + Note: + Firmware versions from 2021+ use 1:1 acceleration scaling, while pre-2021 versions + use 100x scaling. Both maintain a 100,000 increment upper limit. + """ + assert ( + self._head96_information is not None + ), "requires 96-head firmware version information for safe operation" + + fw_version = self._head96_information.fw_version + + # Validate parameters before hardware communication + assert 180.5 <= z <= 342.5, "z must be between 180.5 and 342.5 mm" + assert 0.25 <= speed <= 100.0, "speed must be between 0.25 and 100.0 mm/sec" + assert 25.0 <= acceleration <= 500.0, "acceleration must be between 25.0 and 500.0 mm/sec**2" + assert isinstance(current_protection_limiter, int) and ( + 0 <= current_protection_limiter <= 15 + ), "current_protection_limiter must be an integer between 0 and 15" + + # Determine acceleration scaling based on firmware version + # Pre-2010 firmware: acceleration parameter is multiplied by 1000 + # 2010+ firmware: acceleration parameter is 1:1 with increment/sec**2 + # TODO: identify exact firmware version that introduced this change + acceleration_multiplier = 1 if fw_version.year >= 2010 else 0.001 + + # Convert mm-based parameters to hardware increments + z_increment = self._head96_z_drive_mm_to_increment(z) + speed_increment = self._head96_z_drive_mm_to_increment(speed) + acceleration_increment = round( + self._head96_z_drive_mm_to_increment(acceleration) * acceleration_multiplier + ) + + resp = await self.send_command( + module="H0", + command="ZA", + za=f"{z_increment:05}", + zv=f"{speed_increment:05}", + zr=f"{acceleration_increment:06}", + zw=f"{current_protection_limiter:02}", + ) + + return resp + + # -------------- 3.10.2 Tip handling using CoRe 96 Head -------------- + + @need_iswap_parked + @_requires_head96 + @action(auto_prefix=True, description="用CoRe 96头拾取吸头。") + async def pick_up_tips_core96( + self, + x_position: int, + x_direction: int, + y_position: int, + tip_type_idx: int, + tip_pickup_method: int = 2, + z_deposit_position: int = 3425, + minimum_traverse_height_at_beginning_of_a_command: int = 3425, + minimum_height_command_end: int = 3425, + ): + _unilab_logger.debug("[UNILAB] STARBackend.pick_up_tips_core96() called") + """Pick up tips with CoRe 96 head + + Args: + x_position: x position [0.1mm]. Must be between 0 and 30000. Default 0. + x_direction: X-direction. 0 = positive 1 = negative. Must be between 0 and 1. Default 0. + y_position: y position [0.1mm]. Must be between 1080 and 5600. Default 5600. + tip_size: Tip type. + tip_pickup_method: Tip pick up method. 0 = pick up from rack. 1 = pick up from C0Re 96 tip + wash station. 2 = pick up with " full volume blow out" + z_deposit_position: Z- deposit position [0.1mm] (collar bearing position) Must bet between + 0 and 3425. Default 3425. + minimum_traverse_height_at_beginning_of_a_command: Minimum traverse height at beginning + of a command [0.1mm]. Must be between 0 and 3425. + minimum_height_command_end: Minimal height at command end [0.1 mm] Must be between 0 and 3425. + """ + + assert 0 <= x_position <= 30000, "x_position must be between 0 and 30000" + assert 0 <= x_direction <= 1, "x_direction must be between 0 and 1" + assert 1080 <= y_position <= 5600, "y_position must be between 1080 and 5600" + assert 0 <= z_deposit_position <= 3425, "z_deposit_position must be between 0 and 3425" + assert ( + 0 <= minimum_traverse_height_at_beginning_of_a_command <= 3425 + ), "minimum_traverse_height_at_beginning_of_a_command must be between 0 and 3425" + assert ( + 0 <= minimum_height_command_end <= 3425 + ), "minimum_height_command_end must be between 0 and 3425" + + return await self.send_command( + module="C0", + command="EP", + xs=f"{x_position:05}", + xd=x_direction, + yh=f"{y_position:04}", + tt=f"{tip_type_idx:02}", + wu=tip_pickup_method, + za=f"{z_deposit_position:04}", + zh=f"{minimum_traverse_height_at_beginning_of_a_command:04}", + ze=f"{minimum_height_command_end:04}", + ) + + @need_iswap_parked + @_requires_head96 + @action(auto_prefix=True, description="用CoRe 96头丢弃吸头。") + async def discard_tips_core96( + self, + x_position: int, + x_direction: int, + y_position: int, + z_deposit_position: int = 3425, + minimum_traverse_height_at_beginning_of_a_command: int = 3425, + minimum_height_command_end: int = 3425, + ): + _unilab_logger.debug("[UNILAB] STARBackend.discard_tips_core96() called") + """Drop tips with CoRe 96 head + + Args: + x_position: x position [0.1mm]. Must be between 0 and 30000. Default 0. + x_direction: X-direction. 0 = positive 1 = negative. Must be between 0 and 1. Default 0. + y_position: y position [0.1mm]. Must be between 1080 and 5600. Default 5600. + tip_type: Tip type. + tip_pickup_method: Tip pick up method. 0 = pick up from rack. 1 = pick up from C0Re 96 + tip wash station. 2 = pick up with " full volume blow out" + z_deposit_position: Z- deposit position [0.1mm] (collar bearing position) Must bet between + 0 and 3425. Default 3425. + minimum_traverse_height_at_beginning_of_a_command: Minimum traverse height at beginning + of a command [0.1mm]. Must be between 0 and 3425. + minimum_height_command_end: Minimal height at command end [0.1 mm] Must be between 0 and 3425 + """ + + assert 0 <= x_position <= 30000, "x_position must be between 0 and 30000" + assert 0 <= x_direction <= 1, "x_direction must be between 0 and 1" + assert 1080 <= y_position <= 5600, "y_position must be between 1080 and 5600" + assert 0 <= z_deposit_position <= 3425, "z_deposit_position must be between 0 and 3425" + assert ( + 0 <= minimum_traverse_height_at_beginning_of_a_command <= 3425 + ), "minimum_traverse_height_at_beginning_of_a_command must be between 0 and 3425" + assert ( + 0 <= minimum_height_command_end <= 3425 + ), "minimum_height_command_end must be between 0 and 3425" + + return await self.send_command( + module="C0", + command="ER", + xs=f"{x_position:05}", + xd=x_direction, + yh=f"{y_position:04}", + za=f"{z_deposit_position:04}", + zh=f"{minimum_traverse_height_at_beginning_of_a_command:04}", + ze=f"{minimum_height_command_end:04}", + ) + + # -------------- 3.10.3 Liquid handling using CoRe 96 Head -------------- + + # # # Granular commands # # # + + @action(auto_prefix=True, description="将96头分液驱动移至零体积位。") + async def head96_dispensing_drive_move_to_home_volume( + self, + ): + _unilab_logger.debug("[UNILAB] STARBackend.head96_dispensing_drive_move_to_home_volume() called") + """Move the 96-head dispensing drive into its home position (vol=0.0 uL). + + .. warning:: + This firmware command is known to be broken: the 96-head dispensing drive cannot reach + vol=0.0 uL, which typically raises + ``STARFirmwareError: {'CoRe 96 Head': UnknownHamiltonError('Position out of permitted + area')}``. + """ + + logger.warning( + "head96_dispensing_drive_move_to_home_volume is a known broken firmware command: " + "the 96-head dispensing drive cannot reach vol=0.0 uL and will likely raise " + "STARFirmwareError: {'CoRe 96 Head': UnknownHamiltonError('Position out of permitted " + "area')}. Attempting to send the command anyway." + ) + + return await self.send_command( + module="H0", + command="DL", + ) + + # # # "Atomic" liquid handling commands # # # + + @need_iswap_parked + @_requires_head96 + @action(auto_prefix=True, description="执行底层CoRe 96头吸液。") + async def aspirate_core_96( + self, + aspiration_type: int = 0, + x_position: int = 0, + x_direction: int = 0, + y_positions: int = 0, + minimum_traverse_height_at_beginning_of_a_command: int = 3425, + min_z_endpos: int = 3425, + lld_search_height: int = 3425, + liquid_surface_no_lld: int = 3425, + pull_out_distance_transport_air: int = 3425, + minimum_height: int = 3425, + second_section_height: int = 0, + second_section_ratio: int = 3425, + immersion_depth: int = 0, + immersion_depth_direction: int = 0, + surface_following_distance: float = 0, + aspiration_volumes: int = 0, + aspiration_speed: int = 1000, + transport_air_volume: int = 0, + blow_out_air_volume: int = 200, + pre_wetting_volume: int = 0, + lld_mode: int = 1, + gamma_lld_sensitivity: int = 1, + swap_speed: int = 100, + settling_time: int = 5, + mix_volume: int = 0, + mix_cycles: int = 0, + mix_position_from_liquid_surface: int = 250, + mix_surface_following_distance: int = 0, + speed_of_mix: int = 1000, + channel_pattern: List[bool] = [True] * 96, + limit_curve_index: int = 0, + tadm_algorithm: bool = False, + recording_mode: int = 0, + # Deprecated parameters, to be removed in future versions + # rm: >2026-01: + liquid_surface_sink_distance_at_the_end_of_aspiration: float = 0, + minimal_end_height: int = 3425, + liquid_surface_at_function_without_lld: int = 3425, + pull_out_distance_to_take_transport_air_in_function_without_lld: int = 50, + maximum_immersion_depth: int = 3425, + surface_following_distance_during_mix: int = 0, + tube_2nd_section_ratio: int = 3425, + tube_2nd_section_height_measured_from_zm: int = 0, + ): + _unilab_logger.debug("[UNILAB] STARBackend.aspirate_core_96() called") + """aspirate CoRe 96 + + Aspiration of liquid using CoRe 96 + + Args: + aspiration_type: Type of aspiration (0 = simple; 1 = sequence; 2 = cup emptied). Must be + between 0 and 2. Default 0. + x_position: X-Position [0.1mm] of well A1. Must be between 0 and 30000. Default 0. + x_direction: X-direction. 0 = positive 1 = negative. Must be between 0 and 1. Default 0. + y_positions: Y-Position [0.1mm] of well A1. Must be between 1080 and 5600. Default 0. + minimum_traverse_height_at_beginning_of_a_command: Minimum traverse height at beginning of + a command 0.1mm] (refers to all channels independent of tip pattern parameter 'tm'). + Must be between 0 and 3425. Default 3425. + min_z_endpos: Minimal height at command end [0.1mm]. Must be between 0 and 3425. Default 3425. + lld_search_height: LLD search height [0.1mm]. Must be between 0 and 3425. Default 3425. + liquid_surface_no_lld: Liquid surface at function without LLD [0.1mm]. Must be between 0 and 3425. Default 3425. + pull_out_distance_transport_air: pull out distance to take transport air in function without LLD [0.1mm]. Must be between 0 and 3425. Default 50. + minimum_height: Minimum height (maximum immersion depth) [0.1mm]. Must be between 0 and 3425. Default 3425. + second_section_height: second ratio height. Must be between 0 and 3425. Default 0. + second_section_ratio: Tube 2nd section ratio (See Fig 2.). Must be between 0 and 10000. Default 3425. + immersion_depth: Immersion depth [0.1mm]. Must be between 0 and 3600. Default 0. + immersion_depth_direction: Direction of immersion depth (0 = go deeper, 1 = go up out of + liquid). Must be between 0 and 1. Default 0. + surface_following_distance_at_the_end_of_aspiration: Surface following distance during + aspiration [0.1mm]. Must be between 0 and 990. Default 0. (renamed for clarity from + 'liquid_surface_sink_distance_at_the_end_of_aspiration' in firmware docs) + aspiration_volumes: Aspiration volume [0.1ul]. Must be between 0 and 11500. Default 0. + aspiration_speed: Aspiration speed [0.1ul/s]. Must be between 3 and 5000. Default 1000. + transport_air_volume: Transport air volume [0.1ul]. Must be between 0 and 500. Default 0. + blow_out_air_volume: Blow-out air volume [0.1ul]. Must be between 0 and 11500. Default 200. + pre_wetting_volume: Pre-wetting volume. Must be between 0 and 11500. Default 0. + lld_mode: LLD mode (0 = off, 1 = gamma, 2 = dP, 3 = dual, 4 = Z touch off). Must be between + 0 and 4. Default 1. + gamma_lld_sensitivity: gamma LLD sensitivity (1= high, 4=low). Must be between 1 and 4. + Default 1. + swap_speed: Swap speed (on leaving liquid) [0.1mm/s]. Must be between 3 and 1000. Default 100. + settling_time: Settling time [0.1s]. Must be between 0 and 99. Default 5. + mix_volume: mix volume [0.1ul]. Must be between 0 and 11500. Default 0. + mix_cycles: Number of mix cycles. Must be between 0 and 99. Default 0. + mix_position_from_liquid_surface: mix position in Z- direction from + liquid surface (LLD or absolute terms) [0.1mm]. Must be between 0 and 990. Default 250. + mix_surface_following_distance: surface following distance during + mix [0.1mm]. Must be between 0 and 990. Default 0. + speed_of_mix: Speed of mix [0.1ul/s]. Must be between 3 and 5000. + Default 1000. + todo: TODO: 24 hex chars. Must be between 4 and 5000. + limit_curve_index: limit curve index. Must be between 0 and 999. Default 0. + tadm_algorithm: TADM algorithm. Default False. + recording_mode: Recording mode 0 : no 1 : TADM errors only 2 : all TADM measurement. + Must be between 0 and 2. Default 0. + """ + + # # # TODO: delete > 2026-01 # # # + # deprecated liquid_surface_sink_distance_at_the_end_of_aspiration: + if liquid_surface_sink_distance_at_the_end_of_aspiration != 0.0: + surface_following_distance = liquid_surface_sink_distance_at_the_end_of_aspiration + warnings.warn( + "The liquid_surface_sink_distance_at_the_end_of_aspiration parameter is deprecated and will be removed in the future. " + "Use the Hamilton-standard surface_following_distance parameter instead.\n" + "liquid_surface_sink_distance_at_the_end_of_aspiration currently superseding " + "surface_following_distance.", + DeprecationWarning, + ) + + if minimal_end_height != 3425: + min_z_endpos = minimal_end_height + warnings.warn( + "The minimal_end_height parameter is deprecated and will be removed in the future. " + "Use the Hamilton-standard min_z_endpos parameter instead.\n" + "minimal_end_height currently superseding min_z_endpos.", + DeprecationWarning, + ) + + if liquid_surface_at_function_without_lld != 3425: + liquid_surface_no_lld = liquid_surface_at_function_without_lld + warnings.warn( + "The liquid_surface_at_function_without_lld parameter is deprecated and will be removed in the future. " + "Use the Hamilton-standard liquid_surface_no_lld parameter instead.\n" + "liquid_surface_at_function_without_lld currently superseding liquid_surface_no_lld.", + DeprecationWarning, + ) + + if pull_out_distance_to_take_transport_air_in_function_without_lld != 50: + pull_out_distance_transport_air = ( + pull_out_distance_to_take_transport_air_in_function_without_lld + ) + warnings.warn( + "The pull_out_distance_to_take_transport_air_in_function_without_lld parameter is deprecated and will be removed in the future. " + "Use the Hamilton-standard pull_out_distance_transport_air parameter instead.\n" + "pull_out_distance_to_take_transport_air_in_function_without_lld currently superseding pull_out_distance_transport_air.", + DeprecationWarning, + ) + + if maximum_immersion_depth != 3425: + minimum_height = maximum_immersion_depth + warnings.warn( + "The maximum_immersion_depth parameter is deprecated and will be removed in the future. " + "Use the Hamilton-standard minimum_height parameter instead.\n" + "minimum_height currently superseding maximum_immersion_depth.", + DeprecationWarning, + ) + + if surface_following_distance_during_mix != 0: + mix_surface_following_distance = surface_following_distance_during_mix + warnings.warn( + "The surface_following_distance_during_mix parameter is deprecated and will be removed in the future. " + "Use the Hamilton-standard mix_surface_following_distance parameter instead.\n" + "surface_following_distance_during_mix currently superseding mix_surface_following_distance.", + DeprecationWarning, + ) + + if tube_2nd_section_ratio != 3425: + second_section_ratio = tube_2nd_section_ratio + warnings.warn( + "The tube_2nd_section_ratio parameter is deprecated and will be removed in the future. " + "Use the Hamilton-standard second_section_ratio parameter instead.\n" + "tube_2nd_section_ratio currently superseding second_section_ratio.", + DeprecationWarning, + ) + + if tube_2nd_section_height_measured_from_zm != 0: + second_section_height = tube_2nd_section_height_measured_from_zm + warnings.warn( + "The tube_2nd_section_height_measured_from_zm parameter is deprecated and will be removed in the future. " + "Use the Hamilton-standard tube_2nd_section_height_measured_from_zm parameter instead.\n" + "tube_2nd_section_height_measured_from_zm currently superseding tube_2nd_section_height_measured_from_zm.", + DeprecationWarning, + ) + # # # delete # # # + + assert 0 <= aspiration_type <= 2, "aspiration_type must be between 0 and 2" + assert 0 <= x_position <= 30000, "x_position must be between 0 and 30000" + assert 0 <= x_direction <= 1, "x_direction must be between 0 and 1" + assert 1080 <= y_positions <= 5600, "y_positions must be between 1080 and 5600" + assert ( + 0 <= minimum_traverse_height_at_beginning_of_a_command <= 3425 + ), "minimum_traverse_height_at_beginning_of_a_command must be between 0 and 3425" + assert 0 <= min_z_endpos <= 3425, "min_z_endpos must be between 0 and 3425" + assert 0 <= lld_search_height <= 3425, "lld_search_height must be between 0 and 3425" + assert 0 <= liquid_surface_no_lld <= 3425, "liquid_surface_no_lld must be between 0 and 3425" + assert ( + 0 <= pull_out_distance_transport_air <= 3425 + ), "pull_out_distance_transport_air must be between 0 and 3425" + assert 0 <= minimum_height <= 3425, "minimum_height must be between 0 and 3425" + assert 0 <= second_section_height <= 3425, "second_section_height must be between 0 and 3425" + assert 0 <= second_section_ratio <= 10000, "second_section_ratio must be between 0 and 10000" + assert 0 <= immersion_depth <= 3600, "immersion_depth must be between 0 and 3600" + assert 0 <= immersion_depth_direction <= 1, "immersion_depth_direction must be between 0 and 1" + assert ( + 0 <= surface_following_distance <= 990 + ), "surface_following_distance must be between 0 and 990" + assert 0 <= aspiration_volumes <= 11500, "aspiration_volumes must be between 0 and 11500" + assert 3 <= aspiration_speed <= 5000, "aspiration_speed must be between 3 and 5000" + assert 0 <= transport_air_volume <= 500, "transport_air_volume must be between 0 and 500" + assert 0 <= blow_out_air_volume <= 11500, "blow_out_air_volume must be between 0 and 11500" + assert 0 <= pre_wetting_volume <= 11500, "pre_wetting_volume must be between 0 and 11500" + assert 0 <= lld_mode <= 4, "lld_mode must be between 0 and 4" + assert 1 <= gamma_lld_sensitivity <= 4, "gamma_lld_sensitivity must be between 1 and 4" + assert 3 <= swap_speed <= 1000, "swap_speed must be between 3 and 1000" + assert 0 <= settling_time <= 99, "settling_time must be between 0 and 99" + assert 0 <= mix_volume <= 11500, "mix_volume must be between 0 and 11500" + assert 0 <= mix_cycles <= 99, "mix_cycles must be between 0 and 99" + assert ( + 0 <= mix_position_from_liquid_surface <= 990 + ), "mix_position_from_liquid_surface must be between 0 and 990" + assert ( + 0 <= mix_surface_following_distance <= 990 + ), "mix_surface_following_distance must be between 0 and 990" + assert 3 <= speed_of_mix <= 5000, "speed_of_mix must be between 3 and 5000" + assert 0 <= limit_curve_index <= 999, "limit_curve_index must be between 0 and 999" + + assert 0 <= recording_mode <= 2, "recording_mode must be between 0 and 2" + + # Convert bool list to hex string + assert len(channel_pattern) == 96, "channel_pattern must be a list of 96 boolean values" + channel_pattern_bin_str = reversed(["1" if x else "0" for x in channel_pattern]) + channel_pattern_hex = hex(int("".join(channel_pattern_bin_str), 2)).upper()[2:] + + return await self.send_command( + module="C0", + command="EA", + aa=aspiration_type, + xs=f"{x_position:05}", + xd=x_direction, + yh=f"{y_positions:04}", + zh=f"{minimum_traverse_height_at_beginning_of_a_command:04}", + ze=f"{min_z_endpos:04}", + lz=f"{lld_search_height:04}", + zt=f"{liquid_surface_no_lld:04}", + pp=f"{pull_out_distance_transport_air:04}", + zm=f"{minimum_height:04}", + zv=f"{second_section_height:04}", + zq=f"{second_section_ratio:05}", + iw=f"{immersion_depth:03}", + ix=immersion_depth_direction, + fh=f"{surface_following_distance:03}", + af=f"{aspiration_volumes:05}", + ag=f"{aspiration_speed:04}", + vt=f"{transport_air_volume:03}", + bv=f"{blow_out_air_volume:05}", + wv=f"{pre_wetting_volume:05}", + cm=lld_mode, + cs=gamma_lld_sensitivity, + bs=f"{swap_speed:04}", + wh=f"{settling_time:02}", + hv=f"{mix_volume:05}", + hc=f"{mix_cycles:02}", + hp=f"{mix_position_from_liquid_surface:03}", + mj=f"{mix_surface_following_distance:03}", + hs=f"{speed_of_mix:04}", + cw=channel_pattern_hex, + cr=f"{limit_curve_index:03}", + cj=tadm_algorithm, + cx=recording_mode, + ) + + @need_iswap_parked + @_requires_head96 + @action(auto_prefix=True, description="执行底层CoRe 96头分液。") + async def dispense_core_96( + self, + dispensing_mode: int = 0, + x_position: int = 0, + x_direction: int = 0, + y_position: int = 0, + second_section_height: int = 0, + second_section_ratio: int = 3425, + lld_search_height: int = 3425, + liquid_surface_no_lld: int = 3425, + pull_out_distance_transport_air: int = 50, + minimum_height: int = 3425, + immersion_depth: int = 0, + immersion_depth_direction: int = 0, + surface_following_distance: float = 0, + minimum_traverse_height_at_beginning_of_a_command: int = 3425, + min_z_endpos: int = 3425, + dispense_volume: int = 0, + dispense_speed: int = 5000, + cut_off_speed: int = 250, + stop_back_volume: int = 0, + transport_air_volume: int = 0, + blow_out_air_volume: int = 200, + lld_mode: int = 1, + gamma_lld_sensitivity: int = 1, + side_touch_off_distance: int = 0, + swap_speed: int = 100, + settling_time: int = 5, + mixing_volume: int = 0, + mixing_cycles: int = 0, + mix_position_from_liquid_surface: int = 250, + mix_surface_following_distance: int = 0, + speed_of_mixing: int = 1000, + channel_pattern: List[bool] = [True] * 12 * 8, + limit_curve_index: int = 0, + tadm_algorithm: bool = False, + recording_mode: int = 0, + # Deprecated parameters, to be removed in future versions + # rm: >2026-01: + liquid_surface_sink_distance_at_the_end_of_dispense: float = 0, # surface_following_distance! + tube_2nd_section_ratio: int = 3425, + liquid_surface_at_function_without_lld: int = 3425, + maximum_immersion_depth: int = 3425, + minimal_end_height: int = 3425, + mixing_position_from_liquid_surface: int = 250, + surface_following_distance_during_mixing: int = 0, + pull_out_distance_to_take_transport_air_in_function_without_lld: int = 50, + tube_2nd_section_height_measured_from_zm: int = 0, + ): + _unilab_logger.debug("[UNILAB] STARBackend.dispense_core_96() called") + """Dispensing of liquid using CoRe 96 + + Args: + dispensing_mode: Type of dispensing mode 0 = Partial volume in jet mode 1 = Blow out + in jet mode 2 = Partial volume at surface 3 = Blow out at surface 4 = Empty tip at fix + position. Must be between 0 and 4. Default 0. + x_position: X-Position [0.1mm] of well A1. Must be between 0 and 30000. Default 0. + x_direction: X-direction. 0 = positive 1 = negative. Must be between 0 and 1. Default 0. + y_position: Y-Position [0.1mm] of well A1. Must be between 1080 and 5600. Default 0. + minimum_height: Minimum height (maximum immersion depth) [0.1mm]. Must be between 0 and 3425. Default 3425. + second_section_height: Second ratio height. [0.1mm]. Must be between 0 and 3425. Default 0. + second_section_ratio: Tube 2nd section ratio (See Fig 2.). Must be between 0 and 10000. Default 3425. + lld_search_height: LLD search height [0.1mm]. Must be between 0 and 3425. Default 3425. + liquid_surface_no_lld: Liquid surface at function without LLD [0.1mm]. Must be between 0 and 3425. Default 3425. + pull_out_distance_transport_air: pull out distance to take transport air in function without LLD [0.1mm]. Must be between 0 and 3425. Default 50. + immersion_depth: Immersion depth [0.1mm]. Must be between 0 and 3600. Default 0. + immersion_depth_direction: Direction of immersion depth (0 = go deeper, 1 = go up out of + liquid). Must be between 0 and 1. Default 0. + surface_following_distance: Liquid surface following distance during dispense [0.1mm]. + Must be between 0 and 990. Default 0. (renamed for clarity from + 'liquid_surface_sink_distance_at_the_end_of_dispense' in firmware docs) + minimum_traverse_height_at_beginning_of_a_command: Minimal traverse height at begin of + command [0.1mm]. Must be between 0 and 3425. Default 3425. + min_z_endpos: Minimal height at command end [0.1mm]. Must be between 0 and 3425. Default 3425. + dispense_volume: Dispense volume [0.1ul]. Must be between 0 and 11500. Default 0. + dispense_speed: Dispense speed [0.1ul/s]. Must be between 3 and 5000. Default 5000. + cut_off_speed: Cut-off speed [0.1ul/s]. Must be between 3 and 5000. Default 250. + stop_back_volume: Stop back volume [0.1ul/s]. Must be between 0 and 999. Default 0. + transport_air_volume: Transport air volume [0.1ul]. Must be between 0 and 500. Default 0. + blow_out_air_volume: Blow-out air volume [0.1ul]. Must be between 0 and 11500. Default 200. + lld_mode: LLD mode (0 = off, 1 = gamma, 2 = dP, 3 = dual, 4 = Z touch off). Must be + between 0 and 4. Default 1. + gamma_lld_sensitivity: gamma LLD sensitivity (1= high, 4=low). Must be between 1 and 4. + Default 1. + side_touch_off_distance: side touch off distance [0.1 mm] 0 = OFF ( > 0 = ON & turns LLD off) + Must be between 0 and 45. Default 1. + swap_speed: Swap speed (on leaving liquid) [0.1mm/s]. Must be between 3 and 1000. Default 100. + settling_time: Settling time [0.1s]. Must be between 0 and 99. Default 5. + mixing_volume: mix volume [0.1ul]. Must be between 0 and 11500. Default 0. + mixing_cycles: Number of mixing cycles. Must be between 0 and 99. Default 0. + mix_position_from_liquid_surface: mix position in Z- direction from liquid surface (LLD or absolute terms) [0.1mm]. Must be between 0 and 990. Default 250. + mix_surface_following_distance: surface following distance during mixing [0.1mm]. Must be between 0 and 990. Default 0. + speed_of_mixing: Speed of mixing [0.1ul/s]. Must be between 3 and 5000. Default 1000. + channel_pattern: list of 96 boolean values + limit_curve_index: limit curve index. Must be between 0 and 999. Default 0. + tadm_algorithm: TADM algorithm. Default False. + recording_mode: Recording mode 0 : no 1 : TADM errors only 2 : all TADM measurement. Must + be between 0 and 2. Default 0. + """ + + # # # TODO: delete > 2026-01 # # # + # deprecated liquid_surface_sink_distance_at_the_end_of_aspiration: + if liquid_surface_sink_distance_at_the_end_of_dispense != 0.0: + surface_following_distance = liquid_surface_sink_distance_at_the_end_of_dispense + warnings.warn( + "The liquid_surface_sink_distance_at_the_end_of_dispense parameter is deprecated and will be removed in the future. " + "Use the Hamilton-standard surface_following_distance parameter instead.\n" + "liquid_surface_sink_distance_at_the_end_of_dispense currently superseding surface_following_distance.", + DeprecationWarning, + ) + + if tube_2nd_section_ratio != 3425: + second_section_ratio = tube_2nd_section_ratio + warnings.warn( + "The tube_2nd_section_ratio parameter is deprecated and will be removed in the future. " + "Use the Hamilton-standard second_section_ratio parameter instead.\n" + "second_section_ratio currently superseding tube_2nd_section_ratio.", + DeprecationWarning, + ) + + if maximum_immersion_depth != 3425: + minimum_height = maximum_immersion_depth + warnings.warn( + "The maximum_immersion_depth parameter is deprecated and will be removed in the future. " + "Use the Hamilton-standard minimum_height parameter instead.\n" + "minimum_height currently superseding maximum_immersion_depth.", + DeprecationWarning, + ) + + if liquid_surface_at_function_without_lld != 3425: + liquid_surface_no_lld = liquid_surface_at_function_without_lld + warnings.warn( + "The liquid_surface_at_function_without_lld parameter is deprecated and will be removed in the future. " + "Use the Hamilton-standard liquid_surface_no_lld parameter instead.\n" + "liquid_surface_at_function_without_lld currently superseding liquid_surface_no_lld.", + DeprecationWarning, + ) + + if minimal_end_height != 3425: + min_z_endpos = minimal_end_height + warnings.warn( + "The minimal_end_height parameter is deprecated and will be removed in the future. " + "Use the Hamilton-standard min_z_endpos parameter instead.\n" + "minimal_end_height currently superseding min_z_endpos.", + DeprecationWarning, + ) + + if mixing_position_from_liquid_surface != 250: + mix_position_from_liquid_surface = mixing_position_from_liquid_surface + warnings.warn( + "The mixing_position_from_liquid_surface parameter is deprecated and will be removed in the future. " + "Use the Hamilton-standard mix_position_from_liquid_surface parameter instead.\n" + "mixing_position_from_liquid_surface currently superseding mix_position_from_liquid_surface.", + DeprecationWarning, + ) + + if surface_following_distance_during_mixing != 0: + mix_surface_following_distance = surface_following_distance_during_mixing + warnings.warn( + "The surface_following_distance_during_mixing parameter is deprecated and will be removed in the future. " + "Use the Hamilton-standard mix_surface_following_distance parameter instead.\n" + "mix_surface_following_distance currently superseding surface_following_distance_during_mixing.", + DeprecationWarning, + ) + + if pull_out_distance_to_take_transport_air_in_function_without_lld != 50: + pull_out_distance_transport_air = ( + pull_out_distance_to_take_transport_air_in_function_without_lld + ) + warnings.warn( + "The pull_out_distance_to_take_transport_air_in_function_without_lld parameter is deprecated and will be removed in the future. " + "Use the Hamilton-standard pull_out_distance_transport_air parameter instead.\n" + "pull_out_distance_to_take_transport_air_in_function_without_lld currently superseding pull_out_distance_transport_air.", + DeprecationWarning, + ) + + if tube_2nd_section_height_measured_from_zm != 0: + second_section_height = tube_2nd_section_height_measured_from_zm + warnings.warn( + "The tube_2nd_section_height_measured_from_zm parameter is deprecated and will be removed in the future. " + "Use the Hamilton-standard second_section_height parameter instead.\n" + "tube_2nd_section_height_measured_from_zm currently superseding second_section_height.", + DeprecationWarning, + ) + # # # delete # # # + + assert 0 <= dispensing_mode <= 4, "dispensing_mode must be between 0 and 4" + assert 0 <= x_position <= 30000, "x_position must be between 0 and 30000" + assert 0 <= x_direction <= 1, "x_direction must be between 0 and 1" + assert 1080 <= y_position <= 5600, "y_position must be between 1080 and 5600" + assert 0 <= minimum_height <= 3425, "minimum_height must be between 0 and 3425" + assert 0 <= second_section_height <= 3425, "second_section_height must be between 0 and 3425" + assert 0 <= second_section_ratio <= 10000, "second_section_ratio must be between 0 and 10000" + assert 0 <= lld_search_height <= 3425, "lld_search_height must be between 0 and 3425" + assert 0 <= liquid_surface_no_lld <= 3425, "liquid_surface_no_lld must be between 0 and 3425" + assert ( + 0 <= pull_out_distance_transport_air <= 3425 + ), "pull_out_distance_transport_air must be between 0 and 3425" + assert 0 <= immersion_depth <= 3600, "immersion_depth must be between 0 and 3600" + assert 0 <= immersion_depth_direction <= 1, "immersion_depth_direction must be between 0 and 1" + assert ( + 0 <= surface_following_distance <= 990 + ), "surface_following_distance must be between 0 and 990" + assert ( + 0 <= minimum_traverse_height_at_beginning_of_a_command <= 3425 + ), "minimum_traverse_height_at_beginning_of_a_command must be between 0 and 3425" + assert 0 <= min_z_endpos <= 3425, "min_z_endpos must be between 0 and 3425" + assert 0 <= dispense_volume <= 11500, "dispense_volume must be between 0 and 11500" + assert 3 <= dispense_speed <= 5000, "dispense_speed must be between 3 and 5000" + assert 3 <= cut_off_speed <= 5000, "cut_off_speed must be between 3 and 5000" + assert 0 <= stop_back_volume <= 999, "stop_back_volume must be between 0 and 999" + assert 0 <= transport_air_volume <= 500, "transport_air_volume must be between 0 and 500" + assert 0 <= blow_out_air_volume <= 11500, "blow_out_air_volume must be between 0 and 11500" + assert 0 <= lld_mode <= 4, "lld_mode must be between 0 and 4" + assert 1 <= gamma_lld_sensitivity <= 4, "gamma_lld_sensitivity must be between 1 and 4" + assert 0 <= side_touch_off_distance <= 45, "side_touch_off_distance must be between 0 and 45" + assert 3 <= swap_speed <= 1000, "swap_speed must be between 3 and 1000" + assert 0 <= settling_time <= 99, "settling_time must be between 0 and 99" + assert 0 <= mixing_volume <= 11500, "mixing_volume must be between 0 and 11500" + assert 0 <= mixing_cycles <= 99, "mixing_cycles must be between 0 and 99" + assert ( + 0 <= mix_position_from_liquid_surface <= 990 + ), "mix_position_from_liquid_surface must be between 0 and 990" + assert ( + 0 <= mix_surface_following_distance <= 990 + ), "mix_surface_following_distance must be between 0 and 990" + assert 3 <= speed_of_mixing <= 5000, "speed_of_mixing must be between 3 and 5000" + assert 0 <= limit_curve_index <= 999, "limit_curve_index must be between 0 and 999" + assert 0 <= recording_mode <= 2, "recording_mode must be between 0 and 2" + + # Convert bool list to hex string + assert len(channel_pattern) == 96, "channel_pattern must be a list of 96 boolean values" + channel_pattern_bin_str = reversed(["1" if x else "0" for x in channel_pattern]) + channel_pattern_hex = hex(int("".join(channel_pattern_bin_str), 2)).upper()[2:] + + return await self.send_command( + module="C0", + command="ED", + da=dispensing_mode, + xs=f"{x_position:05}", + xd=x_direction, + yh=f"{y_position:04}", + zm=f"{minimum_height:04}", + zv=f"{second_section_height:04}", + zq=f"{second_section_ratio:05}", + lz=f"{lld_search_height:04}", + zt=f"{liquid_surface_no_lld:04}", + pp=f"{pull_out_distance_transport_air:04}", + iw=f"{immersion_depth:03}", + ix=immersion_depth_direction, + fh=f"{surface_following_distance:03}", + zh=f"{minimum_traverse_height_at_beginning_of_a_command:04}", + ze=f"{min_z_endpos:04}", + df=f"{dispense_volume:05}", + dg=f"{dispense_speed:04}", + es=f"{cut_off_speed:04}", + ev=f"{stop_back_volume:03}", + vt=f"{transport_air_volume:03}", + bv=f"{blow_out_air_volume:05}", + cm=lld_mode, + cs=gamma_lld_sensitivity, + ej=f"{side_touch_off_distance:02}", + bs=f"{swap_speed:04}", + wh=f"{settling_time:02}", + hv=f"{mixing_volume:05}", + hc=f"{mixing_cycles:02}", + hp=f"{mix_position_from_liquid_surface:03}", + mj=f"{mix_surface_following_distance:03}", + hs=f"{speed_of_mixing:04}", + cw=channel_pattern_hex, + cr=f"{limit_curve_index:03}", + cj=tadm_algorithm, + cx=recording_mode, + ) + + # -------------- 3.10.4 Adjustment & movement commands -------------- + + @_requires_head96 + @action(auto_prefix=True, description="将CoRe 96头移动到指定位置。") + async def move_core_96_head_to_defined_position( + self, + x: float, + y: float, + z: float = 342.5, + minimum_height_at_beginning_of_a_command: float = 342.5, + ): + _unilab_logger.debug("[UNILAB] STARBackend.move_core_96_head_to_defined_position() called") + """Move CoRe 96 Head to defined position + + Args: + x: X-Position [1mm] of well A1. Must be between -300.0 and 300.0. Default 0. + y: Y-Position [1mm]. Must be between 108.0 and 560.0. Default 0. + z: Z-Position [1mm]. Must be between 0 and 560.0. Default 0. + minimum_height_at_beginning_of_a_command: Minimum height at beginning of a command [1mm] + (refers to all channels independent of tip pattern parameter 'tm'). Must be between 0 and + 342.5. Default 342.5. + """ + + warnings.warn( # TODO: remove 2025-02 + "`move_core_96_head_to_defined_position` is deprecated and will be " + "removed in 2025-02. Use `head96_move_to_coordinate` instead.", + DeprecationWarning, + stacklevel=2, + ) + + # TODO: these are values for a STARBackend. Find them for a STARlet. + self._check_96_position_legal(Coordinate(x, y, z)) + assert ( + 0 <= minimum_height_at_beginning_of_a_command <= 342.5 + ), "minimum_height_at_beginning_of_a_command must be between 0 and 342.5" + + return await self.send_command( + module="C0", + command="EM", + xs=f"{abs(round(x*10)):05}", + xd=0 if x >= 0 else 1, + yh=f"{round(y*10):04}", + za=f"{round(z*10):04}", + zh=f"{round(minimum_height_at_beginning_of_a_command*10):04}", + ) + + @_requires_head96 + @action(auto_prefix=True, description="将96头移动到指定坐标。") + async def head96_move_to_coordinate( + self, + coordinate: Coordinate, + minimum_height_at_beginning_of_a_command: float = 342.5, + ): + _unilab_logger.debug("[UNILAB] STARBackend.head96_move_to_coordinate() called") + """Move STAR(let) 96-Head to defined Coordinate + + Args: + coordinate: Coordinate of A1 in mm + - if tip present refers to tip bottom, + - if not present refers to channel bottom + minimum_height_at_beginning_of_a_command: Minimum height at beginning of a command [1mm] + (refers to all channels independent of tip pattern parameter 'tm'). Must be between ? and + 342.5. Default 342.5. + """ + + self._check_96_position_legal(coordinate) + + assert ( + 0 <= minimum_height_at_beginning_of_a_command <= 342.5 + ), "minimum_height_at_beginning_of_a_command must be between 0 and 342.5" + + return await self.send_command( + module="C0", + command="EM", + xs=f"{abs(round(coordinate.x*10)):05}", + xd="0" if coordinate.x >= 0 else "1", + yh=f"{round(coordinate.y*10):04}", + za=f"{round(coordinate.z*10):04}", + zh=f"{round(minimum_height_at_beginning_of_a_command*10):04}", + ) + + HEAD96_DISPENSING_DRIVE_VOL_LIMIT_BOTTOM = 0 + HEAD96_DISPENSING_DRIVE_VOL_LIMIT_TOP = 1244.59 + + @_requires_head96 + @action(auto_prefix=True, description="将96头分液驱动移动到指定位置。") + async def head96_dispensing_drive_move_to_position( + self, + position, + speed: float = 261.1, + stop_speed: float = 0, + acceleration: float = 17406.84, + current_protection_limiter: int = 15, + ): + _unilab_logger.debug("[UNILAB] STARBackend.head96_dispensing_drive_move_to_position() called") + """Move dispensing drive to absolute position in uL + + Args: + position: Position in uL. Between 0, 1244.59. + speed: Speed in uL/s. Between 0.1, 1063.75. + stop_speed: Stop speed in uL/s. Between 0, 1063.75. + acceleration: Acceleration in uL/s^2. Between 96.7, 17406.84. + current_protection_limiter: Current protection limiter (0-15), default 15 + """ + + if not ( + self.HEAD96_DISPENSING_DRIVE_VOL_LIMIT_BOTTOM + <= position + <= self.HEAD96_DISPENSING_DRIVE_VOL_LIMIT_TOP + ): + raise ValueError("position must be between 0 and 1244.59") + if not (0.1 <= speed <= 1063.75): + raise ValueError("speed must be between 0.1 and 1063.75") + if not (0 <= stop_speed <= 1063.75): + raise ValueError("stop_speed must be between 0 and 1063.75") + if not (96.7 <= acceleration <= 17406.84): + raise ValueError("acceleration must be between 96.7 and 17406.84") + if not (0 <= current_protection_limiter <= 15): + raise ValueError("current_protection_limiter must be between 0 and 15") + + position_increments = self._head96_dispensing_drive_uL_to_increment(position) + speed_increments = self._head96_dispensing_drive_uL_to_increment(speed) + stop_speed_increments = self._head96_dispensing_drive_uL_to_increment(stop_speed) + acceleration_increments = self._head96_dispensing_drive_uL_to_increment(acceleration) + + await self.send_command( + module="H0", + command="DQ", + dq=f"{position_increments:05}", + dv=f"{speed_increments:05}", + du=f"{stop_speed_increments:05}", + dr=f"{acceleration_increments:06}", + dw=f"{current_protection_limiter:02}", + ) + + @action(auto_prefix=True, description="移动CoRe 96头X轴。") + async def move_core_96_head_x(self, x_position: float): + _unilab_logger.debug("[UNILAB] STARBackend.move_core_96_head_x() called") + """Move CoRe 96 Head X to absolute position + + .. deprecated:: + Use :meth:`head96_move_x` instead. Will be removed in 2026-06. + """ + warnings.warn( + "`move_core_96_head_x` is deprecated. Use `head96_move_x` instead.", + DeprecationWarning, + stacklevel=2, + ) + return await self.head96_move_x(x_position) + + @action(auto_prefix=True, description="移动CoRe 96头Y轴。") + async def move_core_96_head_y(self, y_position: float): + _unilab_logger.debug("[UNILAB] STARBackend.move_core_96_head_y() called") + """Move CoRe 96 Head Y to absolute position + + .. deprecated:: + Use :meth:`head96_move_y` instead. Will be removed in 2026-06. + """ + warnings.warn( + "`move_core_96_head_y` is deprecated. Use `head96_move_y` instead.", + DeprecationWarning, + stacklevel=2, + ) + return await self.head96_move_y(y_position) + + @action(auto_prefix=True, description="移动CoRe 96头Z轴。") + async def move_core_96_head_z(self, z_position: float): + _unilab_logger.debug("[UNILAB] STARBackend.move_core_96_head_z() called") + """Move CoRe 96 Head Z to absolute position + + .. deprecated:: + Use :meth:`head96_move_z` instead. Will be removed in 2026-06. + """ + warnings.warn( + "`move_core_96_head_z` is deprecated. Use `head96_move_z` instead.", + DeprecationWarning, + stacklevel=2, + ) + return await self.head96_move_z(z_position) + + @action(auto_prefix=True, description="将96头移动到指定坐标。") + async def move_96head_to_coordinate( + self, + coordinate: Coordinate, + minimum_height_at_beginning_of_a_command: float = 342.5, + ): + _unilab_logger.debug("[UNILAB] STARBackend.move_96head_to_coordinate() called") + """Move STAR(let) 96-Head to defined Coordinate + + .. deprecated:: + Use :meth:`head96_move_to_coordinate` instead. Will be removed in 2026-06. + """ + warnings.warn( + "`move_96head_to_coordinate` is deprecated. Use `head96_move_to_coordinate` instead.", + DeprecationWarning, + stacklevel=2, + ) + return await self.head96_move_to_coordinate( + coordinate=coordinate, + minimum_height_at_beginning_of_a_command=minimum_height_at_beginning_of_a_command, + ) + + # -------------- 3.10.5 Wash procedure commands using CoRe 96 Head -------------- + + # TODO:(command:EG) Washing tips using CoRe 96 Head + # TODO:(command:EU) Empty washed tips (end of wash procedure only) + + # -------------- 3.10.6 Query CoRe 96 Head -------------- + + @action(auto_prefix=True, description="获取CoRe 96头吸头存在状态。") + async def request_tip_presence_in_core_96_head(self): + _unilab_logger.debug("[UNILAB] STARBackend.request_tip_presence_in_core_96_head() called") + """Deprecated - use `head96_request_tip_presence` instead. + + Returns: + dictionary with key qh: + qh: 0 = no tips, 1 = tips are picked up + """ + warnings.warn( # TODO: remove 2026-06 + "`request_tip_presence_in_core_96_head` is deprecated and will be " + "removed in 2026-06 use `head96_request_tip_presence` instead.", + DeprecationWarning, + stacklevel=2, + ) + + return await self.send_command(module="C0", command="QH", fmt="qh#") + + @action(auto_prefix=True, description="获取96头吸头存在状态。") + async def head96_request_tip_presence(self) -> int: + _unilab_logger.debug("[UNILAB] STARBackend.head96_request_tip_presence() called") + """Request Tip presence on the 96-Head + + Note: this command requests this information from the STAR(let)'s + internal memory. + It does not directly sense whether tips are present. + + Returns: + 0 = no tips + 1 = firmware believes tips are on the 96-head + """ + resp = await self.send_command(module="C0", command="QH", fmt="qh#") + + return int(resp["qh"]) + + @action(auto_prefix=True, description="获取CoRe 96头位置。") + async def request_position_of_core_96_head(self): + _unilab_logger.debug("[UNILAB] STARBackend.request_position_of_core_96_head() called") + """Deprecated - use `head96_request_position` instead.""" + + warnings.warn( # TODO: remove 2026-02 + "`request_position_of_core_96_head` is deprecated and will be " + "removed in 2026-02 use `head96_request_position` instead.", + DeprecationWarning, + stacklevel=2, + ) + + return await self.head96_request_position() + + @action(auto_prefix=True, description="获取96头位置。") + async def head96_request_position(self) -> Coordinate: + _unilab_logger.debug("[UNILAB] STARBackend.head96_request_position() called") + """Request position of CoRe 96 Head (A1 considered to tip length) + + Returns: + Coordinate: x, y, z in mm + """ + + resp = await self.send_command(module="C0", command="QI", fmt="xs#####xd#yh####za####") + + x_coordinate = resp["xs"] / 10 + y_coordinate = resp["yh"] / 10 + z_coordinate = resp["za"] / 10 + + x_coordinate = x_coordinate if resp["xd"] == 0 else -x_coordinate + + return Coordinate(x=x_coordinate, y=y_coordinate, z=z_coordinate) + + @action(auto_prefix=True, description="获取CoRe 96头TADM状态。") + async def request_core_96_head_channel_tadm_status(self): + _unilab_logger.debug("[UNILAB] STARBackend.request_core_96_head_channel_tadm_status() called") + """Request CoRe 96 Head channel TADM Status + + Returns: + qx: TADM channel status 0 = off 1 = on + """ + + return await self.send_command(module="C0", command="VC", fmt="qx#") + + @action(auto_prefix=True, description="获取CoRe 96头TADM错误状态。") + async def request_core_96_head_channel_tadm_error_status(self): + _unilab_logger.debug("[UNILAB] STARBackend.request_core_96_head_channel_tadm_error_status() called") + """Request CoRe 96 Head channel TADM error status + + Returns: + vb: error pattern 0 = no error + """ + + return await self.send_command(module="C0", command="VB", fmt="vb" + "&" * 24) + + @action(auto_prefix=True, description="获取96头分液驱动位置(毫米)。") + async def head96_dispensing_drive_request_position_mm(self) -> float: + _unilab_logger.debug("[UNILAB] STARBackend.head96_dispensing_drive_request_position_mm() called") + """Request 96 Head dispensing drive position in mm""" + resp = await self.send_command(module="H0", command="RD", fmt="rd######") + return self._head96_dispensing_drive_increment_to_mm(resp["rd"]) + + @action(auto_prefix=True, description="获取96头分液驱动位置(微升)。") + async def head96_dispensing_drive_request_position_uL(self) -> float: + _unilab_logger.debug("[UNILAB] STARBackend.head96_dispensing_drive_request_position_uL() called") + """Request 96 Head dispensing drive position in uL""" + position_mm = await self.head96_dispensing_drive_request_position_mm() + return self._head96_dispensing_drive_mm_to_uL(position_mm) + + # -------------- 3.11 384 Head commands -------------- + + # -------------- 3.11.1 Initialization -------------- + + # -------------- 3.11.2 Tip handling using 384 Head -------------- + + # -------------- 3.11.3 Liquid handling using 384 Head -------------- + + # -------------- 3.11.4 Adjustment & movement commands -------------- + + # -------------- 3.11.5 Wash procedure commands using 384 Head -------------- + + # -------------- 3.11.6 Query 384 Head -------------- + + # -------------- 3.12 Nano pipettor commands -------------- + + # TODO: all nano pipettor commands + + # -------------- 3.12.1 Initialization -------------- + + # TODO:(command:NI) + # TODO:(command:NV) + # TODO:(command:NP) + + # -------------- 3.12.2 Nano pipettor liquid handling commands -------------- + + # TODO:(command:NA) + # TODO:(command:ND) + # TODO:(command:NF) + + # -------------- 3.12.3 Nano pipettor wash & clean commands -------------- + + # TODO:(command:NW) + # TODO:(command:NU) + + # -------------- 3.12.4 Nano pipettor adjustment & movements -------------- + + # TODO:(command:NM) + # TODO:(command:NT) + + # -------------- 3.12.5 Nano pipettor query -------------- + + # TODO:(command:QL) + # TODO:(command:QN) + # TODO:(command:RN) + # TODO:(command:QQ) + # TODO:(command:QR) + # TODO:(command:QO) + # TODO:(command:RR) + # TODO:(command:QU) + + # -------------- 3.13 Autoload commands -------------- + + # -------------- 3.13.1 Initialization -------------- + + @action(auto_prefix=True, description="初始化autoload模块。") + async def initialize_auto_load(self): + _unilab_logger.debug("[UNILAB] STARBackend.initialize_auto_load() called") + """Deprecated - use `initialize_autoload` instead.""" + warnings.warn( # TODO: remove 2025-02 + "`initialize_auto_load` is deprecated and will be removed " + "in 2025-02 use `initialize_autoload` instead.", + DeprecationWarning, + stacklevel=2, + ) + return await self.initialize_autoload() + + @action(auto_prefix=True, description="初始化autoload模块。") + async def initialize_autoload(self): + _unilab_logger.debug("[UNILAB] STARBackend.initialize_autoload() called") + """Initialize Auto load module""" + + return await self.send_command(module="C0", command="II") + + @action(auto_prefix=True, description="将autoload移至Z安全位。") + async def move_auto_load_to_z_save_position(self): + _unilab_logger.debug("[UNILAB] STARBackend.move_auto_load_to_z_save_position() called") + """Deprecated - use `move_autoload_to_safe_z_position` instead.""" + + warnings.warn( # TODO: remove 2025-02 + "`move_auto_load_to_z_save_position` is deprecated and will be " + "removed in 2025-02 use `move_autoload_to_safe_z_position` instead.", + DeprecationWarning, + stacklevel=2, + ) + + return await self.move_autoload_to_safe_z_position() + + @action(auto_prefix=True, description="将autoload移至Z安全位。") + async def move_autoload_to_save_z_position(self): + _unilab_logger.debug("[UNILAB] STARBackend.move_autoload_to_save_z_position() called") + """Deprecated - use `move_autoload_to_safe_z_position` instead.""" + warnings.warn( # TODO: remove 2025-02 + "`move_autoload_to_saVe_z_position` is deprecated and will be " + "removed in 2025-02 use `move_autoload_to_safe_z_position` instead.", + DeprecationWarning, + stacklevel=2, + ) + return await self.move_autoload_to_safe_z_position() + + @action(auto_prefix=True, description="将autoload移至Z安全位。") + async def move_autoload_to_safe_z_position(self): + _unilab_logger.debug("[UNILAB] STARBackend.move_autoload_to_safe_z_position() called") + """Move autoload carrier handling wheel to safe Z position""" + + return await self.send_command(module="C0", command="IV") + + @action(auto_prefix=True, description="获取autoload槽位位置。") + async def request_auto_load_slot_position(self): + _unilab_logger.debug("[UNILAB] STARBackend.request_auto_load_slot_position() called") + """Deprecated - use `request_autoload_track` instead.""" + warnings.warn( # TODO: remove 2025-02 + "`request_auto_load_slot_position` is deprecated and will be " + "removed in 2025-02 use `request_autoload_track` instead.", + DeprecationWarning, + stacklevel=2, + ) + return await self.request_autoload_track() + + @action(auto_prefix=True, description="获取autoload当前轨道。") + async def request_autoload_track(self) -> int: + _unilab_logger.debug("[UNILAB] STARBackend.request_autoload_track() called") + """Request current track of the autoload 'carrier handler'. + + Returns: + track (0..54) + """ + resp = await self.send_command(module="C0", command="QA", fmt="qa##") + return int(resp["qa"]) + + @action(auto_prefix=True, description="获取autoload模块类型。") + async def request_autoload_type(self) -> str: + _unilab_logger.debug("[UNILAB] STARBackend.request_autoload_type() called") + """ + Query the autoload module type. + + This sends the `C0:QA` command, which returns a CQ-format response containing + the autoload identification fields, error/trace information, and the module + type code. The `cq` field specifies the autoload hardware type: + + 0 = ML-STAR with 1D Barcode Scanner + 1 = XRP Lite + 2 = ML-STAR with 2D Barcode Scanner + 3-9 = Reserved / other module variants + + Returns: + int: The autoload module type code (0-9). + """ + + autoload_type_dict = { + 0: "ML-STAR with 1D Barcode Scanner", + 1: "XRP Lite", + 2: "ML-STAR with 2D Barcode Scanner", + } + + resp = await self.send_command(module="C0", command="CQ", fmt="cq#") + resp = autoload_type_dict[resp["cq"]] if resp["cq"] in autoload_type_dict else resp["cq"] + + return str(resp) + + # -------------- 3.13.2 Carrier sensing -------------- + + def _decode_hex_bitmask_to_track_list(self, mask_hex: str) -> list[int]: + _unilab_logger.debug("[UNILAB] STARBackend._decode_hex_bitmask_to_track_list() called") + """ + Decode a hex occupancy bitmask of arbitrary length. + Each hex nibble = 4 slots. + Slot numbering starts at 1 from the rightmost nibble (LSB). + """ + mask_hex = mask_hex.strip() + + if not all(c in "0123456789abcdefABCDEF" for c in mask_hex): + raise ValueError(f"Invalid hex in mask: {mask_hex!r}") + + slots = [] + bit_index = 1 + + # Rightmost hex digit = slot 1 (LSB) + for nibble in reversed(mask_hex): + val = int(nibble, 16) + for bit in range(4): + if val & (1 << bit): + slots.append(bit_index) + bit_index += 1 + + return sorted(slots) + + @action(auto_prefix=True, description="检测deck上载架存在情况。") + async def request_presence_of_carriers_on_deck(self) -> list[int]: + _unilab_logger.debug("[UNILAB] STARBackend.request_presence_of_carriers_on_deck() called") + """ + Read the deck carrier presence sensors and return the positions where carriers + are currently detected. + + This sends the `C0:RC` command to query the rear deck sensors. No autoload + movement is performed. The returned hex bitmask is decoded into a list of + track numbers (1-54), where each number corresponds to a deck rail position + that is occupied by a carrier. + + Returns: + list[int]: Sorted list of deck rail positions where carriers are present. + """ + resp = await self.send_command(module="C0", command="RC") + + ce_resp = resp.split("ce")[-1] + + return self._decode_hex_bitmask_to_track_list(ce_resp) + + @action(auto_prefix=True, description="检测装载托盘上的载架存在情况。") + async def request_presence_of_carriers_on_loading_tray(self) -> list[int]: + _unilab_logger.debug("[UNILAB] STARBackend.request_presence_of_carriers_on_loading_tray() called") + """ + Moves autoload sled across loading tray and reads its front-facing proximity sensors + to determine which tray positions contain carriers. + + This sends the `C0:CS` command, which provides a hex-encoded presence bitmask + for the loading tray. The bitmask is decoded into a list of track numbers (1-54) + representing tray positions that currently contain a carrier. + + Returns: + list[int]: Sorted list of loading-tray positions where carriers are present. + + Raises: + ValueError: If the response is missing the expected 'cd' field. + """ + resp = await self.send_command(module="C0", command="CS") + + if "cd" not in resp: + raise ValueError(f"CD field missing: {resp!r}") + + mask_hex = resp.split("cd", 1)[1].strip() + + return self._decode_hex_bitmask_to_track_list(mask_hex) + + @action(auto_prefix=True, description="检测托盘指定轨道是否有载架。") + async def request_presence_of_single_carrier_on_loading_tray(self, track: int) -> bool: + _unilab_logger.debug("[UNILAB] STARBackend.request_presence_of_single_carrier_on_loading_tray() called") + """ + Check whether a specific loading-tray track contains a carrier. + + This sends the `C0:CT` command, which instructs the autoload sled to move to + the specified tray track and read its front-facing proximity sensor. Unlike + `request_presence_of_carriers_on_loading_tray`, which scans all tray + positions and returns a bitmask, this method queries only a single track and + returns a boolean result. + + Args: + track (int): The loading-tray track number to query (1-54). + + Returns: + bool: True if a carrier is detected at the given track; False otherwise. + + Raises: + AssertionError: If `track` is outside the valid range (1-54). + """ + + assert 1 <= track <= 54, "track must be between 1 and 54" + + track_str = str(track).zfill(2) + + resp = await self.send_command( + module="C0", + command="CT", + fmt="ct#", + cp=track_str, + ) + assert resp is not None + + return int(resp["ct"]) == 1 + + @action(auto_prefix=True, description="检测单个载架是否存在。") + async def request_single_carrier_presence(self, carrier_position: int): + _unilab_logger.debug("[UNILAB] STARBackend.request_single_carrier_presence() called") + """Request single carrier presence on the loading tray (not on deck)""" + warnings.warn( # TODO: remove 2025-02 + "`request_single_carrier_presence` is deprecated and will be " + "removed in 2025-02 use `is_carrier_present_on_loading_tray` instead.", + DeprecationWarning, + stacklevel=2, + ) + await self.request_presence_of_single_carrier_on_loading_tray(carrier_position) + + # -------------- 3.13.3 Autoload movement commands -------------- + + def _compute_end_rail_of_carrier(self, carrier: Carrier, track_width: float = 22.5) -> int: + _unilab_logger.debug("[UNILAB] STARBackend._compute_end_rail_of_carrier() called") + """Compute end rail of carrier based on its location on the deck.""" + + carrier_width = carrier.get_location_wrt(self.deck).x - 100 + carrier.get_absolute_size_x() + carrier_end_rail = int(carrier_width / track_width) + + assert 1 <= carrier_end_rail <= 54, "carrier loading rail must be between 1 and 54" + + return carrier_end_rail + + @action(auto_prefix=True, description="将autoload移动到指定槽位。") + async def move_autoload_to_slot(self, slot_number: int): + _unilab_logger.debug("[UNILAB] STARBackend.move_autoload_to_slot() called") + """deprecated - use `move_autoload_to_track` instead.""" + + warnings.warn( # TODO: remove 2025-02 + "`move_autoload_to_slot` is deprecated and will be " + "removed in 2025-02 use `move_autoload_to_track` instead.", + DeprecationWarning, + stacklevel=2, + ) + + return await self.move_autoload_to_track(track=slot_number) + + @action(auto_prefix=True, description="将autoload移动到指定轨道。") + async def move_autoload_to_track(self, track: int): + _unilab_logger.debug("[UNILAB] STARBackend.move_autoload_to_track() called") + """Move autoload to specific slot/track position""" + + assert 1 <= track <= 54, "track must be between 1 and 54" + + await self.move_autoload_to_safe_z_position() + + track_no_as_safe_str = str(track).zfill(2) + return await self.send_command(module="I0", command="XP", xp=track_no_as_safe_str) + + @action(auto_prefix=True, description="停放autoload模块。") + async def park_autoload(self): + _unilab_logger.debug("[UNILAB] STARBackend.park_autoload() called") + """Park autoload""" + + # Identify max number of x positions for your liquid handler + max_x_pos = str(self.extended_conf["xt"]).zfill(2) + + await self.move_autoload_to_safe_z_position() + + # Park autoload to max x position available + return await self.send_command(module="I0", command="XP", xp=max_x_pos) + + @action(auto_prefix=True, description="将载架送出到autoload识别位。") + async def take_carrier_out_to_autoload_belt(self, carrier: Carrier): + _unilab_logger.debug("[UNILAB] STARBackend.take_carrier_out_to_autoload_belt() called") + """Take carrier out to identification position for barcode reading. + Start: carrier is already on the deck + """ + + # Identify carrier end rail + carrier_end_rail = self._compute_end_rail_of_carrier(carrier) + + carrier_on_loading_tray = await self.request_single_carrier_presence(carrier_end_rail) + + if not carrier_on_loading_tray: + try: + await self.send_command( + module="C0", + command="CN", + cp=str(carrier_end_rail).zfill(2), + ) + except Exception as e: + await self.move_autoload_to_safe_z_position() + raise RuntimeError( + f"Failed to take carrier at rail {carrier_end_rail} " f"out to autoload belt: {e}" + ) + else: + raise ValueError(f"Carrier is already on the loading tray at position {carrier_end_rail}.") + + # -------------- 3.13.4 Autoload barcode reading commands -------------- + + # 1D barcode symbology bitmask + # Each symbology corresponds to exactly one bit in the 8-bit barcode type field. + # Bit definitions from spec: + # Bit 0 = ISBT Standard + # Bit 1 = Code 128 (Subset B and C) + # Bit 2 = Code 39 + # Bit 3 = Codabar + # Bit 4 = Code 2of5 Interleaved + # Bit 5 = UPC A/E + # Bit 6 = YESN/EAN 8 + # Bit 7 = (unused / undocumented) + + barcode_1d_symbology_dict: dict[Barcode1DSymbology, str] = { + "ISBT Standard": "01", # bit 0 → 0b00000001 → 0x01 → 1 + "Code 128 (Subset B and C)": "02", # bit 1 → 0b00000010 → 0x02 → 2 + "Code 39": "04", # bit 2 → 0b00000100 → 0x04 → 4 + "Codebar": "08", # bit 3 → 0b00001000 → 0x08 → 8 + "Code 2of5 Interleaved": "10", # bit 4 → 0b00010000 → 0x10 → 16 + "UPC A/E": "20", # bit 5 → 0b00100000 → 0x20 → 32 + "YESN/EAN 8": "40", # bit 6 → 0b01000000 → 0x40 → 64 + # Bit 7 → 0b10000000 → 0x80 → 128 (not documented, so omitted) + "ANY 1D": "7F", # bits 0-6 → 0b01111111 → 0x7F → 127 + } + + @action(auto_prefix=True, description="设置一维条码类型。") + async def set_1d_barcode_type( + self, + barcode_symbology: Optional[Barcode1DSymbology], + ) -> None: + _unilab_logger.debug("[UNILAB] STARBackend.set_1d_barcode_type() called") + """Set 1D barcode type for autoload barcode reading.""" + + # If none given, use the default + if barcode_symbology is None: + barcode_symbology = self._default_1d_symbology + + # Prove to mypy that barcode_symbology is no longer Optional + assert barcode_symbology is not None + + await self.send_command( + module="C0", + command="CB", + bt=self.barcode_1d_symbology_dict[barcode_symbology], + ) + + self._default_1d_symbology = barcode_symbology + + @action(auto_prefix=True, description="设置条码类型。") + async def set_barcode_type( + self, + ISBT_Standard: bool = True, + code128: bool = True, + code39: bool = True, + codebar: bool = True, + code2_5: bool = True, + UPC_AE: bool = True, + EAN8: bool = True, + ): + _unilab_logger.debug("[UNILAB] STARBackend.set_barcode_type() called") + """deprecated - use set_1d_barcode_type instead""" + + warnings.warn( # TODO: remove 2025-02 + "`set_barcode_type` is deprecated and will be " + "removed in 2025-02 use `set_1d_barcode_type` instead.", + DeprecationWarning, + stacklevel=2, + ) + + # Encode values into bit pattern. Last bit is always one. + bt = "" + for t in [ + ISBT_Standard, + code128, + code39, + codebar, + code2_5, + UPC_AE, + EAN8, + True, + ]: + bt += "1" if t else "0" + # Convert bit pattern to hex. + bt_hex = hex(int(bt, base=2)) + return await self.send_command(module="C0", command="CB", bt=bt_hex) + + # TODO:(command:CW) Unload carrier finally + + @action(auto_prefix=True, description="从托盘装载载架并扫描条码。") + async def load_carrier_from_tray_and_scan_carrier_barcode( + self, + carrier: Carrier, + carrier_barcode_reading: bool = True, + barcode_symbology: Optional[Barcode1DSymbology] = None, + barcode_position: float = 4.3, # mm + barcode_reading_window_width: float = 38.0, # mm + reading_speed: float = 128.1, # mm/sec + ) -> Optional[Barcode]: + _unilab_logger.debug("[UNILAB] STARBackend.load_carrier_from_tray_and_scan_carrier_barcode() called") + """Load carrier from loading tray and - optionally - scan 1D carrier barcode""" + + if barcode_symbology is None: + barcode_symbology = self._default_1d_symbology + + assert barcode_symbology is not None + + carrier_end_rail = self._compute_end_rail_of_carrier(carrier) + carrier_end_rail_str = str(carrier_end_rail).zfill(2) + + assert 1 <= int(carrier_end_rail_str) <= 54 + assert 0 <= barcode_position <= 470 + assert 0.1 <= barcode_reading_window_width <= 99.9 + assert 1.5 <= reading_speed <= 160.0 + + try: + resp = await self.send_command( + module="C0", + command="CI", + cp=carrier_end_rail_str, + bi=f"{round(barcode_position*10):04}", + bw=f"{round(barcode_reading_window_width*10):03}", + co="0960", # Distance between containers (pattern) [0.1 mm] + cv=f"{round(reading_speed*10):04}", + ) + except Exception as e: + if carrier_barcode_reading: + await self.move_autoload_to_safe_z_position() + raise RuntimeError( + f"Failed to load carrier at rail {carrier_end_rail} " f"and scan barcode: {e}" + ) + else: + pass + + if not carrier_barcode_reading: + return None + + barcode_str = resp.split("bb/")[-1] + + return Barcode(data=barcode_str, symbology=barcode_symbology, position_on_resource="right") + + @action(auto_prefix=True, description="扫描载架条码后卸下载架。") + async def unload_carrier_after_carrier_barcode_scanning(self): + _unilab_logger.debug("[UNILAB] STARBackend.unload_carrier_after_carrier_barcode_scanning() called") + """After scanning the barcode of the carrier currently engaged with + the autoload sled, unload the carrier back to the loading tray. + """ + try: + resp = await self.send_command( + module="C0", + command="CA", + ) + except Exception as e: + await self.move_autoload_to_safe_z_position() + raise RuntimeError(f"Failed to unload carrier after barcode scanning: {e}") + + return resp + + @action(auto_prefix=True, description="设置载架监控状态。") + async def set_carrier_monitoring(self, should_monitor: bool = False): + _unilab_logger.debug("[UNILAB] STARBackend.set_carrier_monitoring() called") + """Set carrier monitoring + + Args: + should_monitor: whether carrier should be monitored. + + Returns: + True if present, False otherwise + """ + + return await self.send_command(module="C0", command="CU", cu=should_monitor) + + @action(auto_prefix=True, description="从autoload传送带装载载架。") + async def load_carrier_from_autoload_belt( + self, + barcode_reading: bool = False, + barcode_reading_direction: Literal["horizontal", "vertical"] = "horizontal", + barcode_symbology: Optional[Barcode1DSymbology] = None, + reading_position_of_first_barcode: float = 63.0, # mm + no_container_per_carrier: int = 5, + distance_between_containers: float = 96.0, # mm + width_of_reading_window: float = 38.0, # mm + reading_speed: float = 128.1, # mm/secs + park_autoload_after: bool = True, + ) -> dict[int, Optional[Barcode]]: + _unilab_logger.debug("[UNILAB] STARBackend.load_carrier_from_autoload_belt() called") + """Finishes loading the carrier that is currently engaged with the autoload sled, + i.e. is currently in the identification position. + """ + + assert barcode_reading_direction in ["horizontal", "vertical"] + assert 0 <= reading_position_of_first_barcode <= 470 + assert 0 <= no_container_per_carrier <= 32 + assert 0 <= distance_between_containers <= 470 + assert 0.1 <= width_of_reading_window <= 99.9 + assert 1.5 <= reading_speed <= 160.0 + + barcode_reading_direction_dict = { + "vertical": "0", + "horizontal": "1", + } + + if barcode_symbology is None: + barcode_symbology = self._default_1d_symbology + assert barcode_symbology is not None + + no_container_per_carrier_str = str(no_container_per_carrier).zfill(2) + reading_position_of_first_barcode_str = str( + round(reading_position_of_first_barcode * 10) + ).zfill(4) + distance_between_containers_str = str(round(distance_between_containers * 10)).zfill(4) + width_of_reading_window_str = str(round(width_of_reading_window * 10)).zfill(3) + reading_speed_str = str(round(reading_speed * 10)).zfill(4) + + if not barcode_reading: + barcode_reading_direction = "vertical" # no movement + no_container_per_carrier_str = "00" # no scanning + + else: + # Choose barcode symbology + await self.set_1d_barcode_type(barcode_symbology=barcode_symbology) + + self._default_1d_symbology = barcode_symbology + + try: + resp = await self.send_command( + module="C0", + command="CL", + bd=barcode_reading_direction_dict[barcode_reading_direction], + bp=reading_position_of_first_barcode_str, # Barcode reading position of first barcode [mm] + cn=no_container_per_carrier_str, + co=distance_between_containers_str, # Distance between containers (pattern) [mm] + cf=width_of_reading_window_str, # Width of reading window [mm] + cv=reading_speed_str, # Carrier reading speed [mm/sec]/ + ) + except Exception as e: + await self.move_autoload_to_safe_z_position() + raise RuntimeError(f"Failed to load carrier from autoload belt: {e}") + + if park_autoload_after: + await self.park_autoload() + + assert isinstance(resp, str), f"Response is not a string: {resp!r}" + + barcode_dict: dict[int, Optional[Barcode]] = {} + + if barcode_reading: + resp_list = resp.split("bb/")[-1].split("/") # remove header + + assert len(resp_list) == no_container_per_carrier, ( + f"Number of barcodes read ({len(resp_list)}) does not match " + f"expected number ({no_container_per_carrier})" + ) + for i in range(0, no_container_per_carrier): + if resp_list[i] == "00": + barcode_dict[i] = None + else: + barcode_dict[i] = Barcode( + data=resp_list[i], symbology=barcode_symbology, position_on_resource="right" + ) + + return barcode_dict + + # -------------- 3.13.5 Autoload carrier loading/unloading commands -------------- + + @action(auto_prefix=True, description="用autoload装载载架。") + async def load_carrier( + self, + carrier: Carrier, + carrier_barcode_reading: bool = True, + barcode_reading: bool = False, + barcode_reading_direction: Literal["horizontal", "vertical"] = "horizontal", + barcode_symbology: Optional[Barcode1DSymbology] = None, + no_container_per_carrier: int = 5, + reading_position_of_first_barcode: float = 63.0, # mm + distance_between_containers: float = 96.0, # mm + width_of_reading_window: float = 38.0, # mm + reading_speed: float = 128.1, # mm/secs + park_autoload_after: bool = True, + ) -> dict: + _unilab_logger.debug("[UNILAB] STARBackend.load_carrier() called") + """ + Use autoload to load carrier. + + Args: + carrier: Carrier to load + barcode_reading: Whether to read barcodes. Default False. + barcode_reading_direction: Barcode reading direction. Either "vertical" or "horizontal", + default "horizontal". + barcode_symbology: Barcode symbology. Default "Code 128 (Subset B and C)". + no_container_per_carrier: Number of containers per carrier. Default 5. + park_autoload_after: Whether to park autoload after loading. Default True. + """ + + if barcode_symbology is None: + barcode_symbology = self._default_1d_symbology + + # Identify carrier end rail + carrier_end_rail = self._compute_end_rail_of_carrier(carrier) + assert 1 <= int(carrier_end_rail) <= 54, "carrier loading rail must be between 1 and 54" + + # Determine presence of carrier at defined position + presence_check = await self.request_presence_of_single_carrier_on_loading_tray(carrier_end_rail) + + if presence_check != 1: + raise ValueError( + f"""No carrier found at position {carrier_end_rail}, + have you placed the carrier onto the correct autoload tray position?""" + ) + + # Set carrier type for identification purposes + carrier_barcode = await self.load_carrier_from_tray_and_scan_carrier_barcode( + carrier, carrier_barcode_reading=carrier_barcode_reading + ) + + # Load carrier + # with barcoding + if barcode_reading: + # Choose barcode symbology + await self.set_1d_barcode_type(barcode_symbology=barcode_symbology) + self._default_1d_symbology = barcode_symbology + + # Load and read out barcodes # TODO: swap with load_carrier_from_autoload_belt? + resp = await self.load_carrier_from_autoload_belt( + barcode_reading=barcode_reading, + barcode_reading_direction=barcode_reading_direction, + barcode_symbology=barcode_symbology, + reading_position_of_first_barcode=reading_position_of_first_barcode, + no_container_per_carrier=no_container_per_carrier, + distance_between_containers=distance_between_containers, + width_of_reading_window=width_of_reading_window, + reading_speed=reading_speed, + park_autoload_after=False, + ) + else: # without barcoding + resp = await self.load_carrier_from_autoload_belt( + barcode_reading=False, park_autoload_after=False + ) + + if park_autoload_after: + await self.park_autoload() + + # Parse response and create output dict + output = { + "carrier_barcode": carrier_barcode if carrier_barcode_reading else None, + "container_barcodes": resp if barcode_reading else None, + } + + return output + + @action(auto_prefix=True, description="设置装载指示灯状态。") + async def set_loading_indicators(self, bit_pattern: List[bool], blink_pattern: List[bool]): + _unilab_logger.debug("[UNILAB] STARBackend.set_loading_indicators() called") + """Set loading indicators (LEDs) + + The docs here are a little weird because 2^54 < 7FFFFFFFFFFFFF. + + Args: + bit_pattern: On if True, off otherwise + blink_pattern: Blinking if True, steady otherwise + """ + + assert len(bit_pattern) == 54, "bit pattern must be length 54" + assert len(blink_pattern) == 54, "bit pattern must be length 54" + + def pattern2hex(pattern: List[bool]) -> str: + _unilab_logger.debug("[UNILAB] STARBackend.pattern2hex() called") + bit_string = "".join(["1" if x else "0" for x in pattern]) + return hex(int(bit_string, base=2))[2:].upper().zfill(14) + + bit_pattern_hex = pattern2hex(bit_pattern) + blink_pattern_hex = pattern2hex(blink_pattern) + + return await self.send_command( + module="C0", + command="CP", + cl=bit_pattern_hex, + cb=blink_pattern_hex, + ) + + @action(auto_prefix=True, description="核验并等待载架到位。") + async def verify_and_wait_for_carriers( + self, + check_interval: float = 1.0, + ): + _unilab_logger.debug("[UNILAB] STARBackend.verify_and_wait_for_carriers() called") + """Verify that carriers have been loaded at expected rail positions. + + This function checks if carriers are physically present on the deck at the specified + rail positions using the deck's presence sensors. If any carriers are missing, it will: + 1. Prompt the user to load the missing carriers + 2. Flash LEDs at the missing positions using set_loading_indicators + 3. Continue checking until all carriers are detected + + Args: + check_interval: Interval in seconds between presence checks (default: 1.0) + + Raises: + ValueError: If no carriers are found on the deck. + """ + # Extract carriers from deck children with start and end rail positions + carrier_rails: List[Tuple[int, int]] = [] # List of (start_rail, end_rail) tuples + + for child in self.deck.children: + if isinstance(child, Carrier): + # Get x coordinate relative to deck + carrier_x = child.get_location_wrt(self.deck).x + carrier_start_rail = rails_for_x_coordinate(carrier_x) + carrier_end_rail = rails_for_x_coordinate(carrier_x - 100.0 + child.get_absolute_size_x()) + + # Verify rails are valid + carrier_start_rail = max(1, min(carrier_start_rail, 54)) + if 1 <= carrier_end_rail <= 54: + carrier_rails.append((carrier_start_rail, carrier_end_rail)) + + if len(carrier_rails) == 0: + raise ValueError("No carriers found on deck. Assign carriers to the deck.") + + # Extract end rails for comparison with detected rails + # The presence detection reports the end rail position + expected_end_rails = [end_rail for _, end_rail in carrier_rails] + + # Check initial presence + detected_rails = set(await self.request_presence_of_carriers_on_deck()) + missing_end_rails = sorted(set(expected_end_rails) - detected_rails) + + if len(missing_end_rails) == 0: + logger.info(f"All carriers detected at end rail positions: {expected_end_rails}") + # Turn off all indicators + await self.set_loading_indicators( + bit_pattern=[False] * 54, + blink_pattern=[False] * 54, + ) + print(f"\n✓ All carriers successfully detected at end rail positions: {expected_end_rails}\n") + return + + # Prompt user about missing carriers + print( + f"\n{'='*60}\n" + f"CARRIER LOADING REQUIRED\n" + f"{'='*60}\n" + f"Expected carriers at end rail positions: {expected_end_rails}\n" + f"Detected carriers at rail positions: {sorted(detected_rails)}\n" + f"Missing carriers at end rail positions: {missing_end_rails}\n" + f"{'='*60}\n" + f"Please load the missing carriers. LEDs will flash at the carrier positions.\n" + f"The system will automatically detect when all carriers are loaded.\n" + f"{'='*60}\n" + ) + + # Flash LEDs until all carriers are detected + while missing_end_rails: + # Create bit pattern for missing carriers + # Flash all LEDs from start_rail to end_rail (inclusive) for each missing carrier + bit_pattern = [False] * 54 + blink_pattern = [False] * 54 + + # For each missing carrier (identified by missing end rail), flash all its rails + for missing_end_rail in missing_end_rails: + # Find the carrier with this end rail + for start_rail, end_rail in carrier_rails: + if end_rail == missing_end_rail: + # Flash all LEDs from start_rail to end_rail (inclusive) + for rail in range(start_rail, end_rail + 1): + if 1 <= rail <= 54: + indicator_index = rail - 1 # Convert rail (1-54) to index (0-53) + bit_pattern[indicator_index] = True + blink_pattern[indicator_index] = True + break + + # Set loading indicators + await self.set_loading_indicators(bit_pattern[::-1], blink_pattern[::-1]) + + # Wait before checking again + await asyncio.sleep(check_interval) + + # Check for presence again + detected_rails = set(await self.request_presence_of_carriers_on_deck()) + missing_end_rails = sorted(set(expected_end_rails) - detected_rails) + + # All carriers detected, turn off all indicators + logger.info(f"All carriers successfully detected at end rail positions: {expected_end_rails}") + await self.set_loading_indicators( + bit_pattern=[False] * 54, + blink_pattern=[False] * 54, + ) + print("\n✓ All carriers successfully loaded and detected!\n") + + @action(auto_prefix=True, description="用autoload卸下载架。") + async def unload_carrier( + self, + carrier: Carrier, + park_autoload_after: bool = True, + ): + _unilab_logger.debug("[UNILAB] STARBackend.unload_carrier() called") + """Use autoload to unload carrier.""" + # Identify carrier end rail + track_width = 22.5 + carrier_width = carrier.get_location_wrt(self.deck).x - 100 + carrier.get_absolute_size_x() + carrier_end_rail = int(carrier_width / track_width) + + assert 1 <= carrier_end_rail <= 54, "carrier loading rail must be between 1 and 54" + + carrier_end_rail_str = str(carrier_end_rail).zfill(2) + + # Unload + resp = await self.send_command( + module="C0", + command="CR", + cp=carrier_end_rail_str, + ) + + if park_autoload_after: + await self.park_autoload() + + return resp + + # -------------- 3.14 G1-3/ CR Needle Washer commands -------------- + + # TODO: All needle washer commands + + # TODO:(command:WI) + # TODO:(command:WI) + # TODO:(command:WS) + # TODO:(command:WW) + # TODO:(command:WR) + # TODO:(command:WC) + # TODO:(command:QF) + + # -------------- 3.15 Pump unit commands -------------- + + @action(auto_prefix=True, description="获取泵站设置。") + async def request_pump_settings(self, pump_station: int = 1): + _unilab_logger.debug("[UNILAB] STARBackend.request_pump_settings() called") + """Set carrier monitoring + + Args: + carrier_position: pump station number (1..3) + + Returns: + 0 = CoRe 96 wash station (single chamber) + 1 = DC wash station (single chamber rev 02 ) 2 = ReReRe (single chamber) + 3 = CoRe 96 wash station (dual chamber) + 4 = DC wash station (dual chamber) + 5 = ReReRe (dual chamber) + """ + + assert 1 <= pump_station <= 3, "pump_station must be between 1 and 3" + + return await self.send_command(module="C0", command="ET", fmt="et#", ep=pump_station) + + # -------------- 3.15.1 DC Wash commands (only for revision up to 01) -------------- + + # TODO:(command:FA) Start DC wash procedure + # TODO:(command:FB) Stop DC wash procedure + # TODO:(command:FP) Prime DC wash station + + # -------------- 3.15.2 Single chamber pump unit only -------------- + + # TODO:(command:EW) Start circulation (single chamber only) + # TODO:(command:EC) Check circulation (single chamber only) + # TODO:(command:ES) Stop circulation (single chamber only) + # TODO:(command:EF) Prime (single chamber only) + # TODO:(command:EE) Drain & refill (single chamber only) + # TODO:(command:EB) Fill (single chamber only) + # TODO:(command:QE) Request single chamber pump station prime status + + # -------------- 3.15.3 Dual chamber pump unit only -------------- + + @action(auto_prefix=True, description="初始化双腔泵站阀门。") + async def initialize_dual_pump_station_valves(self, pump_station: int = 1): + _unilab_logger.debug("[UNILAB] STARBackend.initialize_dual_pump_station_valves() called") + """Initialize pump station valves (dual chamber only) + + Args: + carrier_position: pump station number (1..3) + """ + + assert 1 <= pump_station <= 3, "pump_station must be between 1 and 3" + + return await self.send_command(module="C0", command="EJ", ep=pump_station) + + @action(auto_prefix=True, description="填充选定双腔泵腔室。") + async def fill_selected_dual_chamber( + self, + pump_station: int = 1, + drain_before_refill: bool = False, + wash_fluid: int = 1, + chamber: int = 2, + waste_chamber_suck_time_after_sensor_change: int = 0, + ): + _unilab_logger.debug("[UNILAB] STARBackend.fill_selected_dual_chamber() called") + """Initialize pump station valves (dual chamber only) + + Args: + carrier_position: pump station number (1..3) + drain_before_refill: drain chamber before refill. Default False. + wash_fluid: wash fluid (1 or 2) + chamber: chamber (1 or 2) + drain_before_refill: waste chamber suck time after sensor change [s] (for error handling only) + """ + + assert 1 <= pump_station <= 3, "pump_station must be between 1 and 3" + assert 1 <= wash_fluid <= 2, "wash_fluid must be between 1 and 2" + assert 1 <= chamber <= 2, "chamber must be between 1 and 2" + + # wash fluid <-> chamber connection + # 0 = wash fluid 1 <-> chamber 2 + # 1 = wash fluid 1 <-> chamber 1 + # 2 = wash fluid 2 <-> chamber 1 + # 3 = wash fluid 2 <-> chamber 2 + connection = {(1, 2): 0, (1, 1): 1, (2, 1): 2, (2, 2): 3}[wash_fluid, chamber] + + return await self.send_command( + module="C0", + command="EH", + ep=pump_station, + ed=drain_before_refill, + ek=connection, + eu=f"{waste_chamber_suck_time_after_sensor_change:02}", + wait=False, + ) + + # TODO:(command:EK) Drain selected chamber + + @action(auto_prefix=True, description="排空双腔泵系统。") + async def drain_dual_chamber_system(self, pump_station: int = 1): + _unilab_logger.debug("[UNILAB] STARBackend.drain_dual_chamber_system() called") + """Drain system (dual chamber only) + + Args: + carrier_position: pump station number (1..3) + """ + + assert 1 <= pump_station <= 3, "pump_station must be between 1 and 3" + + return await self.send_command(module="C0", command="EL", ep=pump_station) + + # TODO:(command:QD) Request dual chamber pump station prime status + + # -------------- 3.16 Incubator commands -------------- + + # TODO: all incubator commands + # TODO:(command:HC) + # TODO:(command:HI) + # TODO:(command:HF) + # TODO:(command:RP) + + # -------------- 3.17 iSWAP commands -------------- + + # -------------- 3.17.1 Pre & Initialization commands -------------- + + @action(auto_prefix=True, description="初始化iSWAP机械手。") + async def initialize_iswap(self): + _unilab_logger.debug("[UNILAB] STARBackend.initialize_iswap() called") + """Initialize iSWAP (for standalone configuration only)""" + + return await self.send_command(module="C0", command="FI") + + @action(auto_prefix=True, description="调整部件以释放iSWAP的Y方向空间。") + async def position_components_for_free_iswap_y_range(self): + _unilab_logger.debug("[UNILAB] STARBackend.position_components_for_free_iswap_y_range() called") + """Position all components so that there is maximum free Y range for iSWAP""" + + return await self.send_command(module="C0", command="FY") + + @action(auto_prefix=True, description="相对移动iSWAP X轴。") + async def move_iswap_x_relative(self, step_size: float, allow_splitting: bool = False): + _unilab_logger.debug("[UNILAB] STARBackend.move_iswap_x_relative() called") + """ + Args: + step_size: X Step size [1mm] Between -99.9 and 99.9 if allow_splitting is False. + allow_splitting: Allow splitting of the movement into multiple steps. Default False. + """ + + direction = 0 if step_size >= 0 else 1 + max_step_size = 99.9 + if abs(step_size) > max_step_size: + if not allow_splitting: + raise ValueError("step_size must be less than 99.9") + await self.move_iswap_x_relative( + step_size=max_step_size if step_size > 0 else -max_step_size, allow_splitting=True + ) + remaining_steps = step_size - max_step_size if step_size > 0 else step_size + max_step_size + return await self.move_iswap_x_relative(remaining_steps, allow_splitting) + + return await self.send_command( + module="C0", command="GX", gx=str(round(abs(step_size) * 10)).zfill(3), xd=direction + ) + + @action(auto_prefix=True, description="相对移动iSWAP Y轴。") + async def move_iswap_y_relative(self, step_size: float, allow_splitting: bool = False): + _unilab_logger.debug("[UNILAB] STARBackend.move_iswap_y_relative() called") + """ + Args: + step_size: Y Step size [1mm] Between -99.9 and 99.9 if allow_splitting is False. + allow_splitting: Allow splitting of the movement into multiple steps. Default False. + """ + + # check if iswap will hit the first (backmost) channel + # we only need to check for positive step sizes because the iswap is always behind the first channel + if step_size < 0: + y_pos_channel_0 = await self.request_y_pos_channel_n(0) + current_y_pos_iswap = await self.iswap_rotation_drive_request_y() + if current_y_pos_iswap + step_size < y_pos_channel_0: + raise ValueError( + f"iSWAP will hit the first (backmost) channel. Current iSWAP Y position: {current_y_pos_iswap} mm, " + f"first channel Y position: {y_pos_channel_0} mm, requested step size: {step_size} mm" + ) + + direction = 0 if step_size >= 0 else 1 + max_step_size = 99.9 + if abs(step_size) > max_step_size: + if not allow_splitting: + raise ValueError("step_size must be less than 99.9") + await self.move_iswap_y_relative( + step_size=max_step_size if step_size > 0 else -max_step_size, allow_splitting=True + ) + remaining_steps = step_size - max_step_size if step_size > 0 else step_size + max_step_size + return await self.move_iswap_y_relative(remaining_steps, allow_splitting) + + return await self.send_command( + module="C0", command="GY", gy=str(round(abs(step_size) * 10)).zfill(3), yd=direction + ) + + @action(auto_prefix=True, description="相对移动iSWAP Z轴。") + async def move_iswap_z_relative(self, step_size: float, allow_splitting: bool = False): + _unilab_logger.debug("[UNILAB] STARBackend.move_iswap_z_relative() called") + """ + Args: + step_size: Z Step size [1mm] Between -99.9 and 99.9 if allow_splitting is False. + allow_splitting: Allow splitting of the movement into multiple steps. Default False. + """ + + direction = 0 if step_size >= 0 else 1 + max_step_size = 99.9 + if abs(step_size) > max_step_size: + if not allow_splitting: + raise ValueError("step_size must be less than 99.9") + await self.move_iswap_z_relative( + step_size=max_step_size if step_size > 0 else -max_step_size, allow_splitting=True + ) + remaining_steps = step_size - max_step_size if step_size > 0 else step_size + max_step_size + return await self.move_iswap_z_relative(remaining_steps, allow_splitting) + + return await self.send_command( + module="C0", command="GZ", gz=str(round(abs(step_size) * 10)).zfill(3), zd=direction + ) + + @action(auto_prefix=True, description="移动iSWAP X轴。") + async def move_iswap_x(self, x_position: float): + _unilab_logger.debug("[UNILAB] STARBackend.move_iswap_x() called") + """Move iSWAP X to absolute position""" + loc = await self.request_iswap_position() + await self.move_iswap_x_relative( + step_size=x_position - loc.x, + allow_splitting=True, + ) + + @action(auto_prefix=True, description="移动iSWAP Y轴。") + async def move_iswap_y(self, y_position: float): + _unilab_logger.debug("[UNILAB] STARBackend.move_iswap_y() called") + """Move iSWAP Y to absolute position""" + loc = await self.request_iswap_position() + await self.move_iswap_y_relative( + step_size=y_position - loc.y, + allow_splitting=True, + ) + + @action(auto_prefix=True, description="移动iSWAP Z轴。") + async def move_iswap_z(self, z_position: float): + _unilab_logger.debug("[UNILAB] STARBackend.move_iswap_z() called") + """Move iSWAP Z to absolute position""" + loc = await self.request_iswap_position() + await self.move_iswap_z_relative( + step_size=z_position - loc.z, + allow_splitting=True, + ) + + @action(auto_prefix=True, description="打开未初始化的iSWAP夹爪。") + async def open_not_initialized_gripper(self): + _unilab_logger.debug("[UNILAB] STARBackend.open_not_initialized_gripper() called") + return await self.send_command(module="C0", command="GI") + + @action(auto_prefix=True, description="打开iSWAP夹爪。") + async def iswap_open_gripper(self, open_position: Optional[float] = None): + _unilab_logger.debug("[UNILAB] STARBackend.iswap_open_gripper() called") + """Open gripper + + Args: + open_position: Open position [mm] (0.1 mm = 16 increments) The gripper moves to pos + 20. + Must be between 0 and 9999. Default 1320 for iSWAP 4.0 (landscape). Default to + 910 for iSWAP 3 (portrait). + """ + + if open_position is None: + open_position = 91.0 if (await self.get_iswap_version()).startswith("3") else 132.0 + + assert 0 <= open_position <= 999.9, "open_position must be between 0 and 999.9" + + return await self.send_command(module="C0", command="GF", go=f"{round(open_position*10):04}") + + @action(auto_prefix=True, description="关闭iSWAP夹爪。") + async def iswap_close_gripper( + self, + grip_strength: int = 5, + plate_width: float = 0, + plate_width_tolerance: float = 0, + ): + _unilab_logger.debug("[UNILAB] STARBackend.iswap_close_gripper() called") + """Close gripper + + The gripper should be at the position plate_width+plate_width_tolerance+2.0mm before sending this command. + + Args: + grip_strength: Grip strength. 0 = low . 9 = high. Default 5. + plate_width: Plate width [mm] (gb should be > min. Pos. + stop ramp + gt -> gb > 760 + 5 + g ) + plate_width_tolerance: Plate width tolerance [mm]. Must be between 0 and 9.9. Default 2.0. + """ + + assert 0 <= grip_strength <= 9, "grip_strength must be between 0 and 9" + assert 0 <= plate_width <= 999.9, "plate_width must be between 0 and 999.9" + assert 0 <= plate_width_tolerance <= 9.9, "plate_width_tolerance must be between 0 and 9.9" + + return await self.send_command( + module="C0", + command="GC", + gw=grip_strength, + gb=f"{round(plate_width*10):04}", + gt=f"{round(plate_width_tolerance*10):02}", + ) + + # -------------- 3.17.2 Stack handling commands CP -------------- + + @action(auto_prefix=True, description="停放iSWAP机械手。") + async def park_iswap( + self, + minimum_traverse_height_at_beginning_of_a_command: int = 2840, + ): + _unilab_logger.debug("[UNILAB] STARBackend.park_iswap() called") + """Close gripper + + The gripper should be at the position gb+gt+20 before sending this command. + + Args: + minimum_traverse_height_at_beginning_of_a_command: Minimum traverse height at beginning + of a command [0.1mm]. Must be between 0 and 3600. Default 3600. + """ + + assert ( + 0 <= minimum_traverse_height_at_beginning_of_a_command <= 3600 + ), "minimum_traverse_height_at_beginning_of_a_command must be between 0 and 3600" + + command_output = await self.send_command( + module="C0", + command="PG", + th=minimum_traverse_height_at_beginning_of_a_command, + ) + + # Once the command has completed successfully, set _iswap_parked to True + self._iswap_parked = True + return command_output + + @action(auto_prefix=True, description="用iSWAP抓取板。") + async def iswap_get_plate( + self, + x_position: int = 0, + x_direction: int = 0, + y_position: int = 0, + y_direction: int = 0, + z_position: int = 0, + z_direction: int = 0, + grip_direction: int = 1, + minimum_traverse_height_at_beginning_of_a_command: int = 3600, + z_position_at_the_command_end: int = 3600, + grip_strength: int = 5, + open_gripper_position: int = 860, + plate_width: int = 860, + plate_width_tolerance: int = 860, + collision_control_level: int = 1, + acceleration_index_high_acc: int = 4, + acceleration_index_low_acc: int = 1, + iswap_fold_up_sequence_at_the_end_of_process: bool = False, + ): + _unilab_logger.debug("[UNILAB] STARBackend.iswap_get_plate() called") + """Get plate using iswap. + + Args: + x_position: Plate center in X direction [0.1mm]. Must be between 0 and 30000. Default 0. + x_direction: X-direction. 0 = positive 1 = negative. Must be between 0 and 1. Default 0. + y_position: Plate center in Y direction [0.1mm]. Must be between 0 and 6500. Default 0. + y_direction: Y-direction. 0 = positive 1 = negative. Must be between 0 and 1. Default 0. + z_position: Plate gripping height in Z direction. Must be between 0 and 3600. Default 0. + z_direction: Z-direction. 0 = positive 1 = negative. Must be between 0 and 1. Default 0. + grip_direction: Grip direction. 1 = negative Y, 2 = positive X, 3 = positive Y, + 4 =negative X. Must be between 1 and 4. Default 1. + minimum_traverse_height_at_beginning_of_a_command: Minimum traverse height at beginning of + a command 0.1mm]. Must be between 0 and 3600. Default 3600. + z_position_at_the_command_end: Z-Position at the command end [0.1mm]. Must be between 0 + and 3600. Default 3600. + grip_strength: Grip strength 0 = low .. 9 = high. Must be between 1 and 9. Default 5. + open_gripper_position: Open gripper position [0.1mm]. Must be between 0 and 9999. + Default 860. + plate_width: plate width [0.1mm]. Must be between 0 and 9999. Default 860. + plate_width_tolerance: plate width tolerance [0.1mm]. Must be between 0 and 99. Default 860. + collision_control_level: collision control level 1 = high 0 = low. Must be between 0 and 1. + Default 1. + acceleration_index_high_acc: acceleration index high acc. Must be between 0 and 4. Default 4. + acceleration_index_low_acc: acceleration index high acc. Must be between 0 and 4. Default 1. + iswap_fold_up_sequence_at_the_end_of_process: fold up sequence at the end of process. Default False. + """ + + assert 0 <= x_position <= 30000, "x_position must be between 0 and 30000" + assert 0 <= x_direction <= 1, "x_direction must be between 0 and 1" + assert 0 <= y_position <= 6500, "y_position must be between 0 and 6500" + assert 0 <= y_direction <= 1, "y_direction must be between 0 and 1" + assert 0 <= z_position <= 3600, "z_position must be between 0 and 3600" + assert 0 <= z_direction <= 1, "z_direction must be between 0 and 1" + assert 1 <= grip_direction <= 4, "grip_direction must be between 1 and 4" + assert ( + 0 <= minimum_traverse_height_at_beginning_of_a_command <= 3600 + ), "minimum_traverse_height_at_beginning_of_a_command must be between 0 and 3600" + assert ( + 0 <= z_position_at_the_command_end <= 3600 + ), "z_position_at_the_command_end must be between 0 and 3600" + assert 1 <= grip_strength <= 9, "grip_strength must be between 1 and 9" + assert 0 <= open_gripper_position <= 9999, "open_gripper_position must be between 0 and 9999" + assert 0 <= plate_width <= 9999, "plate_width must be between 0 and 9999" + assert 0 <= plate_width_tolerance <= 99, "plate_width_tolerance must be between 0 and 99" + assert 0 <= collision_control_level <= 1, "collision_control_level must be between 0 and 1" + assert ( + 0 <= acceleration_index_high_acc <= 4 + ), "acceleration_index_high_acc must be between 0 and 4" + assert ( + 0 <= acceleration_index_low_acc <= 4 + ), "acceleration_index_low_acc must be between 0 and 4" + + command_output = await self.send_command( + module="C0", + command="PP", + xs=f"{x_position:05}", + xd=x_direction, + yj=f"{y_position:04}", + yd=y_direction, + zj=f"{z_position:04}", + zd=z_direction, + gr=grip_direction, + th=f"{minimum_traverse_height_at_beginning_of_a_command:04}", + te=f"{z_position_at_the_command_end:04}", + gw=grip_strength, + go=f"{open_gripper_position:04}", + gb=f"{plate_width:04}", + gt=f"{plate_width_tolerance:02}", + ga=collision_control_level, + # xe=f"{acceleration_index_high_acc} {acceleration_index_low_acc}", + gc=iswap_fold_up_sequence_at_the_end_of_process, + ) + + # Once the command has completed successfully, set _iswap_parked to false + self._iswap_parked = False + return command_output + + @action(auto_prefix=True, description="用iSWAP放下板。") + async def iswap_put_plate( + self, + x_position: int = 0, + x_direction: int = 0, + y_position: int = 0, + y_direction: int = 0, + z_position: int = 0, + z_direction: int = 0, + grip_direction: int = 1, + minimum_traverse_height_at_beginning_of_a_command: int = 3600, + z_position_at_the_command_end: int = 3600, + open_gripper_position: int = 860, + collision_control_level: int = 1, + acceleration_index_high_acc: int = 4, + acceleration_index_low_acc: int = 1, + iswap_fold_up_sequence_at_the_end_of_process: bool = False, + ): + _unilab_logger.debug("[UNILAB] STARBackend.iswap_put_plate() called") + """put plate + + Args: + x_position: Plate center in X direction [0.1mm]. Must be between 0 and 30000. Default 0. + x_direction: X-direction. 0 = positive 1 = negative. Must be between 0 and 1. Default 0. + y_position: Plate center in Y direction [0.1mm]. Must be between 0 and 6500. Default 0. + y_direction: Y-direction. 0 = positive 1 = negative. Must be between 0 and 1. Default 0. + z_position: Plate gripping height in Z direction. Must be between 0 and 3600. Default 0. + z_direction: Z-direction. 0 = positive 1 = negative. Must be between 0 and 1. Default 0. + grip_direction: Grip direction. 1 = negative Y, 2 = positive X, 3 = positive Y, 4 = negative + X. Must be between 1 and 4. Default 1. + minimum_traverse_height_at_beginning_of_a_command: Minimum traverse height at beginning of a + command 0.1mm]. Must be between 0 and 3600. Default 3600. + z_position_at_the_command_end: Z-Position at the command end [0.1mm]. Must be between 0 and + 3600. Default 3600. + open_gripper_position: Open gripper position [0.1mm]. Must be between 0 and 9999. Default + 860. + collision_control_level: collision control level 1 = high 0 = low. Must be between 0 and 1. + Default 1. + acceleration_index_high_acc: acceleration index high acc. Must be between 0 and 4. + Default 4. + acceleration_index_low_acc: acceleration index high acc. Must be between 0 and 4. + Default 1. + iswap_fold_up_sequence_at_the_end_of_process: fold up sequence at the end of process. Default False. + """ + + assert 0 <= x_position <= 30000, "x_position must be between 0 and 30000" + assert 0 <= x_direction <= 1, "x_direction must be between 0 and 1" + assert 0 <= y_position <= 6500, "y_position must be between 0 and 6500" + assert 0 <= y_direction <= 1, "y_direction must be between 0 and 1" + assert 0 <= z_position <= 3600, "z_position must be between 0 and 3600" + assert 0 <= z_direction <= 1, "z_direction must be between 0 and 1" + assert 1 <= grip_direction <= 4, "grip_direction must be between 1 and 4" + assert ( + 0 <= minimum_traverse_height_at_beginning_of_a_command <= 3600 + ), "minimum_traverse_height_at_beginning_of_a_command must be between 0 and 3600" + assert ( + 0 <= z_position_at_the_command_end <= 3600 + ), "z_position_at_the_command_end must be between 0 and 3600" + assert 0 <= open_gripper_position <= 9999, "open_gripper_position must be between 0 and 9999" + assert 0 <= collision_control_level <= 1, "collision_control_level must be between 0 and 1" + assert ( + 0 <= acceleration_index_high_acc <= 4 + ), "acceleration_index_high_acc must be between 0 and 4" + assert ( + 0 <= acceleration_index_low_acc <= 4 + ), "acceleration_index_low_acc must be between 0 and 4" + + command_output = await self.send_command( + module="C0", + command="PR", + xs=f"{x_position:05}", + xd=x_direction, + yj=f"{y_position:04}", + yd=y_direction, + zj=f"{z_position:04}", + zd=z_direction, + th=f"{minimum_traverse_height_at_beginning_of_a_command:04}", + te=f"{z_position_at_the_command_end:04}", + gr=grip_direction, + go=f"{open_gripper_position:04}", + ga=collision_control_level, + # xe=f"{acceleration_index_high_acc} {acceleration_index_low_acc}" + gc=iswap_fold_up_sequence_at_the_end_of_process, + ) + + # Once the command has completed successfully, set _iswap_parked to false + self._iswap_parked = False + return command_output + + @action(auto_prefix=True, description="获取iSWAP旋转驱动位置增量值。") + async def request_iswap_rotation_drive_position_increments(self) -> int: + _unilab_logger.debug("[UNILAB] STARBackend.request_iswap_rotation_drive_position_increments() called") + """Query the iSWAP rotation drive position (units: increments) from the firmware.""" + response = await self.send_command(module="R0", command="RW", fmt="rw######") + return cast(int, response["rw"]) + + @action(auto_prefix=True, description="获取iSWAP旋转驱动朝向。") + async def request_iswap_rotation_drive_orientation(self) -> "RotationDriveOrientation": + _unilab_logger.debug("[UNILAB] STARBackend.request_iswap_rotation_drive_orientation() called") + """ + Request the iSWAP rotation drive orientation. + This is the orientation of the iSWAP rotation drive (relative to the machine). + + Uses empirically determined increment values: + FRONT: -25 ± 50 + RIGHT: +29068 ± 50 + LEFT: -29116 ± 50 + + Returns: + RotationDriveOrientation: The interpreted rotation orientation (LEFT, FRONT, RIGHT). + """ + # Map motor increments to rotation orientations (constant lookup table). + rotation_orientation_to_motor_increment_dict = { + STARBackend.RotationDriveOrientation.FRONT: range(-75, 26), + STARBackend.RotationDriveOrientation.RIGHT: range(29018, 29119), + STARBackend.RotationDriveOrientation.LEFT: range(-29166, -29065), + STARBackend.RotationDriveOrientation.PARKED_RIGHT: range(29450, 29550), + # TODO: add range for STAR(let)s with "PARKED_LEFT" setting + } + + motor_position_increments = await self.request_iswap_rotation_drive_position_increments() + + for orientation, increment_range in rotation_orientation_to_motor_increment_dict.items(): + if motor_position_increments in increment_range: + return orientation + + raise ValueError( + f"Unknown rotation orientation: {motor_position_increments}. " + f"Expected one of {list(rotation_orientation_to_motor_increment_dict.values())}." + ) + + @action(auto_prefix=True, description="获取iSWAP腕部驱动位置增量值。") + async def request_iswap_wrist_drive_position_increments(self) -> int: + _unilab_logger.debug("[UNILAB] STARBackend.request_iswap_wrist_drive_position_increments() called") + """Query the iSWAP wrist drive position (units: increments) from the firmware.""" + response = await self.send_command(module="R0", command="RT", fmt="rt######") + return cast(int, response["rt"]) + + @action(auto_prefix=True, description="获取iSWAP腕部驱动朝向。") + async def request_iswap_wrist_drive_orientation(self) -> "WristDriveOrientation": + _unilab_logger.debug("[UNILAB] STARBackend.request_iswap_wrist_drive_orientation() called") + """ + Request the iSWAP wrist drive orientation. + This is the orientation of the iSWAP wrist drive (always in relation to the iSWAP arm/rotation drive). + + e.g.: + 1) iSWAP RotationDriveOrientation.FRONT (i.e. pointing to the front of the machine) + iSWAP WristDriveOrientation.STRAIGHT (i.e. wrist is also pointing to the front) + + 2) iSWAP RotationDriveOrientation.LEFT (i.e. pointing to the left of the machine) + iSWAP WristDriveOrientation.STRAIGHT (i.e. wrist is also pointing to the left) + + 3) iSWAP RotationDriveOrientation.FRONT (i.e. pointing to the front of the machine) + iSWAP WristDriveOrientation.RIGHT (i.e. wrist is pointing to the left !) + + The relative wrist orientation is reported as a motor position increment by the STAR firmware. This value is mapped to a `WristDriveOrientation` enum member. + + Returns: + WristDriveOrientation: The interpreted wrist orientation (e.g., RIGHT, STRAIGHT, LEFT, REVERSE). + """ + + # Map motor increments to wrist orientations (constant lookup table). + wrist_orientation_to_motor_increment_dict = { + STARBackend.WristDriveOrientation.RIGHT: range(-26_627, -26_527), + STARBackend.WristDriveOrientation.STRAIGHT: range(-8_804, -8_704), + STARBackend.WristDriveOrientation.LEFT: range(9_051, 9_151), + STARBackend.WristDriveOrientation.REVERSE: range(26_802, 26_902), + } + + motor_position_increments = await self.request_iswap_wrist_drive_position_increments() + + for orientation, increment_range in wrist_orientation_to_motor_increment_dict.items(): + if motor_position_increments in increment_range: + return orientation + + raise ValueError( + f"Unknown wrist orientation: {motor_position_increments}. " + f"Expected one of {list(wrist_orientation_to_motor_increment_dict)}." + ) + + @action(auto_prefix=True, description="旋转iSWAP姿态。") + async def iswap_rotate( + self, + rotation_drive: "RotationDriveOrientation", + grip_direction: GripDirection, + gripper_velocity: int = 55_000, + gripper_acceleration: int = 170, + gripper_protection: Literal[0, 1, 2, 3, 4, 5, 6, 7] = 5, + wrist_velocity: int = 48_000, + wrist_acceleration: int = 145, + wrist_protection: Literal[0, 1, 2, 3, 4, 5, 6, 7] = 5, + ): + _unilab_logger.debug("[UNILAB] STARBackend.iswap_rotate() called") + """ + Rotate the iswap to a predefined position. + Velocity units are "incr/sec" + Acceleration units are "1_000 incr/sec**2" + For a list of the possible positions see the pylabrobot documentation on the R0 module. + """ + assert 20 <= gripper_velocity <= 75_000 + assert 5 <= gripper_acceleration <= 200 + assert 20 <= wrist_velocity <= 65_000 + assert 20 <= wrist_acceleration <= 200 + + position = 0 + + if rotation_drive == STARBackend.RotationDriveOrientation.LEFT: + position += 10 + elif rotation_drive == STARBackend.RotationDriveOrientation.FRONT: + position += 20 + elif rotation_drive == STARBackend.RotationDriveOrientation.RIGHT: + position += 30 + else: + raise ValueError(f"Invalid rotation drive orientation: {rotation_drive}") + + if grip_direction == GripDirection.FRONT: + position += 1 + elif grip_direction == GripDirection.RIGHT: + position += 2 + elif grip_direction == GripDirection.BACK: + position += 3 + elif grip_direction == GripDirection.LEFT: + position += 4 + else: + raise ValueError("Invalid grip direction") + + return await self.send_command( + module="R0", + command="PD", + pd=position, + wv=f"{gripper_velocity:05}", + wr=f"{gripper_acceleration:03}", + ww=gripper_protection, + tv=f"{wrist_velocity:05}", + tr=f"{wrist_acceleration:03}", + tw=wrist_protection, + ) + + @action(auto_prefix=True, description="危险地释放iSWAP制动。") + async def iswap_dangerous_release_break(self): + _unilab_logger.debug("[UNILAB] STARBackend.iswap_dangerous_release_break() called") + return await self.send_command(module="R0", command="BA") + + @action(auto_prefix=True, description="重新啮合iSWAP制动。") + async def iswap_reengage_break(self): + _unilab_logger.debug("[UNILAB] STARBackend.iswap_reengage_break() called") + return await self.send_command(module="R0", command="BO") + + @action(auto_prefix=True, description="初始化iSWAP Z轴。") + async def iswap_initialize_z_axis(self): + _unilab_logger.debug("[UNILAB] STARBackend.iswap_initialize_z_axis() called") + return await self.send_command(module="R0", command="ZI") + + @action(auto_prefix=True, description="将板移动到指定位置。") + async def move_plate_to_position( + self, + x_position: int = 0, + x_direction: int = 0, + y_position: int = 0, + y_direction: int = 0, + z_position: int = 0, + z_direction: int = 0, + grip_direction: int = 1, + minimum_traverse_height_at_beginning_of_a_command: int = 3600, + collision_control_level: int = 1, + acceleration_index_high_acc: int = 4, + acceleration_index_low_acc: int = 1, + ): + _unilab_logger.debug("[UNILAB] STARBackend.move_plate_to_position() called") + """Move plate to position. + + Args: + x_position: Plate center in X direction [0.1mm]. Must be between 0 and 30000. Default 0. + x_direction: X-direction. 0 = positive 1 = negative. Must be between 0 and 1. Default 0. + y_position: Plate center in Y direction [0.1mm]. Must be between 0 and 6500. Default 0. + y_direction: Y-direction. 0 = positive 1 = negative. Must be between 0 and 1. Default 0. + z_position: Plate gripping height in Z direction. Must be between 0 and 3600. Default 0. + z_direction: Z-direction. 0 = positive 1 = negative. Must be between 0 and 1. Default 0. + grip_direction: Grip direction. 1 = negative Y, 2 = positive X, 3 = positive Y, 4 = negative + X. Must be between 1 and 4. Default 1. + minimum_traverse_height_at_beginning_of_a_command: Minimum traverse height at beginning of a + command 0.1mm]. Must be between 0 and 3600. Default 3600. + collision_control_level: collision control level 1 = high 0 = low. Must be between 0 and 1. + Default 1. + acceleration_index_high_acc: acceleration index high acc. Must be between 0 and 4. Default 4. + acceleration_index_low_acc: acceleration index low acc. Must be between 0 and 4. Default 1. + """ + + assert 0 <= x_position <= 30000, "x_position must be between 0 and 30000" + assert 0 <= x_direction <= 1, "x_direction must be between 0 and 1" + assert 0 <= y_position <= 6500, "y_position must be between 0 and 6500" + assert 0 <= y_direction <= 1, "y_direction must be between 0 and 1" + assert 0 <= z_position <= 3600, "z_position must be between 0 and 3600" + assert 0 <= z_direction <= 1, "z_direction must be between 0 and 1" + assert 1 <= grip_direction <= 4, "grip_direction must be between 1 and 4" + assert ( + 0 <= minimum_traverse_height_at_beginning_of_a_command <= 3600 + ), "minimum_traverse_height_at_beginning_of_a_command must be between 0 and 3600" + assert 0 <= collision_control_level <= 1, "collision_control_level must be between 0 and 1" + assert ( + 0 <= acceleration_index_high_acc <= 4 + ), "acceleration_index_high_acc must be between 0 and 4" + assert ( + 0 <= acceleration_index_low_acc <= 4 + ), "acceleration_index_low_acc must be between 0 and 4" + + command_output = await self.send_command( + module="C0", + command="PM", + xs=f"{x_position:05}", + xd=x_direction, + yj=f"{y_position:04}", + yd=y_direction, + zj=f"{z_position:04}", + zd=z_direction, + gr=grip_direction, + th=f"{minimum_traverse_height_at_beginning_of_a_command:04}", + ga=collision_control_level, + xe=f"{acceleration_index_high_acc} {acceleration_index_low_acc}", + ) + # Once the command has completed successfully, set _iswap_parked to false + self._iswap_parked = False + return command_output + + @action(auto_prefix=True, description="收拢iSWAP夹爪臂。") + async def collapse_gripper_arm( + self, + minimum_traverse_height_at_beginning_of_a_command: int = 3600, + iswap_fold_up_sequence_at_the_end_of_process: bool = False, + ): + _unilab_logger.debug("[UNILAB] STARBackend.collapse_gripper_arm() called") + """Collapse gripper arm + + Args: + minimum_traverse_height_at_beginning_of_a_command: Minimum traverse height at beginning of a + command 0.1mm]. Must be between 0 and 3600. + Default 3600. + iswap_fold_up_sequence_at_the_end_of_process: fold up sequence at the end of process. Default False. + """ + + assert ( + 0 <= minimum_traverse_height_at_beginning_of_a_command <= 3600 + ), "minimum_traverse_height_at_beginning_of_a_command must be between 0 and 3600" + + return await self.send_command( + module="C0", + command="PN", + th=minimum_traverse_height_at_beginning_of_a_command, + gc=iswap_fold_up_sequence_at_the_end_of_process, + ) + + # -------------- 3.17.3 Hotel handling commands -------------- + + # implemented in UnSafe class + + # -------------- 3.17.4 Barcode commands -------------- + + # TODO:(command:PB) Read barcode using iSWAP + + # -------------- 3.17.5 Teach in commands -------------- + + @action(auto_prefix=True, description="准备iSWAP示教。") + async def prepare_iswap_teaching( + self, + x_position: int = 0, + x_direction: int = 0, + y_position: int = 0, + y_direction: int = 0, + z_position: int = 0, + z_direction: int = 0, + location: int = 0, + hotel_depth: int = 1300, + grip_direction: int = 1, + minimum_traverse_height_at_beginning_of_a_command: int = 3600, + collision_control_level: int = 1, + acceleration_index_high_acc: int = 4, + acceleration_index_low_acc: int = 1, + ): + _unilab_logger.debug("[UNILAB] STARBackend.prepare_iswap_teaching() called") + """Prepare iSWAP teaching + + Prepare for teaching with iSWAP + + Args: + x_position: Plate center in X direction [0.1mm]. Must be between 0 and 30000. Default 0. + x_direction: X-direction. 0 = positive 1 = negative. Must be between 0 and 1. Default 0. + y_position: Plate center in Y direction [0.1mm]. Must be between 0 and 6500. Default 0. + y_direction: Y-direction. 0 = positive 1 = negative. Must be between 0 and 1. Default 0. + z_position: Plate gripping height in Z direction. Must be between 0 and 3600. Default 0. + z_direction: Z-direction. 0 = positive 1 = negative. Must be between 0 and 1. Default 0. + location: location. 0 = Stack 1 = Hotel. Must be between 0 and 1. Default 0. + hotel_depth: Hotel depth [0.1mm]. Must be between 0 and 3000. Default 1300. + minimum_traverse_height_at_beginning_of_a_command: Minimum traverse height at beginning of + a command 0.1mm]. Must be between 0 and 3600. Default 3600. + collision_control_level: collision control level 1 = high 0 = low. Must be between 0 and 1. + Default 1. + acceleration_index_high_acc: acceleration index high acc. Must be between 0 and 4. Default 4. + acceleration_index_low_acc: acceleration index high acc. Must be between 0 and 4. Default 1. + """ + + assert 0 <= x_position <= 30000, "x_position must be between 0 and 30000" + assert 0 <= x_direction <= 1, "x_direction must be between 0 and 1" + assert 0 <= y_position <= 6500, "y_position must be between 0 and 6500" + assert 0 <= y_direction <= 1, "y_direction must be between 0 and 1" + assert 0 <= z_position <= 3600, "z_position must be between 0 and 3600" + assert 0 <= z_direction <= 1, "z_direction must be between 0 and 1" + assert 0 <= location <= 1, "location must be between 0 and 1" + assert 0 <= hotel_depth <= 3000, "hotel_depth must be between 0 and 3000" + assert ( + 0 <= minimum_traverse_height_at_beginning_of_a_command <= 3600 + ), "minimum_traverse_height_at_beginning_of_a_command must be between 0 and 3600" + assert 0 <= collision_control_level <= 1, "collision_control_level must be between 0 and 1" + assert ( + 0 <= acceleration_index_high_acc <= 4 + ), "acceleration_index_high_acc must be between 0 and 4" + assert ( + 0 <= acceleration_index_low_acc <= 4 + ), "acceleration_index_low_acc must be between 0 and 4" + + return await self.send_command( + module="C0", + command="PT", + xs=f"{x_position:05}", + xd=x_direction, + yj=f"{y_position:04}", + yd=y_direction, + zj=f"{z_position:04}", + zd=z_direction, + hh=location, + hd=f"{hotel_depth:04}", + gr=grip_direction, + th=f"{minimum_traverse_height_at_beginning_of_a_command:04}", + ga=collision_control_level, + xe=f"{acceleration_index_high_acc} {acceleration_index_low_acc}", + ) + + @action(auto_prefix=True, description="获取逻辑iSWAP位置。") + async def get_logic_iswap_position( + self, + x_position: int = 0, + x_direction: int = 0, + y_position: int = 0, + y_direction: int = 0, + z_position: int = 0, + z_direction: int = 0, + location: int = 0, + hotel_depth: int = 1300, + grip_direction: int = 1, + collision_control_level: int = 1, + ): + _unilab_logger.debug("[UNILAB] STARBackend.get_logic_iswap_position() called") + """Get logic iSWAP position + + Args: + x_position: Plate center in X direction [0.1mm]. Must be between 0 and 30000. Default 0. + x_direction: X-direction. 0 = positive 1 = negative. Must be between 0 and 1. Default 0. + y_position: Plate center in Y direction [0.1mm]. Must be between 0 and 6500. Default 0. + y_direction: Y-direction. 0 = positive 1 = negative. Must be between 0 and 1. Default 0. + z_position: Plate gripping height in Z direction. Must be between 0 and 3600. Default 0. + z_direction: Z-direction. 0 = positive 1 = negative. Must be between 0 and 1. Default 0. + location: location. 0 = Stack 1 = Hotel. Must be between 0 and 1. Default 0. + hotel_depth: Hotel depth [0.1mm]. Must be between 0 and 3000. Default 1300. + grip_direction: Grip direction. 1 = negative Y, 2 = positive X, 3 = positive Y, + 4 = negative X. Must be between 1 and 4. Default 1. + collision_control_level: collision control level 1 = high 0 = low. Must be between 0 and 1. + Default 1. + """ + + assert 0 <= x_position <= 30000, "x_position must be between 0 and 30000" + assert 0 <= x_direction <= 1, "x_direction must be between 0 and 1" + assert 0 <= y_position <= 6500, "y_position must be between 0 and 6500" + assert 0 <= y_direction <= 1, "y_direction must be between 0 and 1" + assert 0 <= z_position <= 3600, "z_position must be between 0 and 3600" + assert 0 <= z_direction <= 1, "z_direction must be between 0 and 1" + assert 0 <= location <= 1, "location must be between 0 and 1" + assert 0 <= hotel_depth <= 3000, "hotel_depth must be between 0 and 3000" + assert 1 <= grip_direction <= 4, "grip_direction must be between 1 and 4" + assert 0 <= collision_control_level <= 1, "collision_control_level must be between 0 and 1" + + return await self.send_command( + module="C0", + command="PC", + xs=x_position, + xd=x_direction, + yj=y_position, + yd=y_direction, + zj=z_position, + zd=z_direction, + hh=location, + hd=hotel_depth, + gr=grip_direction, + ga=collision_control_level, + ) + + # -------------- 3.17.6 iSWAP query -------------- + + @action(auto_prefix=True, description="获取iSWAP是否在停放位。") + async def request_iswap_in_parking_position(self): + _unilab_logger.debug("[UNILAB] STARBackend.request_iswap_in_parking_position() called") + """Request iSWAP in parking position + + Returns: + 0 = gripper is not in parking position + 1 = gripper is in parking position + """ + + return await self.send_command(module="C0", command="RG", fmt="rg#") + + @action(auto_prefix=True, description="获取iSWAP是否夹持板。") + async def request_plate_in_iswap(self) -> bool: + _unilab_logger.debug("[UNILAB] STARBackend.request_plate_in_iswap() called") + """Request plate in iSWAP + + Returns: + True if holding a plate, False otherwise. + """ + + resp = await self.send_command(module="C0", command="QP", fmt="ph#") + return resp is not None and resp["ph"] == 1 + + @action(auto_prefix=True, description="获取iSWAP位置。") + async def request_iswap_position(self) -> Coordinate: + _unilab_logger.debug("[UNILAB] STARBackend.request_iswap_position() called") + """Request iSWAP position ( grip center ) + + Returns: + xs: Hotel center in X direction [1mm] + xd: X direction 0 = positive 1 = negative + yj: Gripper center in Y direction [1mm] + yd: Y direction 0 = positive 1 = negative + zj: Gripper Z height (gripping height) [1mm] + zd: Z direction 0 = positive 1 = negative + """ + + resp = await self.send_command(module="C0", command="QG", fmt="xs#####xd#yj####yd#zj####zd#") + return Coordinate( + x=(resp["xs"] / 10) * (1 if resp["xd"] == 0 else -1), + y=(resp["yj"] / 10) * (1 if resp["yd"] == 0 else -1), + z=(resp["zj"] / 10) * (1 if resp["zd"] == 0 else -1), + ) + + @action(auto_prefix=True, description="获取iSWAP旋转驱动Y位置。") + async def iswap_rotation_drive_request_y(self) -> float: + _unilab_logger.debug("[UNILAB] STARBackend.iswap_rotation_drive_request_y() called") + """Request iSWAP rotation drive Y position (center) in mm. This is equivalent to the y location of the iSWAP module.""" + if not self.iswap_installed: + raise RuntimeError("iSWAP is not installed") + resp = await self.send_command(module="R0", command="RY", fmt="ry##### (n)") + iswap_y_pos = resp["ry"][1] # 0 = FW counter, 1 = HW counter + return round(STARBackend.y_drive_increment_to_mm(iswap_y_pos), 1) + + @action(auto_prefix=True, description="获取iSWAP初始化状态。") + async def request_iswap_initialization_status(self) -> bool: + _unilab_logger.debug("[UNILAB] STARBackend.request_iswap_initialization_status() called") + """Request iSWAP initialization status + + Returns: + True if iSWAP is fully initialized + """ + + resp = await self.send_command(module="R0", command="QW", fmt="qw#") + return cast(int, resp["qw"]) == 1 + + @action(auto_prefix=True, description="获取iSWAP版本。") + async def request_iswap_version(self) -> str: + _unilab_logger.debug("[UNILAB] STARBackend.request_iswap_version() called") + """Firmware command for getting iswap version""" + return cast(str, (await self.send_command("R0", "RF", fmt="rf" + "&" * 15))["rf"]) + + # -------------- 3.18 Cover and port control -------------- + + @action(auto_prefix=True, description="锁定上盖。") + async def lock_cover(self): + _unilab_logger.debug("[UNILAB] STARBackend.lock_cover() called") + """Lock cover""" + + return await self.send_command(module="C0", command="CO") + + @action(auto_prefix=True, description="解锁上盖。") + async def unlock_cover(self): + _unilab_logger.debug("[UNILAB] STARBackend.unlock_cover() called") + """Unlock cover""" + + return await self.send_command(module="C0", command="HO") + + @action(auto_prefix=True, description="禁用上盖联锁控制。") + async def disable_cover_control(self): + _unilab_logger.debug("[UNILAB] STARBackend.disable_cover_control() called") + """Disable cover control""" + + return await self.send_command(module="C0", command="CD") + + @action(auto_prefix=True, description="启用上盖联锁控制。") + async def enable_cover_control(self): + _unilab_logger.debug("[UNILAB] STARBackend.enable_cover_control() called") + """Enable cover control""" + + return await self.send_command(module="C0", command="CE") + + @action(auto_prefix=True, description="设置上盖输出。") + async def set_cover_output(self, output: int = 0): + _unilab_logger.debug("[UNILAB] STARBackend.set_cover_output() called") + """Set cover output + + Args: + output: 1 = cover lock; 2 = reserve out; 3 = reserve out. + """ + + assert 1 <= output <= 3, "output must be between 1 and 3" + return await self.send_command(module="C0", command="OS", on=output) + + @action(auto_prefix=True, description="复位指定输出。") + async def reset_output(self, output: int = 0): + _unilab_logger.debug("[UNILAB] STARBackend.reset_output() called") + """Reset output + + Returns: + output: 1 = cover lock; 2 = reserve out; 3 = reserve out. + """ + + assert 1 <= output <= 3, "output must be between 1 and 3" + return await self.send_command(module="C0", command="QS", on=output, fmt="#") + + @action(auto_prefix=True, description="获取上盖开启状态。") + async def request_cover_open(self) -> bool: + _unilab_logger.debug("[UNILAB] STARBackend.request_cover_open() called") + """Request cover open + + Returns: True if the cover is open + """ + + resp = await self.send_command(module="C0", command="QC", fmt="qc#") + return bool(resp["qc"]) + + # -------------- Extra - Probing labware with STAR - making STAR into a CMM -------------- + + y_drive_mm_per_increment = 0.046302082 + z_drive_mm_per_increment = 0.01072765 + + dispensing_drive_vol_per_increment = 0.046876 # uL / increment + dispensing_drive_mm_per_increment = 0.002734375 + + @staticmethod + @action(auto_prefix=True, description="将毫米换算为Y驱动增量。") + def mm_to_y_drive_increment(value_mm: float) -> int: + _unilab_logger.debug("[UNILAB] STARBackend.mm_to_y_drive_increment() called") + return round(value_mm / STARBackend.y_drive_mm_per_increment) + + @staticmethod + @action(auto_prefix=True, description="将Y驱动增量换算为毫米。") + def y_drive_increment_to_mm(value_mm: int) -> float: + _unilab_logger.debug("[UNILAB] STARBackend.y_drive_increment_to_mm() called") + return round(value_mm * STARBackend.y_drive_mm_per_increment, 2) + + @staticmethod + @action(auto_prefix=True, description="将毫米换算为Z驱动增量。") + def mm_to_z_drive_increment(value_mm: float) -> int: + _unilab_logger.debug("[UNILAB] STARBackend.mm_to_z_drive_increment() called") + return round(value_mm / STARBackend.z_drive_mm_per_increment) + + @staticmethod + @action(auto_prefix=True, description="将Z驱动增量换算为毫米。") + def z_drive_increment_to_mm(value_increments: int) -> float: + _unilab_logger.debug("[UNILAB] STARBackend.z_drive_increment_to_mm() called") + return round(value_increments * STARBackend.z_drive_mm_per_increment, 2) + + # Dispensing drive conversions + # --- uL <-> increments --- + @staticmethod + @action(auto_prefix=True, description="将体积换算为分液驱动增量。") + def dispensing_drive_vol_to_increment(volume: float) -> int: + _unilab_logger.debug("[UNILAB] STARBackend.dispensing_drive_vol_to_increment() called") + return round(volume / STARBackend.dispensing_drive_vol_per_increment) + + @staticmethod + @action(auto_prefix=True, description="将分液驱动增量换算为体积。") + def dispensing_drive_increment_to_volume(position_increment: int) -> float: + _unilab_logger.debug("[UNILAB] STARBackend.dispensing_drive_increment_to_volume() called") + return round(position_increment * STARBackend.dispensing_drive_vol_per_increment, 1) + + # --- mm <-> increments --- + @staticmethod + @action(auto_prefix=True, description="将分液驱动毫米位置换算为增量。") + def dispensing_drive_mm_to_increment(position_mm: float) -> int: + _unilab_logger.debug("[UNILAB] STARBackend.dispensing_drive_mm_to_increment() called") + return round(position_mm / STARBackend.dispensing_drive_mm_per_increment) + + @staticmethod + @action(auto_prefix=True, description="将分液驱动增量换算为毫米。") + def dispensing_drive_increment_to_mm(position_increment: int) -> float: + _unilab_logger.debug("[UNILAB] STARBackend.dispensing_drive_increment_to_mm() called") + return round(position_increment * STARBackend.dispensing_drive_mm_per_increment, 3) + + # --- uL <-> mm --- + @staticmethod + @action(auto_prefix=True, description="将体积换算为分液驱动毫米位置。") + def dispensing_drive_vol_to_mm(vol: float) -> float: + _unilab_logger.debug("[UNILAB] STARBackend.dispensing_drive_vol_to_mm() called") + inc = STARBackend.dispensing_drive_vol_to_increment(vol) + return STARBackend.dispensing_drive_increment_to_mm(inc) + + @staticmethod + @action(auto_prefix=True, description="将分液驱动毫米位置换算为体积。") + def dispensing_drive_mm_to_vol(position_mm: float) -> float: + _unilab_logger.debug("[UNILAB] STARBackend.dispensing_drive_mm_to_vol() called") + inc = STARBackend.dispensing_drive_mm_to_increment(position_mm) + return STARBackend.dispensing_drive_increment_to_volume(inc) + + @action(auto_prefix=True, description="用通道cLLD沿X方向探测位置。") + async def clld_probe_x_position_using_channel( + self, + channel_idx: int, # 0-based indexing of channels! + probing_direction: Literal["right", "left"], + end_pos_search: Optional[float] = None, # mm + post_detection_dist: float = 2.0, # mm, + tip_bottom_diameter: float = 1.2, # mm + read_timeout=240.0, # seconds + ) -> float: + _unilab_logger.debug("[UNILAB] STARBackend.clld_probe_x_position_using_channel() called") + """ + Probe the x-position of a conductive material using a channel's capacitive liquid + level detection (cLLD) via a lateral X scan. + + Starting from the channel's current X position, the channel is moved laterally in + the specified direction using the XL command until cLLD triggers or the configured + end position is reached. After the scan, the channel is retracted inward by + `post_detection_dist`. + + The returned value is a first-order geometric estimate of the material boundary, + corrected by half the tip bottom diameter assuming cylindrical tip contact. + + Notes: + - The XL command does not report whether cLLD triggered; reaching the end position is indistinguishable from a successful detection. + - This function assumes cLLD triggers before `end_pos_search`. + + Preconditions: + - The channel must already be at a Z height safe for lateral X motion. + - The current X position must be consistent with `probing_direction`. + + Side effects: + - Moves the specified channel in X. + - Leaves the channel retracted from the detected object. + + Returns: + Estimated x-position of the detected material boundary in millimeters. + """ + + assert channel_idx in range( + self.num_channels + ), f"Channel index must be between 0 and {self.num_channels - 1}, is {channel_idx}." + assert probing_direction in [ + "right", + "left", + ], f"Probing direction must be either 'right' or 'left', is {probing_direction}." + assert post_detection_dist >= 0.0, ( + f"Post-detection distance must be non-negative, is {post_detection_dist} mm." + "(always marks a movement away from the detected material)." + ) + + # TODO: Anti-channel-crash feature -> use self.deck with recursive logic + current_x_position = await self.request_x_pos_channel_n(channel_idx) + # y_position = await self.request_y_pos_channel_n(channel_idx) + # current_z_position = await self.request_z_pos_channel_n(channel_idx) + + # Use identified rail number to calculate possible upper limit: + # STAR = 95 - 1415 mm, STARlet = 95 - 800mm + num_rails = self.extended_conf["xt"] + track_width = 22.5 # mm + reachable_dist_to_last_rail = 125.0 + + max_safe_upper_x_pos = num_rails * track_width + reachable_dist_to_last_rail + max_safe_lower_x_pos = 95.0 # unit: mm + + if end_pos_search is None: + if probing_direction == "right": + end_pos_search = max_safe_upper_x_pos + else: # probing_direction == "left" + end_pos_search = max_safe_lower_x_pos + else: + assert max_safe_lower_x_pos <= end_pos_search <= max_safe_upper_x_pos, ( + f"End position for x search must be between " + f"{max_safe_lower_x_pos} and {max_safe_upper_x_pos} mm, " + f"is {end_pos_search} mm." + ) + + # Assert probing direction matches start and end positions + if probing_direction == "right": + assert current_x_position < end_pos_search, ( + f"Current position ({current_x_position} mm) must be less than " + + f"end position ({end_pos_search} mm) when probing right." + ) + else: # probing_direction == "left" + assert current_x_position > end_pos_search, ( + f"Current position ({current_x_position} mm) must be greater than " + + f"end position ({end_pos_search} mm) when probing left." + ) + + # Move channel in x until cLLD (Note: does not return detected x-position!) + await self.send_command( + module="C0", + command="XL", + xs=f"{int(round(end_pos_search * 10)):05}", + read_timeout=read_timeout, + ) + + sensor_triggered_x_pos = await self.request_x_pos_channel_n(channel_idx) + + # Move channel post-detection + if probing_direction == "left": + final_x_pos = sensor_triggered_x_pos + post_detection_dist + + # tip_bottom_diameter geometric correction assuming cylindrical tip contact + material_x_pos = sensor_triggered_x_pos - tip_bottom_diameter / 2 + + else: # probing_direction == "right" + final_x_pos = sensor_triggered_x_pos - post_detection_dist + + material_x_pos = sensor_triggered_x_pos + tip_bottom_diameter / 2 + + # Move away from detected object to avoid mechanical interference + # e.g. touch carrier, then carrier moves -> friction on channel! + await self.move_channel_x(x=final_x_pos, channel=channel_idx) + + return round(material_x_pos, 1) + + @action(auto_prefix=True, description="用通道cLLD沿Y方向探测位置。") + async def clld_probe_y_position_using_channel( + self, + channel_idx: int, # 0-based indexing of channels! + probing_direction: Literal["forward", "backward"], + start_pos_search: Optional[float] = None, # mm + end_pos_search: Optional[float] = None, # mm + channel_speed: float = 10.0, # mm/sec + channel_acceleration_int: Literal[1, 2, 3, 4] = 4, # * 5_000 steps/sec**2 == 926 mm/sec**2 + detection_edge: int = 10, + current_limit_int: Literal[1, 2, 3, 4, 5, 6, 7] = 7, + post_detection_dist: float = 2.0, # mm, + tip_bottom_diameter: float = 1.2, # mm + ) -> float: + _unilab_logger.debug("[UNILAB] STARBackend.clld_probe_y_position_using_channel() called") + """ + Probe the y-position of a conductive material using the channel's capacitive Liquid Level + Detection (cLLD). + + This method carefully moves a specified STAR channel along the y-axis to detect the presence + of a conductive surface. It uses STAR's built-in capacitive sensing to measure where the + needle tip first encounters the material, applying safety checks to prevent channel collisions + with adjacent channels. After detection, the channel is retracted by a configurable safe + distance (`post_detection_dist`) to avoid mechanical interference. + + By default, the parameter `tip_bottom_diameter` assumes STAR's **integrated teaching needles**, + which feature an extended, straight bottom section. The correction accounts for the needle's + geometry by adjusting the final reported material y-position to represent the material center + rather than the conductive detection edge. If you are using different tips or needle designs + (e.g., conical tips or third-party teaching needles), you should adapt the + `tip_bottom_diameter` value to reflect their actual geometry. + + Args: + channel_idx: Index of the channel to probe (0-based). The backmost channel is 0. + probing_direction: Direction of probing: + - "forward" decreases y-position, + - "backward" increases y-position. + start_pos_search: Initial y-position for the search (in mm). If not set, defaults to the current channel y-position. + end_pos_search: Final y-position for the search (in mm). If not set, defaults to the maximum safe travel range. + channel_speed: Channel movement speed during probing (mm/sec). Defaults to 10.0 mm/sec. + channel_acceleration_int: Acceleration ramp setting [1-4], where the physical acceleration is `value * 5,000 steps/sec**2`. Defaults to 4. + detection_edge: Edge steepness for capacitive detection [0-1024]. Defaults to 10. + current_limit_int: Current limit setting [1-7]. Defaults to 7. + post_detection_dist: Retraction distance after detection (in mm). Defaults to 2.0 mm. + tip_bottom_diameter: Effective diameter of the needle/tip bottom (in mm). Defaults to 1.2 mm, corresponding to STAR's integrated teaching needles. + + Returns: + The corrected y-position (in mm) of the detected conductive material, adjusted for the specified `tip_bottom_diameter`. + + Raises: + ValueError: + - If `probing_direction` is invalid. + - If `start_pos_search` or `end_pos_search` is outside the safe range. + - If the configured end position conflicts with the probing direction. + - If no conductive material is detected. + """ + + assert probing_direction in [ + "forward", + "backward", + ], f"Probing direction must be either 'forward' or 'backward', is {probing_direction}." + + # Anti-channel-crash feature + if channel_idx > 0: + channel_idx_minus_one_y_pos = await self.request_y_pos_channel_n(channel_idx - 1) + else: + channel_idx_minus_one_y_pos = ( + STARBackend.y_drive_increment_to_mm(13_714) + 9 + ) # y-position=635 mm + if channel_idx < (self.num_channels - 1): + channel_idx_plus_one_y_pos = await self.request_y_pos_channel_n(channel_idx + 1) + else: + channel_idx_plus_one_y_pos = 6 + # Insight: STAR machines appear to lose connection to a channel below y-position=6 mm + + max_safe_upper_y_pos = channel_idx_minus_one_y_pos - self._channel_minimum_y_spacing + max_safe_lower_y_pos = ( + channel_idx_plus_one_y_pos + self._channel_minimum_y_spacing + if channel_idx_plus_one_y_pos != 0 + else 6 + ) + + # Enable safe start and end positions + if start_pos_search: + assert max_safe_lower_y_pos <= start_pos_search <= max_safe_upper_y_pos, ( + f"Start position for y search must be between \n{max_safe_lower_y_pos} and " + + f"{max_safe_upper_y_pos} mm, is {end_pos_search} mm. Otherwise channel will crash." + ) + await self.move_channel_y(y=start_pos_search, channel=channel_idx) + + if end_pos_search: + assert max_safe_lower_y_pos <= end_pos_search <= max_safe_upper_y_pos, ( + f"End position for y search must be between \n{max_safe_lower_y_pos} and " + + f"{max_safe_upper_y_pos} mm, is {end_pos_search} mm. Otherwise channel will crash." + ) + + # Set safe y-search end position based on the probing direction + current_channel_y_pos = await self.request_y_pos_channel_n(channel_idx) + if probing_direction == "backward": + max_y_search_pos = end_pos_search or max_safe_upper_y_pos + if max_y_search_pos < current_channel_y_pos: + raise ValueError( + f"Channel {channel_idx} cannot move forward: " + f"End position = {max_y_search_pos} < current position = {current_channel_y_pos}" + f"\nDid you mean to move forward?" + ) + else: # probing_direction == "forward" + max_y_search_pos = end_pos_search or max_safe_lower_y_pos + if max_y_search_pos > current_channel_y_pos: + raise ValueError( + f"Channel {channel_idx} cannot move forward: " + f"End position = {max_y_search_pos} > current position = {current_channel_y_pos}" + f"\nDid you mean to move backward?" + ) + + # Convert mm to increments + max_y_search_pos_increments = STAR.mm_to_y_drive_increment(max_y_search_pos) + channel_speed_increments = STAR.mm_to_y_drive_increment(channel_speed) + + # Machine-compatibility check of calculated parameters + assert 0 <= max_y_search_pos_increments <= 13_714, ( + "Maximum y search position must be between \n0 and" + + f"{STARBackend.y_drive_increment_to_mm(13_714)+9} mm, is {max_y_search_pos_increments} mm" + ) + assert 20 <= channel_speed_increments <= 8_000, ( + f"LLD search speed must be between \n{STARBackend.y_drive_increment_to_mm(20)}" + + f"and {STARBackend.y_drive_increment_to_mm(8_000)} mm/sec, is {channel_speed} mm/sec" + ) + assert channel_acceleration_int in [1, 2, 3, 4], ( + "Channel speed must be in [1, 2, 3, 4] (* 5_000 steps/sec**2)" + + f", is {channel_speed} mm/sec" + ) + assert ( + 0 <= detection_edge <= 1_023 + ), "Edge steepness at capacitive LLD detection must be between 0 and 1023" + assert ( + 0 <= current_limit_int <= 7 + ), f"Current limit must be in [0, 1, 2, 3, 4, 5, 6, 7], is {channel_speed} mm/sec" + + # Move channel for cLLD (Note: does not return detected y-position!) + await self.send_command( + module=STARBackend.channel_id(channel_idx), + command="YL", + ya=f"{max_y_search_pos_increments:05}", # Maximum search position [steps] + gt=f"{detection_edge:04}", # Edge steepness at capacitive LLD detection + gl=f"{0:04}", # Offset after edge detection -> always 0 to measure y-pos! + yv=f"{channel_speed_increments:04}", # Max speed [steps/second] + yr=f"{channel_acceleration_int}", # Acceleration ramp [yr * 5_000 steps/second**2] + yw=f"{current_limit_int}", # Current limit + read_timeout=120, # default 30 seconds is often not enough + ) + + detected_material_y_pos = await self.request_y_pos_channel_n(channel_idx) + + # Dynamically evaluate post-detection distance to avoid crashes + if probing_direction == "backward": + if channel_idx == self.num_channels - 1: # safe default + adjacent_y_pos = 6.0 + else: # next channel + adjacent_y_pos = await self.request_y_pos_channel_n(channel_idx + 1) + + max_safe_y_mov_dist_post_detection = ( + detected_material_y_pos - adjacent_y_pos - self._channel_minimum_y_spacing + ) + move_target = detected_material_y_pos - min( + post_detection_dist, max_safe_y_mov_dist_post_detection + ) + + else: # probing_direction == "forward" + if channel_idx == 0: # safe default + adjacent_y_pos = STARBackend.y_drive_increment_to_mm(13_714) + 9 # y-position=635 mm + else: # previous channel + adjacent_y_pos = await self.request_y_pos_channel_n(channel_idx - 1) + + max_safe_y_mov_dist_post_detection = ( + adjacent_y_pos - detected_material_y_pos - self._channel_minimum_y_spacing + ) + move_target = detected_material_y_pos + min( + post_detection_dist, max_safe_y_mov_dist_post_detection + ) + + await self.move_channel_y(y=move_target, channel=channel_idx) + + # Correct for tip_bottom_diameter + if probing_direction == "backward": + material_y_pos = detected_material_y_pos + tip_bottom_diameter / 2 + else: # probing_direction == "forward" + material_y_pos = detected_material_y_pos - tip_bottom_diameter / 2 + + return round(material_y_pos, 1) + + async def _move_z_drive_to_liquid_surface_using_clld( + self, + channel_idx: int, # 0-based indexing of channels! + lowest_immers_pos: float = 99.98, # mm + start_pos_search: float = 334.7, # mm + channel_speed: float = 10.0, # mm + channel_acceleration: float = 800.0, # mm/sec**2 + detection_edge: int = 10, + detection_drop: int = 2, + post_detection_trajectory: Literal[0, 1] = 1, + post_detection_dist: float = 2.0, # mm + ): + _unilab_logger.debug("[UNILAB] STARBackend._move_z_drive_to_liquid_surface_using_clld() called") + """Move the tip on a channel to the liquid surface using capacitive LLD (cLLD). + + Runs a downward capacitive liquid-level detection (cLLD) search on the specified + 0-indexed channel. The search will not go below lowest_immers_pos. After detection, + the channel performs the configured post-detection move (by default retracting 2.0 mm). + + This is a low level method that takes parameters in "head space", not using the tip length. + + Args: + channel_idx: Channel index (0-based). + lowest_immers_pos: Lowest allowed search position in mm (hard stop). Defaults to 99.98. + start_pos_search: Search start position in mm. If None, computed from tip length. + channel_speed: Search speed in mm/s. Defaults to 10.0. + channel_acceleration: Search acceleration in mm/s^2. Defaults to 800.0. + detection_edge: Edge steepness threshold for cLLD detection (0-1023). Defaults to 10. + detection_drop: Offset applied after cLLD edge detection (0-1023). Defaults to 2. + post_detection_trajectory: Instrument post-detection move mode (0 or 1). Defaults to 1. + post_detection_dist: Distance in mm to move after detection (interpreted per trajectory). + Defaults to 2.0. + + Raises: + ValueError: If channel_idx is out of range. + RuntimeError: If no tip is mounted on channel_idx. + AssertionError: If any parameter is outside the instrument-supported range. + """ + + # Preconditions checks + # Ensure valid channel index + if not isinstance(channel_idx, int) or not (0 <= channel_idx <= self.num_channels - 1): + raise ValueError(f"channel_idx must be in [0, {self.num_channels - 1}], is {channel_idx}") + + # Conversions & machine-compatibility check of parameters + lowest_immers_pos_increments = STARBackend.mm_to_z_drive_increment(lowest_immers_pos) + start_pos_search_increments = STARBackend.mm_to_z_drive_increment(start_pos_search) + channel_speed_increments = STARBackend.mm_to_z_drive_increment(channel_speed) + channel_acceleration_thousand_increments = STARBackend.mm_to_z_drive_increment( + channel_acceleration / 1000 + ) + post_detection_dist_increments = STARBackend.mm_to_z_drive_increment(post_detection_dist) + + assert 9_320 <= lowest_immers_pos_increments <= 31_200, ( + f"Lowest immersion position must be between \n{STARBackend.z_drive_increment_to_mm(9_320)}" + + f" and {STARBackend.z_drive_increment_to_mm(31_200)} mm, is {lowest_immers_pos} mm" + ) + assert 9_320 <= start_pos_search_increments <= 31_200, ( + f"Start position of LLD search must be between \n{STARBackend.z_drive_increment_to_mm(9_320)}" + + f" and {STARBackend.z_drive_increment_to_mm(31_200)} mm, is {start_pos_search} mm" + ) + assert 20 <= channel_speed_increments <= 15_000, ( + f"LLD search speed must be between \n{STARBackend.z_drive_increment_to_mm(20)}" + + f"and {STARBackend.z_drive_increment_to_mm(15_000)} mm/sec, is {channel_speed} mm/sec" + ) + assert 5 <= channel_acceleration_thousand_increments <= 150, ( + f"Channel acceleration must be between \n{STARBackend.z_drive_increment_to_mm(5*1_000)} " + + f" and {STARBackend.z_drive_increment_to_mm(150*1_000)} mm/sec**2, is {channel_acceleration} mm/sec**2" + ) + assert ( + 0 <= detection_edge <= 1_023 + ), "Edge steepness at capacitive LLD detection must be between 0 and 1023" + assert ( + 0 <= detection_drop <= 1_023 + ), "Offset after capacitive LLD edge detection must be between 0 and 1023" + assert 0 <= post_detection_dist_increments <= 9_999, ( + "Post cLLD-detection movement distance must be between \n0" + + f" and {STARBackend.z_drive_increment_to_mm(9_999)} mm, is {post_detection_dist} mm" + ) + + await self.send_command( + module=STARBackend.channel_id(channel_idx), + command="ZL", + zh=f"{lowest_immers_pos_increments:05}", # Lowest immersion position [increment] + zc=f"{start_pos_search_increments:05}", # Start position of LLD search [increment] + zl=f"{channel_speed_increments:05}", # Speed of channel movement + zr=f"{channel_acceleration_thousand_increments:03}", # Acceleration [1000 increment/second^2] + gt=f"{detection_edge:04}", # Edge steepness at capacitive LLD detection + gl=f"{detection_drop:04}", # Offset after capacitive LLD edge detection + zj=post_detection_trajectory, # Movement of the channel after contacting surface + zi=f"{post_detection_dist_increments:04}", # Distance to move up after detection [increment] + ) + + @action(auto_prefix=True, description="用通道cLLD探测液面Z高度。") + async def clld_probe_z_height_using_channel( + self, + channel_idx: int, # 0-based indexing of channels! + lowest_immers_pos: float = 99.98, + start_pos_search: Optional[float] = None, + channel_speed: float = 10.0, + channel_acceleration: float = 800.0, + detection_edge: int = 10, + detection_drop: int = 2, + post_detection_trajectory: Literal[0, 1] = 1, + post_detection_dist: float = 2.0, + move_channels_to_safe_pos_after: bool = False, + ) -> float: + _unilab_logger.debug("[UNILAB] STARBackend.clld_probe_z_height_using_channel() called") + """Probe the liquid surface Z-height using a channel's capacitive LLD (cLLD). + + Uses the specified channel to perform a downward cLLD search and returns the + last liquid level detected by the instrument for that channel. + + This helper is responsible for: + - Ensuring a tip is mounted on the chosen channel. + - Reading the mounted tip length and applying the fixed fitting depth (8 mm) + to convert *tip-referenced* Z positions (C0-style coordinates) into the + channel Z-drive coordinates required by the firmware `ZL` cLLD command. + - Optionally moving channels to a Z-safe position after probing. + + Note: + cLLD requires a conductive target (e.g., conductive liquid / surface). + + Args: + channel_idx: Channel index to probe with (0-based; backmost channel = 0). + lowest_immers_pos: Lowest allowed search position in mm, expressed in the *tip-referenced* coordinate system (i.e., the position you would use for commands that include tip length). Internally converted to channel Z-drive coordinates before issuing `ZL`. + start_pos_search: Start position for the cLLD search in mm, expressed in the *tip-referenced* coordinate system. Internally converted to channel Z-drive coordinates before issuing `ZL`. If None, the highest safe position is used based on tip length. + channel_speed: Search speed in mm/s. Defaults to 10.0. + channel_acceleration: Search acceleration in mm/s^2. Defaults to 800.0. + detection_edge: Edge steepness threshold for cLLD detection (0-1023). Defaults to 10. + detection_drop: Offset applied after cLLD edge detection (0-1023). Defaults to 2. + post_detection_trajectory: Firmware post-detection move mode (0 or 1). Defaults to 1. + post_detection_dist: Distance in mm to move after detection (interpreted per trajectory). Defaults to 2.0. + move_channels_to_safe_pos_after: If True, moves all channels to a Z-safe position after the probing sequence completes. + + Raises: + RuntimeError: If no tip is mounted on `channel_idx`. + ValueError: If the computed start position is outside the allowed safe range. + STARFirmwareError: If the firmware reports an error during cLLD (channels are moved to Z-safe before re-raising). + + Returns: + The detected liquid surface Z-height in mm as reported by `request_pip_height_last_lld()` for `channel_idx`. + """ + + # Ensure tip is mounted + tip_presence = await self.request_tip_presence() + if not tip_presence[channel_idx]: + raise RuntimeError(f"No tip mounted on channel {channel_idx}") + + # Compute the highest position the tip can start the search from based on the known highest head position + tip_len = await self.request_tip_len_on_channel(channel_idx) + safe_tip_top_z_pos = ( + STARBackend.MAXIMUM_CHANNEL_Z_POSITION - tip_len + STARBackend.DEFAULT_TIP_FITTING_DEPTH + ) # head space -> tip space + + if start_pos_search is None: + start_pos_search = safe_tip_top_z_pos + + # Check if lowest_immers_pos is allowed + if lowest_immers_pos < STARBackend.MINIMUM_CHANNEL_Z_POSITION: + raise ValueError(f"lowest_immers_pos must be at least 99.98 mm but is {lowest_immers_pos} mm") + + # Correct for tip length + fitting depth (low level command is in head space, we are in tip space) + lowest_immers_pos_head_space = ( + lowest_immers_pos + tip_len - STARBackend.DEFAULT_TIP_FITTING_DEPTH + ) # tip space -> head space + channel_head_start_pos = round( + start_pos_search + tip_len - STARBackend.DEFAULT_TIP_FITTING_DEPTH, 2 + ) + + # Check that start position is within allowed range + if not (lowest_immers_pos <= start_pos_search <= safe_tip_top_z_pos): + raise ValueError( + f"Start position of LLD search must be between \n{lowest_immers_pos} and {safe_tip_top_z_pos} mm, is {start_pos_search} mm" + ) + + try: + await self._move_z_drive_to_liquid_surface_using_clld( + channel_idx=channel_idx, + lowest_immers_pos=lowest_immers_pos_head_space, + start_pos_search=channel_head_start_pos, + channel_speed=channel_speed, + channel_acceleration=channel_acceleration, + detection_edge=detection_edge, + detection_drop=detection_drop, + post_detection_trajectory=post_detection_trajectory, + post_detection_dist=post_detection_dist, + ) + except STARFirmwareError: + await self.move_all_channels_in_z_safety() + raise + + if move_channels_to_safe_pos_after: + await self.move_all_channels_in_z_safety() + + current_absolute_liquid_heights = await self.request_pip_height_last_lld() + return current_absolute_liquid_heights[channel_idx] + + async def _search_for_surface_using_plld( + self, + channel_idx: int, # 0-based indexing of channels! + lowest_immers_pos: float = 99.98, # mm of the head_probe! + start_pos_search: float = 334.7, # mm of the head_probe! + channel_speed_above_start_pos_search: float = 120.0, # mm/sec + channel_speed: float = 10.0, # mm + channel_acceleration: float = 800.0, # mm/sec**2 + z_drive_current_limit: int = 3, + tip_has_filter: bool = False, + dispense_drive_speed: float = 5.0, # mm/sec + dispense_drive_acceleration: float = 0.2, # mm/sec**2 + dispense_drive_max_speed: float = 14.5, # mm/sec + dispense_drive_current_limit: int = 3, + plld_detection_edge: int = 30, + plld_detection_drop: int = 10, + clld_verification: bool = False, # cLLD Verification feature + clld_detection_edge: int = 10, # cLLD Verification feature + clld_detection_drop: int = 2, # cLLD Verification feature + max_delta_plld_clld: float = 5.0, # cLLD Verification feature; mm + plld_mode: Optional[PressureLLDMode] = None, # Foam feature + plld_foam_detection_drop: int = 30, # Foam feature + plld_foam_detection_edge_tolerance: int = 30, # Foam feature + plld_foam_ad_values: int = 30, # Foam feature; unknown unit + plld_foam_search_speed: float = 10.0, # Foam feature; mm/sec + dispense_back_plld_volume: Optional[float] = None, # uL + post_detection_trajectory: Literal[0, 1] = 1, + post_detection_dist: float = 2.0, # mm + ) -> Tuple[float, float]: + _unilab_logger.debug("[UNILAB] STARBackend._search_for_surface_using_plld() called") + """Search a surface using pressured-based liquid level detection (pLLD) + (1) with or (2) without additional cLLD verification, and (a) with foam detection sub-mode or + (b) without foam detection sub-mode. + + Notes: + - This command is implemented via the PX command module, i.e. it IS parallelisable + - lowest_immers_pos & start_pos_search refer to the head_probe z-coordinate (not the tip) + - The return values represent head_probe z-positions (not the tip) in mm + + Args: + lowest_immers_pos: Lowest allowed Z during the search (mm). Default 99.98. + start_pos_search: Z position where the search begins (mm). Default 334.7. + channel_speed_above_start_pos_search: Z speed above the start position (mm/s). Default 120.0. + channel_speed: Z search speed (mm/s). Default 10.0. + channel_acceleration: Z acceleration (mm/s**2). Default 800.0. + z_drive_current_limit: Z drive current limit (instrument units). Default 3. + tip_has_filter: Whether a filter tip is mounted. Default False. + dispense_drive_speed: Dispense drive speed (mm/s). Default 5.0. + dispense_drive_acceleration: Dispense drive acceleration (mm/s**2). Default 0.2. + dispense_drive_max_speed: Dispense drive max speed (mm/s). Default 14.5. + dispense_drive_current_limit: Dispense drive current limit (instrument units). Default 3. + plld_detection_edge: Pressure detection edge threshold. Default 30. + plld_detection_drop: Pressure detection drop threshold. Default 10. + clld_verification: Activates cLLD sensing concurrently with the pressure probing. Note: cLLD + measurement itself cannot be retrieved. Instead it can be used for other applications, including + (1) verification of the surface level detected by pLLD based on max_delta_plld_clld, + (2) detection of foam (more easily triggers cLLD), if desired, causing an error. + This activates all cLLD-specific arguments. Default False. + max_delta_plld_clld: Max allowed delta between pressure/capacitive detections (mm). Default 5.0. + clld_detection_edge: Capacitive detection edge threshold. Default 10. + clld_detection_drop: Capacitive detection drop threshold. Default 2. + plld_mode: Pressure-detection sub-mode (instrument-defined). Default None. + plld_foam_detection_drop: Foam detection drop threshold. Default 30. + plld_foam_detection_edge_tolerance: Foam detection edge tolerance. Default 30. + plld_foam_ad_values: Foam AD values (instrument units). Default 30. + plld_foam_search_speed: Foam search speed (mm/s). Default 10.0. + dispense_back_plld_volume: Optional dispense-back volume after detection (uL). Default None. + post_detection_trajectory: Post-detection movement pattern selector. Default 1. + post_detection_dist: Post-detection movement distance (mm). Default 2.0. + + Returns: + Two z-coordinates (mm), head_probe, meaning depends on the selected pressure sub-mode: + - Single-detection modes/PressureLLDMode.LIQUID: (liquid_level_pos, 0) + - Two-detection modes/PressureLLDMode.FOAM: (first_detection_pos, liquid_level_pos) + """ + + # Preconditions checks + # Ensure valid channel index + if not isinstance(channel_idx, int) or not (0 <= channel_idx <= self.num_channels - 1): + raise ValueError(f"channel_idx must be in [0, {self.num_channels - 1}], is {channel_idx}") + + if plld_mode is None: + plld_mode = self.PressureLLDMode.LIQUID + + if dispense_back_plld_volume is None: + dispense_back_plld_volume_mode = 0 + dispense_back_plld_volume_increments = 0 + else: + dispense_back_plld_volume_mode = 1 + dispense_back_plld_volume_increments = STARBackend.dispensing_drive_vol_to_increment( + dispense_back_plld_volume + ) + + # Conversions to machine units + lowest_immers_pos_increments = STARBackend.mm_to_z_drive_increment(lowest_immers_pos) + start_pos_search_increments = STARBackend.mm_to_z_drive_increment(start_pos_search) + + channel_speed_above_start_pos_search_increments = STARBackend.mm_to_z_drive_increment( + channel_speed_above_start_pos_search + ) + channel_speed_increments = STARBackend.mm_to_z_drive_increment(channel_speed) + channel_acceleration_thousand_increments = STARBackend.mm_to_z_drive_increment( + channel_acceleration / 1000 + ) + + dispense_drive_speed_increments = STARBackend.dispensing_drive_mm_to_increment( + dispense_drive_speed + ) + dispense_drive_acceleration_increments = STARBackend.dispensing_drive_mm_to_increment( + dispense_drive_acceleration + ) + dispense_drive_max_speed_increments = STARBackend.dispensing_drive_mm_to_increment( + dispense_drive_max_speed + ) + + post_detection_dist_increments = STARBackend.mm_to_z_drive_increment(post_detection_dist) + max_delta_plld_clld_increments = STARBackend.mm_to_z_drive_increment(max_delta_plld_clld) + + plld_foam_search_speed_increments = STARBackend.mm_to_z_drive_increment(plld_foam_search_speed) + + # Machine-compatibility parameter checks + assert 9320 <= lowest_immers_pos_increments <= 31_200, ( + f"Lowest immersion position must be between \n{STARBackend.z_drive_increment_to_mm(9_320)}" + + f" and {STARBackend.z_drive_increment_to_mm(31_200)} mm, is {lowest_immers_pos} mm" + ) + assert 9320 <= start_pos_search_increments <= 31_200, ( + f"Start position of LLD search must be between \n{STARBackend.z_drive_increment_to_mm(9_320)}" + + f" and {STARBackend.z_drive_increment_to_mm(31_200)} mm, is {start_pos_search} mm" + ) + + assert tip_has_filter in [True, False], "tip_has_filter must be a boolean" + + assert isinstance( + clld_verification, bool + ), f"clld_verification must be a boolean, is {clld_verification}" + + assert plld_mode in [self.PressureLLDMode.LIQUID, self.PressureLLDMode.FOAM], ( + f"plld_mode must be either PressureLLDMode.LIQUID ({self.PressureLLDMode.LIQUID}) or " + + f"PressureLLDMode.FOAM ({self.PressureLLDMode.FOAM}), is {plld_mode}" + ) + + assert 20 <= channel_speed_above_start_pos_search_increments <= 15_000, ( + "Speed above start position of LLD search must be between \n" + + f"{STARBackend.z_drive_increment_to_mm(20)} and " + + f"{STARBackend.z_drive_increment_to_mm(15_000)} mm/sec, is " + + f"{channel_speed_above_start_pos_search} mm/sec" + ) + assert 20 <= channel_speed_increments <= 15_000, ( + f"LLD search speed must be between \n{STARBackend.z_drive_increment_to_mm(20)}" + + f"and {STARBackend.z_drive_increment_to_mm(15_000)} mm/sec, is {channel_speed} mm/sec" + ) + assert 5 <= channel_acceleration_thousand_increments <= 150, ( + f"Channel acceleration must be between \n{STARBackend.z_drive_increment_to_mm(5*1_000)} " + + f" and {STARBackend.z_drive_increment_to_mm(150*1_000)} mm/sec**2, is {channel_acceleration} mm/sec**2" + ) + assert ( + 0 <= z_drive_current_limit <= 7 + ), f"Z-drive current limit must be between 0 and 7, is {z_drive_current_limit}" + + assert 20 <= dispense_drive_speed_increments <= 13_500, ( + "Dispensing drive speed must be between \n" + + f"{STARBackend.dispensing_drive_increment_to_mm(20)} and " + + f"{STARBackend.dispensing_drive_increment_to_mm(13_500)} mm/sec, is {dispense_drive_speed} mm/sec" + ) + assert 1 <= dispense_drive_acceleration_increments <= 100, ( + "Dispensing drive acceleration must be between \n" + + f"{STARBackend.dispensing_drive_increment_to_mm(1)} and " + + f"{STARBackend.dispensing_drive_increment_to_mm(100)} mm/sec**2, is {dispense_drive_acceleration} mm/sec**2" + ) + assert 20 <= dispense_drive_max_speed_increments <= 13_500, ( + "Dispensing drive max speed must be between \n" + + f"{STARBackend.dispensing_drive_increment_to_mm(20)} and " + + f"{STARBackend.dispensing_drive_increment_to_mm(13_500)} mm/sec, is {dispense_drive_max_speed} mm/sec" + ) + assert ( + 0 <= dispense_drive_current_limit <= 7 + ), f"Dispensing drive current limit must be between 0 and 7, is {dispense_drive_current_limit}" + + assert ( + 0 <= clld_detection_edge <= 1_023 + ), "Edge steepness at capacitive LLD detection must be between 0 and 1023" + assert ( + 0 <= clld_detection_drop <= 1_023 + ), "Offset after capacitive LLD edge detection must be between 0 and 1023" + assert ( + 0 <= plld_detection_edge <= 1_023 + ), "Edge steepness at pressure LLD detection must be between 0 and 1023" + assert ( + 0 <= plld_detection_drop <= 1_023 + ), "Offset after pressure LLD edge detection must be between 0 and 1023" + + assert 0 <= max_delta_plld_clld_increments <= 9_999, ( + "Maximum allowed difference between pressure LLD and capacitive LLD detection z-positions " + + f"must be between 0 and {STARBackend.z_drive_increment_to_mm(9_999)} mm," + + f" is {max_delta_plld_clld} mm" + ) + + assert ( + 0 <= plld_foam_detection_drop <= 1_023 + ), f"Pressure LLD foam detection drop must be between 0 and 1023, is {plld_foam_detection_drop}" + assert 0 <= plld_foam_detection_edge_tolerance <= 1_023, ( + "Pressure LLD foam detection edge tolerance must be between 0 and 1023, " + + f"is {plld_foam_detection_edge_tolerance}" + ) + assert ( + 0 <= plld_foam_ad_values <= 4_999 + ), f"Pressure LLD foam AD values must be between 0 and 4999, is {plld_foam_ad_values}" + assert 20 <= plld_foam_search_speed_increments <= 13_500, ( + "Pressure LLD foam search speed must be between \n" + + f"{STARBackend.z_drive_increment_to_mm(20)} and " + + f"{STARBackend.z_drive_increment_to_mm(13_500)} mm/sec, is {plld_foam_search_speed} mm/sec" + ) + + assert dispense_back_plld_volume_mode in [0, 1], ( + "dispense_back_plld_volume_mode must be either 0 ('normal') or 1 " + + "('dispense back dispense_back_plld_volume'), " + + f"is {dispense_back_plld_volume_mode}" + ) + + assert 0 <= dispense_back_plld_volume_increments <= 26_666, ( + "Dispense back pressure LLD volume must be between \n0" + + f" and {STARBackend.dispensing_drive_increment_to_volume(26_666)} uL, is {dispense_back_plld_volume} uL" + ) + + assert 0 <= post_detection_dist_increments <= 9_999, ( + "Post cLLD-detection movement distance must be between \n0" + + f" and {STARBackend.z_drive_increment_to_mm(9_999)} mm, is {post_detection_dist} mm" + ) + + resp_raw = await self.send_command( + module=STARBackend.channel_id(channel_idx), + command="ZE", + zh=f"{lowest_immers_pos_increments:05}", + zc=f"{start_pos_search_increments:05}", + zi=f"{post_detection_dist_increments:04}", + zj=f"{post_detection_trajectory:01}", + gf=str(int(tip_has_filter)), + gt=f"{clld_detection_edge:04}", + gl=f"{clld_detection_drop:04}", + gu=f"{plld_detection_edge:04}", + gn=f"{plld_detection_drop:04}", + gm=str(int(clld_verification)), + gz=f"{max_delta_plld_clld_increments:04}", + cj=str(plld_mode.value), + co=f"{plld_foam_detection_drop:04}", + cp=f"{plld_foam_detection_edge_tolerance:04}", + cq=f"{plld_foam_ad_values:04}", + cl=f"{plld_foam_search_speed_increments:05}", + cc=str(dispense_back_plld_volume_mode), + cd=f"{dispense_back_plld_volume_increments:05}", + zv=f"{channel_speed_above_start_pos_search_increments:05}", + zl=f"{channel_speed_increments:05}", + zr=f"{channel_acceleration_thousand_increments:03}", + zw=f"{z_drive_current_limit}", + dl=f"{dispense_drive_speed_increments:05}", + dr=f"{dispense_drive_acceleration_increments:03}", + dv=f"{dispense_drive_max_speed_increments:05}", + dw=f"{dispense_drive_current_limit}", + read_timeout=max(self.read_timeout, 120), # it can take long (>30s) + ) + assert resp_raw is not None + + resp_probe_mm = [ + STARBackend.z_drive_increment_to_mm(int(return_val)) + for return_val in resp_raw.split("if")[-1].split() + ] + + # return depending on mode + return ( + (resp_probe_mm[0], 0) + if plld_mode == self.PressureLLDMode.LIQUID + else (resp_probe_mm[0], resp_probe_mm[1]) + ) + + @action(auto_prefix=True, description="用通道pLLD探测液面Z高度。") + async def plld_probe_z_height_using_channel( + self, + channel_idx: int, # 0-based indexing of channels! + lowest_immers_pos: float = 99.98, # mm + start_pos_search: Optional[float] = None, # mm + channel_speed_above_start_pos_search: float = 120.0, # mm/sec + channel_speed: float = 10.0, # mm + channel_acceleration: float = 800.0, # mm/sec**2 + z_drive_current_limit: int = 3, + tip_has_filter: bool = False, + dispense_drive_speed: float = 5.0, # mm/sec + dispense_drive_acceleration: float = 0.2, # mm/sec**2 + dispense_drive_max_speed: float = 14.5, # mm/sec + dispense_drive_current_limit: int = 3, + plld_detection_edge: int = 30, + plld_detection_drop: int = 10, + clld_verification: bool = False, # cLLD Verification feature + clld_detection_edge: int = 10, # cLLD Verification feature + clld_detection_drop: int = 2, # cLLD Verification feature + max_delta_plld_clld: float = 5.0, # cLLD Verification feature; mm + plld_mode: Optional[PressureLLDMode] = None, # Foam feature + plld_foam_detection_drop: int = 30, # Foam feature + plld_foam_detection_edge_tolerance: int = 30, # Foam feature + plld_foam_ad_values: int = 30, # Foam feature; unknown unit + plld_foam_search_speed: float = 10.0, # Foam feature; mm/sec + dispense_back_plld_volume: Optional[float] = None, # uL + post_detection_trajectory: Literal[0, 1] = 1, + post_detection_dist: float = 2.0, # mm + move_channels_to_safe_pos_after: bool = False, + ) -> Tuple[float, float]: + _unilab_logger.debug("[UNILAB] STARBackend.plld_probe_z_height_using_channel() called") + """Detect liquid level using pressured-based liquid level detection (pLLD) + (1) with or (2) without additional cLLD verification, and (a) with foam detection sub-mode or + (b) without foam detection sub-mode. + + Notes: + - This command is implemented via BOTH the PX and C0 command modules, i.e. it is NOT parallelisable! + - lowest_immers_pos & start_pos_search refer to the tip z-coordinate (not the head_probe)! + - The return values represent tip z-positions (not the head_probe) in mm! + + Args: + lowest_immers_pos: Lowest allowed search position in mm, expressed in the *tip-referenced* coordinate system (i.e., the position you would use for commands that include tip length). Internally converted to channel Z-drive coordinates before issuing `ZL`. + start_pos_search: Start position for the cLLD search in mm, expressed in the *tip-referenced* coordinate system. Internally converted to channel Z-drive coordinates before issuing `ZL`. If None, the highest safe position is used based on tip length. + channel_speed_above_start_pos_search: Z speed above the start position (mm/s). Default 120.0. + channel_speed: Z search speed (mm/s). Default 10.0. + channel_acceleration: Z acceleration (mm/s**2). Default 800.0. + z_drive_current_limit: Z drive current limit (instrument units). Default 3. + tip_has_filter: Whether a filter tip is mounted. Default False. + dispense_drive_speed: Dispense drive speed (mm/s). Default 5.0. + dispense_drive_acceleration: Dispense drive acceleration (mm/s**2). Default 0.2. + dispense_drive_max_speed: Dispense drive max speed (mm/s). Default 14.5. + dispense_drive_current_limit: Dispense drive current limit (instrument units). Default 3. + plld_detection_edge: Pressure detection edge threshold. Default 30. + plld_detection_drop: Pressure detection drop threshold. Default 10. + clld_verification: Activates cLLD sensing concurrently with the pressure probing. Note: cLLD + measurement itself cannot be retrieved. Instead it can be used for other applications, including + (1) verification of the surface level detected by pLLD based on max_delta_plld_clld, + (2) detection of foam (more easily triggers cLLD), if desired causing and error. + This activates all cLLD-specific arguments. Default False. + clld_detection_edge: Capacitive detection edge threshold. Default 10. + clld_detection_drop: Capacitive detection drop threshold. Default 2. + max_delta_plld_clld: Max allowed delta between pressure/capacitive detections (mm). Default 5.0. + plld_mode: Pressure-detection sub-mode (instrument-defined). Default None. + plld_foam_detection_drop: Foam detection drop threshold. Default 30. + plld_foam_detection_edge_tolerance: Foam detection edge tolerance. Default 30. + plld_foam_ad_values: Foam AD values (instrument units). Default 30. + plld_foam_search_speed: Foam search speed (mm/s). Default 10.0. + dispense_back_plld_volume: Optional dispense-back volume after detection (uL). Default None. + post_detection_trajectory: Post-detection movement pattern selector. Default 1. + post_detection_dist: Post-detection movement distance (mm). Default 2.0. + + Returns: + Two z-coordinates (mm), tip, meaning depends on the selected pressure sub-mode: + - Single-detection modes/PressureLLDMode.LIQUID: (liquid_level_pos, 0) + - Two-detection modes/PressureLLDMode.FOAM: (first_detection_pos, liquid_level_pos) + """ + + # Ensure tip is mounted + tip_presence = await self.request_tip_presence() + if not tip_presence[channel_idx]: + raise RuntimeError(f"No tip mounted on channel {channel_idx}") + + # Compute the highest position the tip can start the search from based on the known highest head position + tip_len = await self.request_tip_len_on_channel(channel_idx) + safe_tip_top_z_pos = ( + STARBackend.MAXIMUM_CHANNEL_Z_POSITION - tip_len + STARBackend.DEFAULT_TIP_FITTING_DEPTH + ) # head space -> tip space + + if start_pos_search is None: + start_pos_search = safe_tip_top_z_pos + + # Check if lowest_immers_pos is allowed + if lowest_immers_pos < STARBackend.MINIMUM_CHANNEL_Z_POSITION: + raise ValueError(f"lowest_immers_pos must be at least 99.98 mm but is {lowest_immers_pos} mm") + + # Correct for tip length + fitting depth (low level command is in head space, we are in tip space) + lowest_immers_pos_head_space = ( + lowest_immers_pos + tip_len - STARBackend.DEFAULT_TIP_FITTING_DEPTH + ) # tip space -> head space + channel_head_start_pos = round( + start_pos_search + tip_len - STARBackend.DEFAULT_TIP_FITTING_DEPTH, 2 + ) + + # Check that start position is within allowed range + if not (lowest_immers_pos <= start_pos_search <= safe_tip_top_z_pos): + raise ValueError( + f"Start position of LLD search must be between \n{lowest_immers_pos} and {safe_tip_top_z_pos} mm, is {start_pos_search} mm" + ) + + try: + resp_probe_mm = await self._search_for_surface_using_plld( + channel_idx=channel_idx, + lowest_immers_pos=lowest_immers_pos_head_space, + start_pos_search=channel_head_start_pos, + channel_speed_above_start_pos_search=channel_speed_above_start_pos_search, + channel_speed=channel_speed, + channel_acceleration=channel_acceleration, + z_drive_current_limit=z_drive_current_limit, + tip_has_filter=tip_has_filter, + dispense_drive_speed=dispense_drive_speed, + dispense_drive_acceleration=dispense_drive_acceleration, + dispense_drive_max_speed=dispense_drive_max_speed, + dispense_drive_current_limit=dispense_drive_current_limit, + plld_detection_edge=plld_detection_edge, + plld_detection_drop=plld_detection_drop, + clld_verification=clld_verification, + clld_detection_edge=clld_detection_edge, + clld_detection_drop=clld_detection_drop, + max_delta_plld_clld=max_delta_plld_clld, + plld_mode=plld_mode, + plld_foam_detection_drop=plld_foam_detection_drop, + plld_foam_detection_edge_tolerance=plld_foam_detection_edge_tolerance, + plld_foam_ad_values=plld_foam_ad_values, + plld_foam_search_speed=plld_foam_search_speed, + dispense_back_plld_volume=dispense_back_plld_volume, + post_detection_trajectory=post_detection_trajectory, + post_detection_dist=post_detection_dist, + ) + except STARFirmwareError: + await self.move_all_channels_in_z_safety() + raise + + if plld_mode == self.PressureLLDMode.FOAM: + resp_tip_mm = ( + round(resp_probe_mm[0] - tip_len + STARBackend.DEFAULT_TIP_FITTING_DEPTH, 2), + round(resp_probe_mm[1] - tip_len + STARBackend.DEFAULT_TIP_FITTING_DEPTH, 2), + ) + else: + resp_tip_mm = ( + round(resp_probe_mm[0] - tip_len + STARBackend.DEFAULT_TIP_FITTING_DEPTH, 2), + 0.0, + ) + + if move_channels_to_safe_pos_after: + await self.move_all_channels_in_z_safety() + + return resp_tip_mm + + @action(auto_prefix=True, description="获取通道探针Z位置。") + async def request_probe_z_position(self, channel_idx: int) -> float: + _unilab_logger.debug("[UNILAB] STARBackend.request_probe_z_position() called") + """Request the z-position of the channel probe (EXCLUDING the tip)""" + resp = await self.send_command( + module=self.channel_id(channel_idx), command="RZ", fmt="rz######" + ) + increments = resp["rz"] + return self.z_drive_increment_to_mm(increments) + + @action(auto_prefix=True, description="测量通道上的吸头长度。") + async def request_tip_len_on_channel(self, channel_idx: int) -> float: + _unilab_logger.debug("[UNILAB] STARBackend.request_tip_len_on_channel() called") + """Measures the length of the tip attached to the specified pipetting channel. + Checks if a tip is present on the given channel. Raises an error if no tip is present. + + Parameters: + channel_idx: Index of the pipetting channel (0-indexed). + + Returns: + The measured tip length in millimeters. + + Raises: + RuntimeError: If no tip is present on the channel. + """ + + # Check there is a tip on the channel + all_channel_occupancy = await self.request_tip_presence() + if not all_channel_occupancy[channel_idx]: + raise RuntimeError(f"No tip present on channel {channel_idx}") + + # Request z position of probe bottom + probe_position = await self.request_probe_z_position(channel_idx=channel_idx) + + # Request z-coordinate of probe+tip bottom + tip_bottom_z_coordinate = await self.request_tip_bottom_z_position(channel_idx=channel_idx) + + fitting_depth_of_all_standard_channel_tips = 8 # mm + return round( + probe_position - (tip_bottom_z_coordinate - fitting_depth_of_all_standard_channel_tips), + 1, + ) + + MAXIMUM_CHANNEL_Z_POSITION = 334.7 # mm (= z-drive increment 31_200) + MINIMUM_CHANNEL_Z_POSITION = 99.98 # mm (= z-drive increment 9_320) + DEFAULT_TIP_FITTING_DEPTH = 8 # mm, for 10, 50, 300, 1000 ul Hamilton tips + + @action(auto_prefix=True, description="用Z触碰方式探测Z高度。") + async def ztouch_probe_z_height_using_channel( + self, + channel_idx: int, # 0-based indexing of channels! + tip_len: Optional[float] = None, # mm + lowest_immers_pos: float = 99.98, # mm + start_pos_search: Optional[float] = None, # mm + channel_speed: float = 10.0, # mm/sec + channel_acceleration: float = 800.0, # mm/sec**2 + channel_speed_upwards: float = 125.0, # mm + detection_limiter_in_PWM: int = 1, + push_down_force_in_PWM: int = 0, + post_detection_dist: float = 2.0, # mm + move_channels_to_safe_pos_after: bool = False, + ) -> float: + _unilab_logger.debug("[UNILAB] STARBackend.ztouch_probe_z_height_using_channel() called") + """Probes the Z-height below the specified channel on a Hamilton STAR liquid handling machine + using the channels 'z-touchoff' capabilities, i.e. a controlled triggering of the z-drive, + aka a controlled 'crash'. + + Args: + channel_idx: The index of the channel to use for probing. Backmost channel = 0. + tip_len: override the tip length (of tip on channel `channel_idx`). Default is the tip length + of the tip that was picked up. + lowest_immers_pos: The lowest immersion position in mm. + start_pos_lld_search: The start position for z-touch search in mm. + channel_speed: The speed of channel movement in mm/sec. + channel_acceleration: The acceleration of the channel in mm/sec**2. + detection_limiter_in_PWM: Offset PWM limiter value for searching + push_down_force_in_PWM: Offset PWM value for push down force. + cf000 = No push down force, drive is switched off. + post_detection_dist: Distance to move into the trajectory after detection in mm. + move_channels_to_safe_pos_after: Flag to move channels to a safe position after + operation. + + Returns: + The detected Z-height in mm. + """ + + version = await self.request_pip_channel_version(channel_idx) + year_matches = re.search(r"\b\d{4}\b", version) + if year_matches is not None: + year = int(year_matches.group()) + if year < 2022: + raise ValueError( + "Z-touch probing is not supported for PIP versions predating 2022, " + f"found version '{version}'" + ) + + if tip_len is None: + # currently a bug, will be fixed in the future + # reverted to previous implementation + # tip_len = self.head[channel_idx].get_tip().total_tip_length + tip_len = await self.request_tip_len_on_channel(channel_idx) + + if start_pos_search is None: + start_pos_search = ( + STARBackend.MAXIMUM_CHANNEL_Z_POSITION - tip_len + STARBackend.DEFAULT_TIP_FITTING_DEPTH + ) + + tip_len_used_in_increments = ( + tip_len - STARBackend.DEFAULT_TIP_FITTING_DEPTH + ) / STARBackend.z_drive_mm_per_increment + channel_head_start_pos = ( + start_pos_search + tip_len - STARBackend.DEFAULT_TIP_FITTING_DEPTH + ) # start_pos of the head itself! + safe_head_bottom_z_pos = ( + STARBackend.MINIMUM_CHANNEL_Z_POSITION + tip_len - STARBackend.DEFAULT_TIP_FITTING_DEPTH + ) + safe_head_top_z_pos = STARBackend.MAXIMUM_CHANNEL_Z_POSITION + + lowest_immers_pos_increments = STARBackend.mm_to_z_drive_increment(lowest_immers_pos) + start_pos_search_increments = STARBackend.mm_to_z_drive_increment(channel_head_start_pos) + channel_speed_increments = STARBackend.mm_to_z_drive_increment(channel_speed) + channel_acceleration_thousand_increments = STARBackend.mm_to_z_drive_increment( + channel_acceleration / 1000 + ) + channel_speed_upwards_increments = STARBackend.mm_to_z_drive_increment(channel_speed_upwards) + + assert 0 <= channel_idx <= 15, f"channel_idx must be between 0 and 15, is {channel_idx}" + assert 20 <= tip_len <= 120, "Total tip length must be between 20 and 120" + + assert 9320 <= lowest_immers_pos_increments <= 31_200, ( + "Lowest immersion position must be between \n99.98" + + f" and 334.7 mm, is {lowest_immers_pos} mm" + ) + assert safe_head_bottom_z_pos <= channel_head_start_pos <= safe_head_top_z_pos, ( + f"Start position of LLD search must be between \n{safe_head_bottom_z_pos}" + + f" and {safe_head_top_z_pos} mm, is {channel_head_start_pos} mm" + ) + assert 20 <= channel_speed_increments <= 15_000, ( + f"Z-touch search speed must be between \n{STARBackend.z_drive_increment_to_mm(20)}" + + f" and {STARBackend.z_drive_increment_to_mm(15_000)} mm/sec, is {channel_speed} mm/sec" + ) + assert 5 <= channel_acceleration_thousand_increments <= 150, ( + f"Channel acceleration must be between \n{STARBackend.z_drive_increment_to_mm(5*1_000)}" + + f" and {STARBackend.z_drive_increment_to_mm(150*1_000)} mm/sec**2, is {channel_speed} mm/sec**2" + ) + assert 20 <= channel_speed_upwards_increments <= 15_000, ( + f"Channel retraction speed must be between \n{STARBackend.z_drive_increment_to_mm(20)}" + + f" and {STARBackend.z_drive_increment_to_mm(15_000)} mm/sec, is {channel_speed_upwards} mm/sec" + ) + assert ( + 0 <= detection_limiter_in_PWM <= 125 + ), "Detection limiter value must be between 0 and 125 PWM." + assert 0 <= push_down_force_in_PWM <= 125, "Push down force between 0 and 125 PWM values" + assert ( + 0 <= post_detection_dist <= 245 + ), f"Post detection distance must be between 0 and 245 mm, is {post_detection_dist}" + + lowest_immers_pos_str = f"{lowest_immers_pos_increments:05}" + start_pos_search_str = f"{start_pos_search_increments:05}" + channel_speed_str = f"{channel_speed_increments:05}" + channel_acc_str = f"{channel_acceleration_thousand_increments:03}" + channel_speed_up_str = f"{channel_speed_upwards_increments:05}" + detection_limiter_in_PWM_str = f"{detection_limiter_in_PWM:03}" + push_down_force_in_PWM_str = f"{push_down_force_in_PWM:03}" + + ztouch_probed_z_height = await self.send_command( + module=STARBackend.channel_id(channel_idx), + command="ZH", + zb=start_pos_search_str, # begin of searching range [increment] + za=lowest_immers_pos_str, # end of searching range [increment] + zv=channel_speed_up_str, # speed z-drive upper section [increment/second] + zr=channel_acc_str, # acceleration z-drive [1000 increment/second] + zu=channel_speed_str, # speed z-drive lower section [increment/second] + cg=detection_limiter_in_PWM_str, # offset PWM limiter value for searching + cf=push_down_force_in_PWM_str, # offset PWM value for push down force + fmt="rz#####", + ) + # Subtract tip_length from measurement in increment, and convert to mm + result_in_mm = STARBackend.z_drive_increment_to_mm( + ztouch_probed_z_height["rz"] - tip_len_used_in_increments + ) + if post_detection_dist != 0: # Safety first + await self.move_channel_z(z=result_in_mm + post_detection_dist, channel=channel_idx) + if move_channels_to_safe_pos_after: + await self.move_all_channels_in_z_safety() + + return float(result_in_mm) + + class RotationDriveOrientation(enum.Enum): + LEFT = 1 + FRONT = 2 + RIGHT = 3 + PARKED_RIGHT = None + + @action(auto_prefix=True, description="旋转iSWAP旋转驱动。") + async def rotate_iswap_rotation_drive(self, orientation: RotationDriveOrientation): + _unilab_logger.debug("[UNILAB] STARBackend.rotate_iswap_rotation_drive() called") + if orientation in { + STARBackend.RotationDriveOrientation.RIGHT, + STARBackend.RotationDriveOrientation.FRONT, + STARBackend.RotationDriveOrientation.LEFT, + }: + return await self.send_command( + module="R0", + command="WP", + auto_id=False, + wp=orientation.value, + ) + else: + raise ValueError(f"Invalid rotation drive orientation: {orientation}") + + class WristDriveOrientation(enum.Enum): + RIGHT = 1 + STRAIGHT = 2 + LEFT = 3 + REVERSE = 4 + + @action(auto_prefix=True, description="旋转iSWAP腕部。") + async def rotate_iswap_wrist(self, orientation: WristDriveOrientation): + _unilab_logger.debug("[UNILAB] STARBackend.rotate_iswap_wrist() called") + return await self.send_command( + module="R0", + command="TP", + auto_id=False, + tp=orientation.value, + ) + + @staticmethod + @action(auto_prefix=True, description="获取通道标识。") + def channel_id(channel_idx: int) -> str: + _unilab_logger.debug("[UNILAB] STARBackend.channel_id() called") + """channel_idx: plr style, 0-indexed from the back""" + channel_ids = "123456789ABCDEFG" + return "P" + channel_ids[channel_idx] + + @action(auto_prefix=True, description="获取所有通道Y位置。") + async def get_channels_y_positions(self) -> Dict[int, float]: + _unilab_logger.debug("[UNILAB] STARBackend.get_channels_y_positions() called") + """Get the Y position of all channels in mm""" + resp = await self.send_command( + module="C0", + command="RY", + fmt="ry#### (n)", + ) + y_positions = [round(y / 10, 2) for y in resp["ry"]] + + # sometimes there is (likely) a floating point error and channels are reported to be + # less than 9mm apart. (When you set channels using position_channels_in_y_direction, + # it will raise an error.) The minimum y is 6mm, so we fix that first (in case that + # values is misreported). Then, we traverse the list in reverse and set the min_diff. + if y_positions[-1] < 5.8: + raise RuntimeError( + "Channels are reported to be too close to the front of the machine. " + "The known minimum is 6, which will be fixed automatically for 5.8=9mm. We start with the channel closest to `back_channel`, and make sure the + # channel behind it is at least 9mm, updating if needed. Iterating from the front (closest + # to `back_channel`) to the back (channel 0), all channels are put at the correct location. + # This order matters because the channel in front of any channel may have been moved in the + # previous iteration. + # Note that if a channel is already spaced at >=9mm, it is not moved. + use_channels = list(ys.keys()) + back_channel = min(use_channels) + for channel_idx in range(back_channel, 0, -1): + if ( + channel_locations[channel_idx - 1] - channel_locations[channel_idx] + ) < self._channel_minimum_y_spacing: + channel_locations[channel_idx - 1] = ( + channel_locations[channel_idx] + self._channel_minimum_y_spacing + ) + + # Similarly for the channels to the front of `front_channel`, make sure they are all + # spaced >= channel_minimum_y_spacing (usually 9mm) apart. This time, we iterate from + # back (closest to `front_channel`) to the front (lh.backend.num_channels - 1), and + # put each channel >= channel_minimum_y_spacing before the one behind it. + front_channel = max(use_channels) + for channel_idx in range(front_channel, self.num_channels - 1): + if ( + channel_locations[channel_idx] - channel_locations[channel_idx + 1] + ) < self._channel_minimum_y_spacing: + channel_locations[channel_idx + 1] = ( + channel_locations[channel_idx] - self._channel_minimum_y_spacing + ) + + # Quick checks before movement. + if channel_locations[0] > 650: + raise ValueError("Channel 0 would hit the back of the robot") + + if channel_locations[self.num_channels - 1] < 6: + raise ValueError("Channel N would hit the front of the robot") + + if not all( + round((channel_locations[i] - channel_locations[i + 1]) * 1000) >= 8_990 # float fixing + for i in range(len(channel_locations) - 1) + ): + raise ValueError("Channels must be at least 9mm apart and in descending order") + + yp = " ".join([f"{round(y*10):04}" for y in channel_locations.values()]) + return await self.send_command( + module="C0", + command="JY", + yp=yp, + ) + + @action(auto_prefix=True, description="获取所有通道Z位置。") + async def get_channels_z_positions(self) -> Dict[int, float]: + _unilab_logger.debug("[UNILAB] STARBackend.get_channels_z_positions() called") + """Get the Y position of all channels in mm""" + resp = await self.send_command( + module="C0", + command="RZ", + fmt="rz#### (n)", + ) + return {channel_idx: round(y / 10, 2) for channel_idx, y in enumerate(resp["rz"])} + + @action(auto_prefix=True, description="设置多个通道Z位置。") + async def position_channels_in_z_direction(self, zs: Dict[int, float]): + _unilab_logger.debug("[UNILAB] STARBackend.position_channels_in_z_direction() called") + channel_locations = await self.get_channels_z_positions() + + for channel_idx, z in zs.items(): + channel_locations[channel_idx] = z + + return await self.send_command( + module="C0", command="JZ", zp=[f"{round(z*10):04}" for z in channel_locations.values()] + ) + + @action(auto_prefix=True, description="刺穿板封膜。") + async def pierce_foil( + self, + wells: Union[Well, List[Well]], + piercing_channels: List[int], + hold_down_channels: List[int], + move_inwards: float, + spread: Literal["wide", "tight"] = "wide", + one_by_one: bool = False, + distance_from_bottom: float = 20.0, + ): + _unilab_logger.debug("[UNILAB] STARBackend.pierce_foil() called") + """Pierce the foil of the media source plate at the specified column. Throw away the tips + after piercing because there will be a bit of foil stuck to the tips. Use this method + before aspirating from a foil-sealed plate to make sure the tips are clean and the + aspirations are accurate. + + Args: + wells: Well or wells in the plate to pierce the foil. If multiple wells, they must be on one + column. + piercing_channels: The channels to use for piercing the foil. + hold_down_channels: The channels to use for holding down the plate when moving up the + piercing channels. + spread: The spread of the piercing channels in the well. + one_by_one: If True, the channels will pierce the foil one by one. If False, all channels + will pierce the foil simultaneously. + """ + + x: float + ys: List[float] + z: float + + # if only one well is give, but in a list, convert to Well so we fall into single-well logic. + if isinstance(wells, list) and len(wells) == 1: + wells = wells[0] + + if isinstance(wells, Well): + well = wells + x, y, z = well.get_location_wrt(self.deck, "c", "c", "cavity_bottom") + + if spread == "wide": + offsets = get_wide_single_resource_liquid_op_offsets( + well, num_channels=len(piercing_channels) + ) + else: + offsets = get_tight_single_resource_liquid_op_offsets( + well, num_channels=len(piercing_channels) + ) + ys = [y + offset.y for offset in offsets] + else: + assert ( + len(set(w.get_location_wrt(self.deck).x for w in wells)) == 1 + ), "Wells must be on the same column" + absolute_center = wells[0].get_location_wrt(self.deck, "c", "c", "cavity_bottom") + x = absolute_center.x + ys = [well.get_location_wrt(self.deck, x="c", y="c").y for well in wells] + z = absolute_center.z + + await self.move_channel_x(0, x=x) + + await self.position_channels_in_y_direction( + {channel: y for channel, y in zip(piercing_channels, ys)} + ) + + zs = [z + distance_from_bottom for _ in range(len(piercing_channels))] + if one_by_one: + for channel in piercing_channels: + await self.move_channel_z(channel, z) + else: + await self.position_channels_in_z_direction( + {channel: z for channel, z in zip(piercing_channels, zs)} + ) + + await self.step_off_foil( + [wells] if isinstance(wells, Well) else wells, + back_channel=hold_down_channels[0], + front_channel=hold_down_channels[1], + move_inwards=move_inwards, + ) + + @action(auto_prefix=True, description="从封膜边缘脱离通道。") + async def step_off_foil( + self, + wells: Union[Well, List[Well]], + front_channel: int, + back_channel: int, + move_inwards: float = 2, + move_height: float = 15, + ): + _unilab_logger.debug("[UNILAB] STARBackend.step_off_foil() called") + """ + Hold down a plate by placing two channels on the edges of a plate that is sealed with foil + while moving up the channels that are still within the foil. This is useful when, for + example, aspirating from a plate that is sealed: without holding it down, the tips might get + stuck in the plate and move it up when retracting. Putting plates on the edge prevents this. + + When aspirating or dispensing in the foil, be sure to set the `min_z_endpos` parameter in + `lh.aspirate` or `lh.dispense` to a value in the foil. You might want to use something like + + .. code-block:: python + + well = plate.get_well("A3") + await lh.aspirate( + [well]*4, vols=[100]*4, use_channels=[7,8,9,10], + min_z_endpos=well.get_location_wrt(self.deck, z="cavity_bottom").z, + surface_following_distance=0, + pull_out_distance_transport_air=[0] * 4) + await step_off_foil(lh.backend, [well], front_channel=11, back_channel=6, move_inwards=3) + + Args: + wells: Wells in the plate to hold down. (x-coordinate of channels will be at center of wells). + Must be sorted from back to front. + front_channel: The channel to place on the front of the plate. + back_channel: The channel to place on the back of the plate. + move_inwards: mm to move inwards (backward on the front channel; frontward on the back). + move_height: mm to move upwards after piercing the foil. front_channel and back_channel will hold the plate down. + """ + + if front_channel <= back_channel: + raise ValueError( + "front_channel should be in front of back_channel. " "Channels are 0-indexed from the back." + ) + + if isinstance(wells, Well): + wells = [wells] + + plates = set(well.parent for well in wells) + assert len(plates) == 1, "All wells must be in the same plate" + plate = plates.pop() + assert plate is not None + + z_location = plate.get_location_wrt(self.deck, z="top").z + + if plate.get_absolute_rotation().z % 360 == 0: + back_location = plate.get_location_wrt(self.deck, y="b") + front_location = plate.get_location_wrt(self.deck, y="f") + elif plate.get_absolute_rotation().z % 360 == 90: + back_location = plate.get_location_wrt(self.deck, x="r") + front_location = plate.get_location_wrt(self.deck, x="l") + elif plate.get_absolute_rotation().z % 360 == 180: + back_location = plate.get_location_wrt(self.deck, y="f") + front_location = plate.get_location_wrt(self.deck, y="b") + elif plate.get_absolute_rotation().z % 360 == 270: + back_location = plate.get_location_wrt(self.deck, x="l") + front_location = plate.get_location_wrt(self.deck, x="r") + else: + raise ValueError("Plate rotation must be a multiple of 90 degrees") + + try: + # Then move all channels in the y-space simultaneously. + await self.position_channels_in_y_direction( + { + front_channel: front_location.y + move_inwards, + back_channel: back_location.y - move_inwards, + } + ) + + await self.move_channel_z(front_channel, z_location) + await self.move_channel_z(back_channel, z_location) + finally: + # Move channels that are lower than the `front_channel` and `back_channel` to + # the just above the foil, in case the foil pops up. + zs = await self.get_channels_z_positions() + indices = [channel_idx for channel_idx, z in zs.items() if z < z_location] + idx = { + idx: z_location + move_height for idx in indices if idx not in (front_channel, back_channel) + } + await self.position_channels_in_z_direction(idx) + + # After that, all channels are clear to move up. + await self.move_all_channels_in_z_safety() + + @action(auto_prefix=True, description="获取吸头内体积。") + async def request_volume_in_tip(self, channel: int) -> float: + _unilab_logger.debug("[UNILAB] STARBackend.request_volume_in_tip() called") + resp = await self.send_command(STARBackend.channel_id(channel), "QC", fmt="qc##### (n)") + _, current_volume = resp["qc"] # first is max volume + return float(current_volume) / 10 + + @asynccontextmanager + @action(auto_prefix=True, description="临时降低iSWAP速度。") + async def slow_iswap(self, wrist_velocity: int = 20_000, gripper_velocity: int = 20_000): + _unilab_logger.debug("[UNILAB] STARBackend.slow_iswap() called") + """A context manager that sets the iSWAP to slow speed during the context""" + assert 20 <= gripper_velocity <= 75_000 + assert 20 <= wrist_velocity <= 65_000 + + original_wv = (await self.send_command("R0", "RA", ra="wv", fmt="wv#####"))["wv"] + original_tv = (await self.send_command("R0", "RA", ra="tv", fmt="tv#####"))["tv"] + + await self.send_command("R0", "AA", wv=gripper_velocity) # wrist velocity + await self.send_command("R0", "AA", tv=wrist_velocity) # gripper velocity + try: + yield + finally: + await self.send_command("R0", "AA", wv=original_wv) + await self.send_command("R0", "AA", tv=original_tv) + + # HamiltonHeaterShakerInterface + + @action(auto_prefix=True, description="向HHS模块发送命令。") + async def send_hhs_command(self, index: int, command: str, **kwargs) -> str: + _unilab_logger.debug("[UNILAB] STARBackend.send_hhs_command() called") + resp = await self.send_command( + module=f"T{index}", + command=command, + **kwargs, + ) + assert isinstance(resp, str) + return resp + + # ------------ STAR(RS-232/TCC1/2)-connected Hamilton Heater Cooler (HHS) ------------- + + @action(auto_prefix=True, description="检查连接设备是否为HHC模块。") + async def check_type_is_hhc(self, device_number: int): + _unilab_logger.debug("[UNILAB] STARBackend.check_type_is_hhc() called") + """ + Convenience method to check that connected device is an HHC. + Executed through firmware query + """ + + firmware_version = await self.send_command(module=f"T{device_number}", command="RF") + if "Hamilton Heater Cooler" not in firmware_version: + raise ValueError( + f"Device number {device_number} does not connect to a Hamilton" + f" Heater-Cooler, found {firmware_version} instead." + f"Have you called the wrong device number?" + ) + + @action(auto_prefix=True, description="初始化HHC加热冷却模块。") + async def initialize_hhc(self, device_number: int) -> str: + _unilab_logger.debug("[UNILAB] STARBackend.initialize_hhc() called") + """Initialize Hamilton Heater Cooler (HHC) at specified TCC port + + Args: + device_number: TCC connect number to the HHC + """ + + module_pointer = f"T{device_number}" + + # Request module configuration + try: + await self.send_command(module=module_pointer, command="QU") + except TimeoutError as exc: + error_message = ( + f"No Hamilton Heater Cooler found at device_number {device_number}" + f", have you checked your connections? Original error: {exc}" + ) + raise ValueError(error_message) from exc + + await self.check_type_is_hhc(device_number) + + # Request module configuration + hhc_init_status = await self.send_command(module=module_pointer, command="QW", fmt="qw#") + hhc_init_status = hhc_init_status["qw"] + + info = "HHC already initialized" + # Initializing HHS if necessary + if hhc_init_status != 1: + # Initialize device + await self.send_command(module=module_pointer, command="LI") + info = f"HHS at device number {device_number} initialized." + + return info + + @action(auto_prefix=True, description="启动HHC温控。") + async def start_temperature_control_at_hhc( + self, + device_number: int, + temp: Union[float, int], + ): + _unilab_logger.debug("[UNILAB] STARBackend.start_temperature_control_at_hhc() called") + """Start temperature regulation of specified HHC""" + + await self.check_type_is_hhc(device_number) + assert 0 < temp <= 105 + + # Ensure proper temperature input handling + if isinstance(temp, (float, int)): + safe_temp_str = f"{round(temp * 10):04d}" + else: + safe_temp_str = str(temp) + + return await self.send_command( + module=f"T{device_number}", + command="TA", # temperature adjustment + ta=safe_temp_str, + tb="1800", # TODO: identify precise purpose? + tc="0020", # TODO: identify precise purpose? + ) + + @action(auto_prefix=True, description="获取HHC温度。") + async def get_temperature_at_hhc(self, device_number: int) -> dict: + _unilab_logger.debug("[UNILAB] STARBackend.get_temperature_at_hhc() called") + """Query current temperatures of both sensors of specified HHC""" + + await self.check_type_is_hhc(device_number) + + request_temperature = await self.send_command(module=f"T{device_number}", command="RT") + processed_t_info = [int(x) / 10 for x in request_temperature.split("+")[-2:]] + + return { + "middle_T": processed_t_info[0], + "edge_T": processed_t_info[-1], + } + + @action(auto_prefix=True, description="查询HHC是否达到目标温度。") + async def query_whether_temperature_reached_at_hhc(self, device_number: int): + _unilab_logger.debug("[UNILAB] STARBackend.query_whether_temperature_reached_at_hhc() called") + """Stop temperature regulation of specified HHC""" + + await self.check_type_is_hhc(device_number) + query_current_control_status = await self.send_command( + module=f"T{device_number}", command="QD", fmt="qd#" + ) + + return query_current_control_status["qd"] == 0 + + @action(auto_prefix=True, description="停止HHC温控。") + async def stop_temperature_control_at_hhc(self, device_number: int): + _unilab_logger.debug("[UNILAB] STARBackend.stop_temperature_control_at_hhc() called") + """Stop temperature regulation of specified HHC""" + + await self.check_type_is_hhc(device_number) + + return await self.send_command(module=f"T{device_number}", command="TO") + @action(auto_prefix=True, description="将资源放入hotel位。") + async def put_in_hotel( + self, + hotel_center_x_coord: int = 0, + hotel_center_y_coord: int = 0, + hotel_center_z_coord: int = 0, + hotel_center_x_direction: Literal[0, 1] = 0, + hotel_center_y_direction: Literal[0, 1] = 0, + hotel_center_z_direction: Literal[0, 1] = 0, + clearance_height: int = 50, + hotel_depth: int = 1_300, + grip_direction: GripDirection = GripDirection.FRONT, + traverse_height_at_beginning: int = 3_600, + z_position_at_end: int = 3_600, + grip_strength: Literal[0, 1, 2, 3, 4, 5, 6, 7, 8, 9] = 5, + open_gripper_position: int = 860, + collision_control: Literal[0, 1] = 1, + high_acceleration_index: Literal[1, 2, 3, 4] = 4, + low_acceleration_index: Literal[1, 2, 3, 4] = 1, + fold_up_at_end: bool = True, + ): + return await self.unsafe.put_in_hotel(hotel_center_x_coord, hotel_center_y_coord, hotel_center_z_coord, hotel_center_x_direction, hotel_center_y_direction, hotel_center_z_direction, clearance_height, hotel_depth, grip_direction, traverse_height_at_beginning, z_position_at_end, grip_strength, open_gripper_position, collision_control, high_acceleration_index, low_acceleration_index, fold_up_at_end) + + @action(auto_prefix=True, description="从hotel位取出资源。") + async def get_from_hotel( + self, + hotel_center_x_coord: int = 0, + hotel_center_y_coord: int = 0, + hotel_center_z_coord: int = 0, + # for direction, 0 is positive, 1 is negative + hotel_center_x_direction: Literal[0, 1] = 0, + hotel_center_y_direction: Literal[0, 1] = 0, + hotel_center_z_direction: Literal[0, 1] = 0, + clearance_height: int = 50, + hotel_depth: int = 1_300, + grip_direction: GripDirection = GripDirection.FRONT, + traverse_height_at_beginning: int = 3_600, + z_position_at_end: int = 3_600, + grip_strength: Literal[0, 1, 2, 3, 4, 5, 6, 7, 8, 9] = 5, + open_gripper_position: int = 860, + plate_width: int = 800, + plate_width_tolerance: int = 20, + collision_control: Literal[0, 1] = 1, + high_acceleration_index: Literal[1, 2, 3, 4] = 4, + low_acceleration_index: Literal[1, 2, 3, 4] = 1, + fold_up_at_end: bool = True, + ): + return await self.unsafe.get_from_hotel(hotel_center_x_coord, hotel_center_y_coord, hotel_center_z_coord, hotel_center_x_direction, hotel_center_y_direction, hotel_center_z_direction, clearance_height, hotel_depth, grip_direction, traverse_height_at_beginning, z_position_at_end, grip_strength, open_gripper_position, plate_width, plate_width_tolerance, collision_control, high_acceleration_index, low_acceleration_index, fold_up_at_end) + + @action(auto_prefix=True, description="强制弹出吸头。") + async def violently_shoot_down_tip(self, channel_idx: int): + return await self.unsafe.violently_shoot_down_tip(channel_idx) + + # -------------- Extra - Probing labware with STAR - making STAR into a CMM -------------- + + +class UnSafe: + """ + Namespace for actions that are unsafe to perform. + For example, actions that send the iSWAP outside of the Hamilton Deck + """ + + def __init__(self, star: "STARBackend"): + self.star = star + + async def put_in_hotel( + self, + hotel_center_x_coord: int = 0, + hotel_center_y_coord: int = 0, + hotel_center_z_coord: int = 0, + hotel_center_x_direction: Literal[0, 1] = 0, + hotel_center_y_direction: Literal[0, 1] = 0, + hotel_center_z_direction: Literal[0, 1] = 0, + clearance_height: int = 50, + hotel_depth: int = 1_300, + grip_direction: GripDirection = GripDirection.FRONT, + traverse_height_at_beginning: int = 3_600, + z_position_at_end: int = 3_600, + grip_strength: Literal[0, 1, 2, 3, 4, 5, 6, 7, 8, 9] = 5, + open_gripper_position: int = 860, + collision_control: Literal[0, 1] = 1, + high_acceleration_index: Literal[1, 2, 3, 4] = 4, + low_acceleration_index: Literal[1, 2, 3, 4] = 1, + fold_up_at_end: bool = True, + ): + """ + A hotel is a location to store a plate. This can be a loading + dock for an external machine such as a cytomat or a centrifuge. + + Take care when using this command to interact with hotels located + outside of the hamilton deck area. Ensure that rotations of the + iSWAP arm don't collide with anything. + + tip: set the hotel depth big enough so that the boundary is inside the + hamilton deck. The iSWAP rotations will happen before it enters the hotel. + + The units of all relevant variables are in 0.1mm + """ + + assert 0 <= hotel_center_x_coord <= 99_999 + assert 0 <= hotel_center_y_coord <= 6_500 + assert 0 <= hotel_center_z_coord <= 3_500 + assert 0 <= clearance_height <= 999 + assert 0 <= hotel_depth <= 3_000 + assert 0 <= traverse_height_at_beginning <= 3_600 + assert 0 <= z_position_at_end <= 3_600 + assert 0 <= open_gripper_position <= 9_999 + + return await self.star.send_command( + module="C0", + command="PI", + xs=f"{hotel_center_x_coord:05}", + xd=hotel_center_x_direction, + yj=f"{hotel_center_y_coord:04}", + yd=hotel_center_y_direction, + zj=f"{hotel_center_z_coord:04}", + zd=hotel_center_z_direction, + zc=f"{clearance_height:03}", + hd=f"{hotel_depth:04}", + gr={ + GripDirection.FRONT: 1, + GripDirection.RIGHT: 2, + GripDirection.BACK: 3, + GripDirection.LEFT: 4, + }[grip_direction], + th=f"{traverse_height_at_beginning:04}", + te=f"{z_position_at_end:04}", + gw=grip_strength, + go=f"{open_gripper_position:04}", + ga=collision_control, + xe=f"{high_acceleration_index} {low_acceleration_index}", + gc=int(fold_up_at_end), + ) + + async def get_from_hotel( + self, + hotel_center_x_coord: int = 0, + hotel_center_y_coord: int = 0, + hotel_center_z_coord: int = 0, + # for direction, 0 is positive, 1 is negative + hotel_center_x_direction: Literal[0, 1] = 0, + hotel_center_y_direction: Literal[0, 1] = 0, + hotel_center_z_direction: Literal[0, 1] = 0, + clearance_height: int = 50, + hotel_depth: int = 1_300, + grip_direction: GripDirection = GripDirection.FRONT, + traverse_height_at_beginning: int = 3_600, + z_position_at_end: int = 3_600, + grip_strength: Literal[0, 1, 2, 3, 4, 5, 6, 7, 8, 9] = 5, + open_gripper_position: int = 860, + plate_width: int = 800, + plate_width_tolerance: int = 20, + collision_control: Literal[0, 1] = 1, + high_acceleration_index: Literal[1, 2, 3, 4] = 4, + low_acceleration_index: Literal[1, 2, 3, 4] = 1, + fold_up_at_end: bool = True, + ): + """ + A hotel is a location to store a plate. This can be a loading + dock for an external machine such as a cytomat or a centrifuge. + + Take care when using this command to interact with hotels located + outside of the hamilton deck area. Ensure that rotations of the + iSWAP arm don't collide with anything. + + tip: set the hotel depth big enough so that the boundary is inside the + hamilton deck. The iSWAP rotations will happen before it enters the hotel. + + The units of all relevant variables are in 0.1mm + """ + + assert 0 <= hotel_center_x_coord <= 99_999 + assert 0 <= hotel_center_y_coord <= 6_500 + assert 0 <= hotel_center_z_coord <= 3_500 + assert 0 <= clearance_height <= 999 + assert 0 <= hotel_depth <= 3_000 + assert 0 <= traverse_height_at_beginning <= 3_600 + assert 0 <= z_position_at_end <= 3_600 + assert 0 <= open_gripper_position <= 9_999 + assert 0 <= plate_width <= 9_999 + assert 0 <= plate_width_tolerance <= 99 + + return await self.star.send_command( + module="C0", + command="PO", + xs=f"{hotel_center_x_coord:05}", + xd=hotel_center_x_direction, + yj=f"{hotel_center_y_coord:04}", + yd=hotel_center_y_direction, + zj=f"{hotel_center_z_coord:04}", + zd=hotel_center_z_direction, + zc=f"{clearance_height:03}", + hd=f"{hotel_depth:04}", + gr={ + GripDirection.FRONT: 1, + GripDirection.RIGHT: 2, + GripDirection.BACK: 3, + GripDirection.LEFT: 4, + }[grip_direction], + th=f"{traverse_height_at_beginning:04}", + te=f"{z_position_at_end:04}", + gw=grip_strength, + go=f"{open_gripper_position:04}", + gb=f"{plate_width:04}", + gt=f"{plate_width_tolerance:02}", + ga=collision_control, + xe=f"{high_acceleration_index} {low_acceleration_index}", + gc=int(fold_up_at_end), + ) + + async def violently_shoot_down_tip(self, channel_idx: int): + """Shoot down the tip on the specified channel by releasing the drive that holds the spring. The + tips will shoot down in place at an acceleration bigger than g. This is done by initializing + the squeezer drive wihile a tip is mounted. + + Safe to do when above a tip rack, for example directly after a tip pickup. + + .. warning:: + + Consider this method an easter egg. Not for serious use. + """ + await self.star.send_command(module=STARBackend.channel_id(channel_idx), command="SI") + + +# Deprecated alias with warning # TODO: remove mid May 2025 (giving people 1 month to update) +# https://github.com/PyLabRobot/pylabrobot/issues/466 + + +class STAR(STARBackend): + def __init__(self, *args, **kwargs): + warnings.warn( + "`STAR` is deprecated and will be removed in a future release. " + "Please use `STARBackend` instead.", + DeprecationWarning, + stacklevel=2, + ) + super().__init__(*args, **kwargs) diff --git a/unilabos/devices/_phage_display/v_spin_backend.py b/unilabos/devices/_phage_display/v_spin_backend.py new file mode 100644 index 000000000..65346633f --- /dev/null +++ b/unilabos/devices/_phage_display/v_spin_backend.py @@ -0,0 +1,813 @@ +""" +Auto-generated by Build Agent +Original: pylabrobot/centrifuge/vspin_backend.py + +[UNILAB DEBUG] 此驱动已注入调试日志 +- 每个方法被调用时会打印日志 +- 日志格式: [UNILAB] ClassName.method_name() called +- 查看日志: logging.getLogger("unilab.driver").setLevel(logging.DEBUG) +""" +from unilabos.registry.decorators import action, device +from typing import Dict, Any, Optional +import logging +import warnings as _warnings +_warnings.filterwarnings("ignore", message=r"pkg_resources is deprecated as an API\..*") +import sys as _sys +import types as _types + +# 兼容性:部分上游框架(例如 lantz)在 import-time 强制加载 Qt(PyQt4/PySide1), +# 在 headless/现代 Python 环境中通常不存在这些绑定,会导致导入直接失败。 +# 这里预注入一个最小的 `lantz.utils.qt` shim,避免触发其 Qt loader。 +if "lantz.utils.qt" not in _sys.modules: + _qt_mod = _types.ModuleType("lantz.utils.qt") + + class _DummyQtCore: + class QObject: + def __init__(self, *_args, **_kwargs): + # 兼容 QObject(parent=...) 等调用 + return None + + @staticmethod + def Signal(*_args, **_kwargs): + return object() + + @staticmethod + def Slot(*_args, **_kwargs): + def _decorator(fn): + return fn + + return _decorator + + _qt_mod.QtCore = _DummyQtCore + + class _DummySuperQObject(_DummyQtCore.QObject): + def __new__(cls, *_args, **_kwargs): + return super().__new__(cls) + + def __init__(self, *_args, **_kwargs): + # 允许 Driver(...) 传参,不触发 object.__init__ 的 TypeError + return None + + _qt_mod.MetaQObject = type + _qt_mod.SuperQObject = _DummySuperQObject + _sys.modules["lantz.utils.qt"] = _qt_mod + +# Unilab 驱动调试日志 +_unilab_logger = logging.getLogger("unilab.driver.VSpinBackend") +_unilab_logger.setLevel(logging.DEBUG) +if not _unilab_logger.handlers: + _handler = logging.StreamHandler() + _handler.setFormatter(logging.Formatter("[%(asctime)s] %(message)s")) + _unilab_logger.addHandler(_handler) + +# Vendored repo packages: pylabrobot, pylabrobot +import sys as _sys +from pathlib import Path as _Path +_vendor_root = _Path(__file__).resolve().parent / "_vendor" +if _vendor_root.exists() and str(_vendor_root) not in _sys.path: + _sys.path.insert(0, str(_vendor_root)) + +import asyncio +import ctypes +import json +import logging +import math +import os +import time +import warnings +from typing import Optional + +from pylabrobot.io.ftdi import FTDI + +from pylabrobot.centrifuge.backend import CentrifugeBackend, LoaderBackend +from pylabrobot.centrifuge.standard import LoaderNoPlateError + +logger = logging.getLogger(__name__) + + +class Access2Backend(LoaderBackend): + def __init__( + self, + device_id: str, + timeout: int = 60, + ): + """ + Args: + device_id: The libftdi id for the loader. Find using + `python3 -m pylibftdi.examples.list_devices` + """ + self.io = FTDI(device_id=device_id) + self.timeout = timeout + + async def _read(self) -> bytes: + x = b"" + r = None + start = time.time() + while r != b"" or x == b"": + r = await self.io.read(1) + x += r + if r == b"": + await asyncio.sleep(0.1) + if x == b"" and (time.time() - start) > self.timeout: + raise TimeoutError("No data received within the specified timeout period") + return x + + async def send_command(self, command: bytes) -> bytes: + logger.debug("[loader] Sending %s", command.hex()) + await self.io.write(command) + return await self._read() + + async def setup(self): + logger.debug("[loader] setup") + + await self.io.setup() + await self.io.set_baudrate(115384) + + status = await self.get_status() + if not status.startswith(bytes.fromhex("1105")): + raise RuntimeError("Failed to get status") + + await self.send_command(bytes.fromhex("110500030014000072b1")) + await self.send_command(bytes.fromhex("1105000300100000ae71")) + await self.send_command(bytes.fromhex("110500070024040000008000be89")) + await self.send_command(bytes.fromhex("11050007002404008000800063b1")) + await self.send_command(bytes.fromhex("11050007002404000001800089b9")) + await self.send_command(bytes.fromhex("1105000700240400800180005481")) + await self.send_command(bytes.fromhex("110500070024040000024000c6bd")) + await self.send_command(bytes.fromhex("1105000300400000f0bf")) + await self.send_command(bytes.fromhex("1105000a004607000100000000020235bf")) + # await self.send_command(bytes.fromhex("11050003002000006bd4")) + await self.send_command(bytes.fromhex("1105000e00440b00000000000000007041020203c7")) + # await self.send_command(bytes.fromhex("11050003002000006bd4")) + + async def stop(self): + logger.debug("[loader] stop") + await self.io.stop() + + def serialize(self): + return {"io": self.io.serialize(), "timeout": self.timeout} + + async def get_status(self) -> bytes: + logger.debug("[loader] get_status") + return await self.send_command(bytes.fromhex("11050003002000006bd4")) + + async def park(self): + logger.debug("[loader] park") + await self.send_command(bytes.fromhex("1105000e00440b0000000000410000704103007539")) + + async def close(self): + logger.debug("[loader] close") + await self.send_command(bytes.fromhex("1105000a00420700010000803f02008c64")) + + async def open(self): + logger.debug("[loader] open") + await self.send_command(bytes.fromhex("1105000a0042070001000080bf0200b73e")) + + async def load(self): + """only tested for 1cm plate, 3mm pickup height""" + logger.debug("[loader] load") + + await self.send_command(bytes.fromhex("1105000a004607000100000000020235bf")) + await self.send_command(bytes.fromhex("1105000e00440b000100004040000020410200a5cb")) + + # laser check + r = await self.send_command(bytes.fromhex("1105000300500000b3dc")) + if r == bytes.fromhex("1105000800510500000300000079f1"): + raise LoaderNoPlateError("no plate found on stage") + + await self.send_command(bytes.fromhex("1105000a00460700018fc2b540020023dc")) + await self.send_command(bytes.fromhex("1105000e00440b000200004040000020410300ee00")) + await self.send_command(bytes.fromhex("1105000a004607000100000000020015fd")) + await self.send_command(bytes.fromhex("1105000e00440b0000000040400000204102007d82")) + + async def unload(self): + """only tested for 1cm plate, 3mm pickup height""" + logger.debug("[loader] unload") + + await self.send_command(bytes.fromhex("1105000a004607000100000000020235bf")) + await self.send_command(bytes.fromhex("1105000e00440b000200004040000020410200dd31")) + + # laser check + r = await self.send_command(bytes.fromhex("1105000300500000b3dc")) + if r == bytes.fromhex("1105000800510500000300000079f1"): + raise LoaderNoPlateError("no plate found in centrifuge") + + await self.send_command(bytes.fromhex("1105000a00460700017b14b6400200d57a")) + await self.send_command(bytes.fromhex("1105000e00440b00010000404000002041030096fa")) + await self.send_command(bytes.fromhex("1105000a004607000100000000020015fd")) + await self.send_command(bytes.fromhex("1105000e00440b00000000000000002041020056be")) + # await self.send_command(bytes.fromhex("11050003002000006bd4")) + + +_vspin_bucket_calibrations_path = os.path.join( + os.path.expanduser("~"), + ".pylabrobot", + "vspin_bucket_calibrations.json", +) + + +def _load_vspin_calibrations(device_id: str) -> Optional[int]: + if not os.path.exists(_vspin_bucket_calibrations_path): + warnings.warn( + f"No calibration found for VSpin with device id {device_id}. " + "Please set the bucket 1 position using `set_bucket_1_position_to_current` method after setup.", + UserWarning, + ) + return None + with open(_vspin_bucket_calibrations_path, "r") as f: + return json.load(f).get(device_id) # type: ignore + + +def _save_vspin_calibrations(device_id, remainder: int): + if os.path.exists(_vspin_bucket_calibrations_path): + with open(_vspin_bucket_calibrations_path, "r") as f: + data = json.load(f) + else: + data = {} + data[device_id] = remainder + os.makedirs(os.path.dirname(_vspin_bucket_calibrations_path), exist_ok=True) + with open(_vspin_bucket_calibrations_path, "w") as f: + json.dump(data, f) + + +FULL_ROTATION: int = 8000 + + +bucket_1_not_set_error = RuntimeError( + "Bucket 1 position not set. " + "Please rotate the bucket to bucket 1 using VSpinBackend.go_to_position and " + "then calling VSpinBackend.set_bucket_1_position_to_current." +) + + +@device( + id="v_spin_backend", + category=["Centrifuge"], + description="用于实验室样品离心处理的自动化离心机,可进行转子位置控制、开关门、门锁与桶锁控制,并按设定的相对离心力或转速执行离心程序。", + model={ + "type": "device", + "mesh": "v_spin_backend", + "path": "https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/devices/v_spin_backend/macro_device.xacro", + }, +) +class VSpinBackend(CentrifugeBackend): + """Backend for the Agilent Centrifuge. + Note that this is not a complete implementation.""" + + def __init__(self, device_id: Optional[str] = None): + print("[UNILAB] VSpinBackend.__init__() called", flush=True) + """ + Args: + device_id: The libftdi id for the centrifuge. Find using `python -m pylibftdi.examples.list_devices` + """ + self._device_id = device_id + self.io = FTDI(device_id=device_id) + self._bucket_1_remainder: Optional[int] = None + if device_id is not None: + self._bucket_1_remainder = _load_vspin_calibrations(device_id) + + @action(auto_prefix=True, description="执行离心机设置与校准加载。") + async def setup(self): + _unilab_logger.debug("[UNILAB] VSpinBackend.setup() called") + await self.io.setup() + # TODO: add functionality where if robot has been initialized before nothing needs to happen + for _ in range(3): + await self.configure_and_initialize() + await self._send_command(bytes.fromhex("aa002101ff21")) + await self._send_command(bytes.fromhex("aa002101ff21")) + await self._send_command(bytes.fromhex("aa01132034")) + await self._send_command(bytes.fromhex("aa002102ff22")) + await self._send_command(bytes.fromhex("aa02132035")) + await self._send_command(bytes.fromhex("aa002103ff23")) + await self._send_command(bytes.fromhex("aaff1a142d")) + + await self.io.set_baudrate(57600) + await self.io.set_rts(True) + await self.io.set_dtr(True) + + await self._send_command(bytes.fromhex("aa01121f32")) + for _ in range(8): + await self._send_command(bytes.fromhex("aa0220ff0f30")) + await self._send_command(bytes.fromhex("aa0220df0f10")) + await self._send_command(bytes.fromhex("aa0220df0e0f")) + await self._send_command(bytes.fromhex("aa0220df0c0d")) + await self._send_command(bytes.fromhex("aa0220df0809")) + for _ in range(4): + await self._send_command(bytes.fromhex("aa0226000028")) + await self._send_command(bytes.fromhex("aa02120317")) + for _ in range(5): + await self._send_command(bytes.fromhex("aa0226200048")) + await self._send_command(bytes.fromhex("aa0226000028")) + await self.lock_door() + + await self._send_command(bytes.fromhex("aa0226000028")) + + await self._send_command(bytes.fromhex("aa0117021a")) + await self._send_command(bytes.fromhex("aa01e6c800b00496000f004b00a00f050007")) + await self._send_command(bytes.fromhex("aa0117041c")) + await self._send_command(bytes.fromhex("aa01170119")) + + await self._send_command(bytes.fromhex("aa010b0c")) + await self._send_command(bytes.fromhex("aa010001")) + await self._send_command(bytes.fromhex("aa01e605006400000000003200e80301006e")) + await self._send_command(bytes.fromhex("aa0194b61283000012010000f3")) + await self._send_command(bytes.fromhex("aa01192842")) + + resp = 0x89 + while resp == 0x89: + resp = (await self._get_positions_and_tachometer()).status + + # --- almost the same as go to position --- + await self._send_command(bytes.fromhex("aa0117021a")) + await self._send_command(bytes.fromhex("aa01e6c800b00496000f004b00a00f050007")) + await self._send_command(bytes.fromhex("aa0117041c")) + await self._send_command(bytes.fromhex("aa01170119")) + + await self._send_command(bytes.fromhex("aa010b0c")) + await self._send_command(bytes.fromhex("aa01e6c800b00496000f004b00a00f050007")) + new_position = (0).to_bytes(4, byteorder="little") # arbitrary + # rpm = 600, + # acceleration = 75.09289617486338 + await self._send_command( + bytes.fromhex("aa01d497") + new_position + bytes.fromhex("c3f52800d71a000049") + ) + # ----------------------------------------- + + resp = 0x08 + while resp != 0x09: + resp = (await self._get_positions_and_tachometer()).status + + await self._send_command(bytes.fromhex("aa0117021a")) + + await self.lock_door() + + # If we have not set the calibration yet, load it now. + if self._bucket_1_remainder is None: + device_id = await self.io.get_serial() + self._bucket_1_remainder = _load_vspin_calibrations(device_id) + + @property + @action(auto_prefix=True, description="获取桶位 1 的校准余量。") + def bucket_1_remainder(self) -> int: + _unilab_logger.debug("[UNILAB] VSpinBackend.bucket_1_remainder() called") + if self._bucket_1_remainder is None: + raise bucket_1_not_set_error + return self._bucket_1_remainder + + @action(auto_prefix=True, description="将当前位置设为桶位 1 并保存校准。") + async def set_bucket_1_position_to_current(self) -> None: + _unilab_logger.debug("[UNILAB] VSpinBackend.set_bucket_1_position_to_current() called") + """Set the current position as bucket 1 position and save calibration.""" + current_position = await self.get_position() + device_id = await self.io.get_serial() + remainder = await self.get_home_position() - current_position + self._bucket_1_remainder = current_position % FULL_ROTATION + _save_vspin_calibrations(device_id, remainder) + + @action(auto_prefix=True, description="获取桶位 1 的位置。") + async def get_bucket_1_position(self) -> int: + _unilab_logger.debug("[UNILAB] VSpinBackend.get_bucket_1_position() called") + """Get the bucket 1 position based on calibration. + Normally it is the home position minus the remainder (calibration). + The bucket 1 position must be greater than the current position, so we find + the first position greater than the current position by adding full rotations if needed. + """ + if self._bucket_1_remainder is None: + raise bucket_1_not_set_error + home_position = await self.get_home_position() + bucket_1_position_mod_full_rotation = home_position - self.bucket_1_remainder + # first number after current position that matches bucket 1 position mod FULL_ROTATION + current_position = await self.get_position() + bucket_1_position = ( + FULL_ROTATION + * math.floor((current_position - bucket_1_position_mod_full_rotation) / FULL_ROTATION + 1) + + bucket_1_position_mod_full_rotation + ) + return bucket_1_position + + @action(auto_prefix=True, description="停止离心机运行。") + async def stop(self): + _unilab_logger.debug("[UNILAB] VSpinBackend.stop() called") + await self.configure_and_initialize() + await self.io.stop() + + class _StatusPositionTachometer(ctypes.LittleEndianStructure): + _pack_ = 1 + _fields_ = [ + ("status", ctypes.c_uint8), + ("current_position", ctypes.c_uint32), + ("unknown1", ctypes.c_uint8), + ("tachometer", ctypes.c_int16), + ("unknown2", ctypes.c_uint8), + ("home_position", ctypes.c_uint32), + ("checksum", ctypes.c_uint8), + ] + + async def _get_positions_and_tachometer(self) -> _StatusPositionTachometer: + _unilab_logger.debug("[UNILAB] VSpinBackend._get_positions_and_tachometer() called") + """Returns 14 bytes + + Example: + 11 22 25 00 00 4f 00 00 18 e0 05 00 00 a4 + ^^ checksum + ^^ ^^ ^^ ^^ home position + ^^ ? (probably binary status objects) + ^^ ^^ tachometer + ^^ ? (probably binary status objects) + ^^ ^^ ^^ ^^ current position + ^^ + - First byte (index 0): + - 11 = 0b0001011 = idle + - 13 = 0b0001101 = unknown + - 08 = 0b0001000 = spinning + - 09 = 0b0001001 = also spinning but different + - 19 = 0b0010011 = unknown + - 88 = 0b1011000 = unknown + - 89 = 0b1011001 = unknown + - 10th to 13th byte (index 9-12) = Homing Position + - Last byte (index 13) = checksum + """ + resp = await self._send_command(bytes.fromhex("aa010e0f")) + if len(resp) == 0: + raise IOError("Empty status from centrifuge") + return VSpinBackend._StatusPositionTachometer.from_buffer_copy(resp) + + @action(auto_prefix=True, description="获取当前转子位置。") + async def get_position(self) -> int: + _unilab_logger.debug("[UNILAB] VSpinBackend.get_position() called") + return (await self._get_positions_and_tachometer()).current_position # type: ignore + + @action(auto_prefix=True, description="获取当前转速。") + async def get_tachometer(self) -> int: + _unilab_logger.debug("[UNILAB] VSpinBackend.get_tachometer() called") + """current speed in rpm""" + tack_to_rpm = -14.69320388 # R^2 = 0.9999 when spinning, but not specific at single-digit RPM + return (await self._get_positions_and_tachometer()).tachometer * tack_to_rpm # type: ignore + + @action(auto_prefix=True, description="获取原点位置。") + async def get_home_position(self) -> int: + _unilab_logger.debug("[UNILAB] VSpinBackend.get_home_position() called") + """changes during a run, but the bucket 1 position relative to it does not""" + return (await self._get_positions_and_tachometer()).home_position # type: ignore + + async def _get_status(self): + _unilab_logger.debug("[UNILAB] VSpinBackend._get_status() called") + """ + examples: + - 0080d0015 + - 0080f0015 + """ + + resp = await self._send_command(bytes.fromhex("aa020e10")) + if len(resp) == 0: + raise IOError("Empty status from centrifuge. Is the machine on?") + return resp + + @action(auto_prefix=True, description="获取样品桶锁定状态。") + async def get_bucket_locked(self) -> bool: + _unilab_logger.debug("[UNILAB] VSpinBackend.get_bucket_locked() called") + resp = await self._get_status() + return resp[2] & 0b0001 != 0 # type: ignore + + @action(auto_prefix=True, description="获取门打开状态。") + async def get_door_open(self) -> bool: + _unilab_logger.debug("[UNILAB] VSpinBackend.get_door_open() called") + resp = await self._get_status() + return resp[2] & 0b0010 != 0 # type: ignore + + @action(auto_prefix=True, description="获取门锁定状态。") + async def get_door_locked(self) -> bool: + _unilab_logger.debug("[UNILAB] VSpinBackend.get_door_locked() called") + resp = await self._get_status() + return resp[2] & 0b0100 == 0 # type: ignore + + # Centrifuge communication: read_resp, send + + async def _read_resp(self, timeout: float = 20) -> bytes: + _unilab_logger.debug("[UNILAB] VSpinBackend._read_resp() called") + """Read a response from the centrifuge. If the timeout is reached, return the data that has + been read so far.""" + data = b"" + end_byte_found = False + start_time = time.time() + + while True: + chunk = await self.io.read(25) + if chunk: + data += chunk + end_byte_found = data[-1] == 0x0D + if len(chunk) < 25 and end_byte_found: + break + else: + if end_byte_found or time.time() - start_time > timeout: + break + await asyncio.sleep(0.0001) + + logger.debug("Read %s", data.hex()) + return data + + async def _send_command(self, cmd: bytes, read_timeout=0.2) -> bytes: + _unilab_logger.debug("[UNILAB] VSpinBackend._send_command() called") + written = await self.io.write(bytes(cmd)) + + if written != len(cmd): + raise RuntimeError("Failed to write all bytes") + return await self._read_resp(timeout=read_timeout) + + @action(auto_prefix=True, description="配置并初始化离心机。") + async def configure_and_initialize(self): + _unilab_logger.debug("[UNILAB] VSpinBackend.configure_and_initialize() called") + await self.set_configuration_data() + await self.initialize() + + @action(auto_prefix=True, description="设置设备配置数据。") + async def set_configuration_data(self): + _unilab_logger.debug("[UNILAB] VSpinBackend.set_configuration_data() called") + """Set the device configuration data.""" + await self.io.set_latency_timer(16) + await self.io.set_line_property(bits=8, stopbits=1, parity=0) + await self.io.set_flowctrl(0) + await self.io.set_baudrate(19200) + + @action(auto_prefix=True, description="初始化离心机。") + async def initialize(self): + _unilab_logger.debug("[UNILAB] VSpinBackend.initialize() called") + await self.io.write(b"\x00" * 20) + for i in range(33): + packet = b"\xaa" + bytes([i & 0xFF, 0x0E, 0x0E + (i & 0xFF)]) + b"\x00" * 8 + await self.io.write(packet) + await self._send_command(bytes.fromhex("aaff0f0e")) + + # Centrifuge operations + + @action(auto_prefix=True, description="打开离心机门。") + async def open_door(self): + _unilab_logger.debug("[UNILAB] VSpinBackend.open_door() called") + if await self.get_door_open(): + return + # used to be: aa022600072f + await self._send_command(bytes.fromhex("aa022600062e")) # same as unlock door + + # we can't tell when the door is fully open, so we just wait a bit + await asyncio.sleep(4) + + @action(auto_prefix=True, description="关闭离心机门。") + async def close_door(self): + _unilab_logger.debug("[UNILAB] VSpinBackend.close_door() called") + if not (await self.get_door_open()): + return + # used to be: aa022600052d + await self._send_command(bytes.fromhex("aa022600042c")) # same as unlock door + # we can't tell when the door is fully closed, so we just wait a bit + await asyncio.sleep(2) + + @action(auto_prefix=True, description="锁定离心机门。") + async def lock_door(self): + _unilab_logger.debug("[UNILAB] VSpinBackend.lock_door() called") + if await self.get_door_open(): + raise RuntimeError("Cannot lock door while it is open.") + if await self.get_door_locked(): + return + # used to be aa0226000129 + await self._send_command(bytes.fromhex("aa0226000028")) + + @action(auto_prefix=True, description="解锁离心机门。") + async def unlock_door(self): + _unilab_logger.debug("[UNILAB] VSpinBackend.unlock_door() called") + if not await self.get_door_locked(): + return + # used to be aa022600052d + await self._send_command(bytes.fromhex("aa022600042c")) # same as close door + + @action(auto_prefix=True, description="锁定样品桶。") + async def lock_bucket(self): + _unilab_logger.debug("[UNILAB] VSpinBackend.lock_bucket() called") + if await self.get_bucket_locked(): + return + await self._send_command(bytes.fromhex("aa022600072f")) + + @action(auto_prefix=True, description="解锁样品桶。") + async def unlock_bucket(self): + _unilab_logger.debug("[UNILAB] VSpinBackend.unlock_bucket() called") + if not await self.get_bucket_locked(): + return + await self._send_command(bytes.fromhex("aa022600062e")) # same as open door + + @action(auto_prefix=True, description="将转子移动到桶位 1。") + async def go_to_bucket1(self): + _unilab_logger.debug("[UNILAB] VSpinBackend.go_to_bucket1() called") + await self.go_to_position(await self.get_bucket_1_position()) + + @action(auto_prefix=True, description="将转子移动到桶位 2。") + async def go_to_bucket2(self): + _unilab_logger.debug("[UNILAB] VSpinBackend.go_to_bucket2() called") + await self.go_to_position(await self.get_bucket_1_position() + FULL_ROTATION // 2) + + @action(auto_prefix=True, description="将转子移动到指定位置。") + async def go_to_position(self, position: int): + _unilab_logger.debug("[UNILAB] VSpinBackend.go_to_position() called") + await self.close_door() + await self.lock_door() + + position_bytes = position.to_bytes(4, byteorder="little") + byte_string = bytes.fromhex("aa01d497") + position_bytes + bytes.fromhex("c3f52800d71a0000") + sum_byte = (sum(byte_string) - 0xAA) & 0xFF + byte_string += sum_byte.to_bytes(1, byteorder="little") + await self._send_command(bytes.fromhex("aa0226000028")) + await self._send_command(bytes.fromhex("aa0117021a")) + await self._send_command(bytes.fromhex("aa01e6c800b00496000f004b00a00f050007")) + await self._send_command(bytes.fromhex("aa0117041c")) + await self._send_command(bytes.fromhex("aa01170119")) + await self._send_command(bytes.fromhex("aa010b0c")) + await self._send_command(bytes.fromhex("aa01e6c800b00496000f004b00a00f050007")) + await self._send_command(byte_string) + + # await self._send_command(bytes.fromhex("aa0117021a")) + while ( + abs(await self.get_position() - position) > 10 + ): # 10 tacks tolerance (10/8000 * 360 = 0.45 degrees) + await asyncio.sleep(0.1) + await self.open_door() + + @staticmethod + @action(auto_prefix=True, description="将相对离心力换算为转速。") + def g_to_rpm(g: float) -> int: + _unilab_logger.debug("[UNILAB] VSpinBackend.g_to_rpm() called") + # https://en.wikipedia.org/wiki/Centrifugation#Mathematical_formula + r = 10 + rpm = int((g / (1.118 * 10**-5 * r)) ** 0.5) + return rpm + + @action(auto_prefix=True, description="按设定离心力、时长、加速度和减速度执行离心。") + async def spin( + self, + g: float = 500, + duration: float = 60, + acceleration: float = 0.8, + deceleration: float = 0.8, + ) -> None: + _unilab_logger.debug("[UNILAB] VSpinBackend.spin() called") + """Start a spin cycle. spin spin spin spin + + Args: + g: relative centrifugal force, also known as g-force + duration: time in seconds spent at speed (g) + acceleration: 0-1 of total acceleration + deceleration: 0-1 of total deceleration + """ + + if acceleration <= 0 or acceleration > 1: + raise ValueError("Acceleration must be within 0-1.") + if deceleration <= 0 or deceleration > 1: + raise ValueError("Deceleration must be within 0-1.") + if g < 1 or g > 1000: + raise ValueError("G-force must be within 1-1000") + if duration < 1: + raise ValueError("Spin time must be at least 1 second") + + if await self.get_door_open(): + await self.close_door() + if not await self.get_door_locked(): + await self.lock_door() + if await self.get_bucket_locked(): + await self.unlock_bucket() + + # 1 - compute the final position + rpm = VSpinBackend.g_to_rpm(g) + + # compute the distance traveled during the acceleration period + # distance = 1/2 * v^2 / a. area under 0 to t (triangle). t = a/v_max + # 12903.2 ticks/s^2 is 100% acceleration + acceleration_ticks_per_second2 = 12903.2 * acceleration + rounds_per_second = rpm / 60 + ticks_per_second = rounds_per_second * 8000 + distance_during_acceleration = int(0.5 * (ticks_per_second**2) / acceleration_ticks_per_second2) + + # compute the distance traveled at speed + distance_at_speed = ticks_per_second * duration + + current_position = await self.get_position() + final_position = int(current_position + distance_during_acceleration + distance_at_speed) + + if final_position > 2**32 - 1: + # this is almost 3 hours of spinning at 3000 rpm (max speed), + # so we assume nobody will ever hit this. + raise NotImplementedError( + "We don't know what happens if the destination position exceeds 2^32-1. " + "Please report this issue on discuss.pylabrobot.org." + ) + + # 2 - send "go to position" command with computed final position and rpm + position_b = final_position.to_bytes(4, byteorder="little") + rpm_b = int(rpm * 4473.925).to_bytes(4, byteorder="little") + acceleration_b = int(9.15 * 100 * acceleration).to_bytes(4, byteorder="little") + + byte_string = bytes.fromhex("aa01d497") + position_b + rpm_b + acceleration_b + checksum = (sum(byte_string) - 0xAA) & 0xFF + byte_string += checksum.to_bytes(1, byteorder="little") + + await self._send_command(bytes.fromhex("aa0226000028")) + await self._send_command(bytes.fromhex("aa0117021a")) + await self._send_command(bytes.fromhex("aa01e6c800b00496000f004b00a00f050007")) + await self._send_command(bytes.fromhex("aa0117041c")) + await self._send_command(bytes.fromhex("aa01170119")) + await self._send_command(bytes.fromhex("aa010b0c")) + await self._send_command(bytes.fromhex("aa01e60500640000000000fd00803e01000c")) + + await self._send_command(byte_string) + + # 3 - wait for acceleration to the set rpm + # we also check the position to avoid waiting forever if the speed is not reached (e.g. short spin...) + while await self.get_tachometer() < rpm * 0.95 and await self.get_position() < final_position: + await asyncio.sleep(0.1) + + # 4 - once the speed is reached, compute the position at which to start deceleration + # this is different than computed above, because above we assumed constant acceleration from 0 to rpm. + # however, in reality there is jerk and the acceleration is not constant, so we have to adjust as we go. + # this is what the vendor software does too. + # if we are already past that position, we skip this part. + if await self.get_position() < final_position: + decel_start_position = await self.get_position() + distance_at_speed + + # then wait until we reach that position + while await self.get_position() < decel_start_position: + await asyncio.sleep(0.1) + + # 5 - send deceleration command + await self._send_command(bytes.fromhex("aa01e60500640000000000fd00803e01000c")) + # aa0194b600000000dc02000029: decel at 80 + # aa0194b6000000000a03000058: decel at 85 + # aa0194b61283000012010000f3: used in setup (30%) + decc = int(9.15 * 100 * deceleration).to_bytes(2, byteorder="little") + decel_command = bytes.fromhex("aa0194b600000000") + decc + bytes.fromhex("0000") + decel_command += ((sum(decel_command) - 0xAA) & 0xFF).to_bytes(1, byteorder="little") + await self._send_command(decel_command) + + await asyncio.sleep(2) + + # 6 - reset position back to 0ish + # this part is aneeded because otherwise calling go_to_position will not work after + async def _reset_to_zero(): + _unilab_logger.debug("[UNILAB] VSpinBackend._reset_to_zero() called") + await self._send_command(bytes.fromhex("aa0117021a")) + await self._send_command(bytes.fromhex("aa01e6c800b00496000f004b00a00f050007")) + await self._send_command(bytes.fromhex("aa0117041c")) + await self._send_command(bytes.fromhex("aa01170119")) + await self._send_command(bytes.fromhex("aa010b0c")) + await self._send_command(bytes.fromhex("aa010001")) # set position back to 0 (exactly) + await self._send_command(bytes.fromhex("aa01e605006400000000003200e80301006e")) + await self._send_command(bytes.fromhex("aa0194b61283000012010000f3")) + await self._send_command(bytes.fromhex("aa01192842")) # it starts moving again + + await _reset_to_zero() + + # 7 - wait for home position to change + # go_to_bucket{1,2} does not work until the home position changes + start = await self.get_home_position() + num_tries = 0 + while await self.get_home_position() == start: + await asyncio.sleep(0.1) + num_tries += 1 + if num_tries % 25 == 0: + await _reset_to_zero() + if num_tries > 100: + raise RuntimeError("Home position did not change after spin.") + def _ensure_access2_backend(self): + backend = getattr(self, "_access2_backend", None) + if backend is None: + backend = Access2Backend(device_id=self._device_id) + self._access2_backend = backend + return backend + + @action(auto_prefix=True, description="发送底层设备命令。") + async def send_command(self, command: bytes) -> bytes: + return await self._ensure_access2_backend().send_command(command) + + @action(auto_prefix=True, description="序列化设备配置或状态。") + def serialize(self): + return self._ensure_access2_backend().serialize() + + @action(auto_prefix=True, description="获取设备状态。") + async def get_status(self) -> bytes: + return await self._ensure_access2_backend().get_status() + + @action(auto_prefix=True, description="执行停放流程。") + async def park(self): + return await self._ensure_access2_backend().park() + + @action(auto_prefix=True, description="执行装载流程。") + async def load(self): + return await self._ensure_access2_backend().load() + + @action(auto_prefix=True, description="执行卸载流程。") + async def unload(self): + return await self._ensure_access2_backend().unload() + + +# Deprecated alias with warning # TODO: remove mid May 2025 (giving people 1 month to update) +# https://github.com/PyLabRobot/pylabrobot/issues/466 + + +class VSpin: + def __init__(self, *args, **kwargs): + raise RuntimeError("`VSpin` is deprecated. Please use `VSpinBackend` instead. ") diff --git a/unilabos/devices/_phage_display/vantage_backend.py b/unilabos/devices/_phage_display/vantage_backend.py new file mode 100644 index 000000000..8e417ac65 --- /dev/null +++ b/unilabos/devices/_phage_display/vantage_backend.py @@ -0,0 +1,5724 @@ +""" +Auto-generated by Build Agent +Original: pylabrobot/liquid_handling/backends/hamilton/vantage_backend.py + +[UNILAB DEBUG] 此驱动已注入调试日志 +- 每个方法被调用时会打印日志 +- 日志格式: [UNILAB] ClassName.method_name() called +- 查看日志: logging.getLogger("unilab.driver").setLevel(logging.DEBUG) +""" +from unilabos.registry.decorators import action, device +from typing import Dict, Any, Optional +import logging +import warnings as _warnings +_warnings.filterwarnings("ignore", message=r"pkg_resources is deprecated as an API\..*") +import sys as _sys +import types as _types + +# 兼容性:部分上游框架(例如 lantz)在 import-time 强制加载 Qt(PyQt4/PySide1), +# 在 headless/现代 Python 环境中通常不存在这些绑定,会导致导入直接失败。 +# 这里预注入一个最小的 `lantz.utils.qt` shim,避免触发其 Qt loader。 +if "lantz.utils.qt" not in _sys.modules: + _qt_mod = _types.ModuleType("lantz.utils.qt") + + class _DummyQtCore: + class QObject: + def __init__(self, *_args, **_kwargs): + # 兼容 QObject(parent=...) 等调用 + return None + + @staticmethod + def Signal(*_args, **_kwargs): + return object() + + @staticmethod + def Slot(*_args, **_kwargs): + def _decorator(fn): + return fn + + return _decorator + + _qt_mod.QtCore = _DummyQtCore + + class _DummySuperQObject(_DummyQtCore.QObject): + def __new__(cls, *_args, **_kwargs): + return super().__new__(cls) + + def __init__(self, *_args, **_kwargs): + # 允许 Driver(...) 传参,不触发 object.__init__ 的 TypeError + return None + + _qt_mod.MetaQObject = type + _qt_mod.SuperQObject = _DummySuperQObject + _sys.modules["lantz.utils.qt"] = _qt_mod + +# Unilab 驱动调试日志 +_unilab_logger = logging.getLogger("unilab.driver.VantageBackend") +_unilab_logger.setLevel(logging.DEBUG) +if not _unilab_logger.handlers: + _handler = logging.StreamHandler() + _handler.setFormatter(logging.Formatter("[%(asctime)s] %(message)s")) + _unilab_logger.addHandler(_handler) + +# Vendored repo packages: pylabrobot, pylabrobot +import sys as _sys +from pathlib import Path as _Path +_vendor_root = _Path(__file__).resolve().parent / "_vendor" +if _vendor_root.exists() and str(_vendor_root) not in _sys.path: + _sys.path.insert(0, str(_vendor_root)) + +import asyncio +import random +import re +import sys +import warnings +from typing import Dict, List, Optional, Sequence, Union, cast + +from pylabrobot.liquid_handling.backends.hamilton.base import ( + HamiltonLiquidHandler, +) +from pylabrobot.liquid_handling.liquid_classes.hamilton import ( + HamiltonLiquidClass, + get_vantage_liquid_class, +) +from pylabrobot.liquid_handling.standard import ( + Drop, + DropTipRack, + MultiHeadAspirationContainer, + MultiHeadAspirationPlate, + MultiHeadDispenseContainer, + MultiHeadDispensePlate, + Pickup, + PickupTipRack, + ResourceDrop, + ResourceMove, + ResourcePickup, + SingleChannelAspiration, + SingleChannelDispense, +) +from pylabrobot.resources import ( + Coordinate, + Deck, + Liquid, + Plate, + Resource, + Tip, + TipRack, + Well, +) +from pylabrobot.resources.hamilton import ( + HamiltonTip, + TipPickupMethod, + TipSize, +) + +if sys.version_info >= (3, 8): + from typing import Literal +else: + from typing_extensions import Literal + + +def parse_vantage_fw_string(s: str, fmt: Optional[Dict[str, str]] = None) -> dict: + """Parse a Vantage firmware string into a dict. + + The identifier parameter (id) is added automatically. + + `fmt` is a dict that specifies the format of the string. The keys are the parameter names and the + values are the types. The following types are supported: + + - `"int"`: a single integer + - `"str"`: a string + - `"[int]"`: a list of integers + - `"hex"`: a hexadecimal number + + Example: + >>> parse_fw_string("id0xs30 -100 +1 1000", {"id": "int", "x": "[int]"}) + {"id": 0, "x": [30, -100, 1, 1000]} + + >>> parse_fw_string("es\"error string\"", {"es": "str"}) + {"es": "error string"} + """ + + parsed: dict = {} + + if fmt is None: + fmt = {} + + if not isinstance(fmt, dict): + raise TypeError(f"invalid fmt for fmt: expected dict, got {type(fmt)}") + + if "id" not in fmt: + fmt["id"] = "int" + + for key, data_type in fmt.items(): + if data_type == "int": + matches = re.findall(rf"{key}([-+]?\d+)", s) + if len(matches) != 1: + raise ValueError(f"Expected exactly one match for {key} in {s}") + parsed[key] = int(matches[0]) + elif data_type == "str": + matches = re.findall(rf"{key}\"(.*)\"", s) + if len(matches) != 1: + raise ValueError(f"Expected exactly one match for {key} in {s}") + parsed[key] = matches[0] + elif data_type == "[int]": + matches = re.findall(rf"{key}((?:[-+]?[\d ]+)+)", s) + if len(matches) != 1: + raise ValueError(f"Expected exactly one match for {key} in {s}") + parsed[key] = [int(x) for x in matches[0].split()] + elif data_type == "hex": + matches = re.findall(rf"{key}([0-9a-fA-F]+)", s) + if len(matches) != 1: + raise ValueError(f"Expected exactly one match for {key} in {s}") + parsed[key] = int(matches[0], 16) + else: + raise ValueError(f"Unknown data type {data_type}") + + return parsed + + +core96_errors = { + 0: "No error", + 21: "No communication to digital potentiometer", + 25: "Wrong Flash EPROM data", + 26: "Flash EPROM not programmable", + 27: "Flash EPROM not erasable", + 28: "Flash EPROM checksum error", + 29: "Wrong FW loaded", + 30: "Undefined command", + 31: "Undefined parameter", + 32: "Parameter out of range", + 35: "Voltages out of range", + 36: "Stop during command execution", + 37: "Adjustment sensor didn't switch (no teach in signal)", + 40: "No parallel processes on level 1 permitted", + 41: "No parallel processes on level 2 permitted", + 42: "No parallel processes on level 3 permitted", + 50: "Dispensing drive initialization failed", + 51: "Dispensing drive not initialized", + 52: "Dispensing drive movement error", + 53: "Maximum volume in tip reached", + 54: "Dispensing drive position out of permitted area", + 55: "Y drive initialization failed", + 56: "Y drive not initialized", + 57: "Y drive movement error", + 58: "Y drive position out of permitted area", + 60: "Z drive initialization failed", + 61: "Z drive not initialized", + 62: "Z drive movement error", + 63: "Z drive position out of permitted area", + 65: "Squeezer drive initialization failed", + 66: "Squeezer drive not initialized", + 67: "Squeezer drive movement error", + 68: "Squeezer drive position out of permitted area", + 70: "No liquid level found", + 71: "Not enough liquid present", + 75: "No tip picked up", + 76: "Tip already picked up", + 81: "Clot detected with LLD sensor", + 82: "TADM measurement out of lower limit curve", + 83: "TADM measurement out of upper limit curve", + 84: "Not enough memory for TADM measurement", + 90: "Limit curve not resettable", + 91: "Limit curve not programmable", + 92: "Limit curve name not found", + 93: "Limit curve data incorrect", + 94: "Not enough memory for limit curve", + 95: "Not allowed limit curve index", + 96: "Limit curve already stored", +} + +pip_errors = { + 22: "Drive controller message error", + 23: "EC drive controller setup not executed", + 25: "wrong Flash EPROM data", + 26: "Flash EPROM not programmable", + 27: "Flash EPROM not erasable", + 28: "Flash EPROM checksum error", + 29: "wrong FW loaded", + 30: "Undefined command", + 31: "Undefined parameter", + 32: "Parameter out of range", + 35: "Voltages out of range", + 36: "Stop during command execution", + 37: "Adjustment sensor didn't switch (no teach in signal)", + 38: "Movement interrupted by partner channel", + 39: "Angle alignment offset error", + 40: "No parallel processes on level 1 permitted", + 41: "No parallel processes on level 2 permitted", + 42: "No parallel processes on level 3 permitted", + 50: "D drive initialization failed", + 51: "D drive not initialized", + 52: "D drive movement error", + 53: "Maximum volume in tip reached", + 54: "D drive position out of permitted area", + 55: "Y drive initialization failed", + 56: "Y drive not initialized", + 57: "Y drive movement error", + 58: "Y drive position out of permitted area", + 59: "Divergance Y motion controller to linear encoder to height", + 60: "Z drive initialization failed", + 61: "Z drive not initialized", + 62: "Z drive movement error", + 63: "Z drive position out of permitted area", + 64: "Limit stop not found", + 65: "S drive initialization failed", + 66: "S drive not initialized", + 67: "S drive movement error", + 68: "S drive position out of permitted area", + 69: "Init. position adjustment error", + 70: "No liquid level found", + 71: "Not enough liquid present", + 74: "Liquid at a not allowed position detected", + 75: "No tip picked up", + 76: "Tip already picked up", + 77: "Tip not discarded", + 78: "Wrong tip detected", + 79: "Tip not correct squeezed", + 80: "Liquid not correctly aspirated", + 81: "Clot detected", + 82: "TADM measurement out of lower limit curve", + 83: "TADM measurement out of upper limit curve", + 84: "Not enough memory for TADM measurement", + 85: "Jet dispense pressure not reached", + 86: "ADC algorithm error", + 90: "Limit curve not resettable", + 91: "Limit curve not programmable", + 92: "Limit curve name not found", + 93: "Limit curve data incorrect", + 94: "Not enough memory for limit curve", + 95: "Not allowed limit curve index", + 96: "Limit curve already stored", +} + +ipg_errors = { + 0: "No error", + 22: "Drive controller message error", + 23: "EC drive controller setup not executed", + 25: "Wrong Flash EPROM data", + 26: "Flash EPROM not programmable", + 27: "Flash EPROM not erasable", + 28: "Flash EPROM checksum error", + 29: "Wrong FW loaded", + 30: "Undefined command", + 31: "Undefined parameter", + 32: "Parameter out of range", + 35: "Voltages out of range", + 36: "Stop during command execution", + 37: "Adjustment sensor didn't switch (no teach in signal)", + 39: "Angle alignment offset error", + 40: "No parallel processes on level 1 permitted", + 41: "No parallel processes on level 2 permitted", + 42: "No parallel processes on level 3 permitted", + 50: "Y Drive initialization failed", + 51: "Y Drive not initialized", + 52: "Y Drive movement error", + 53: "Y Drive position out of permitted area", + 54: "Diff. motion controller and lin. encoder counter too high", + 55: "Z Drive initialization failed", + 56: "Z Drive not initialized", + 57: "Z Drive movement error", + 58: "Z Drive position out of permitted area", + 59: "Z Drive limit stop not found", + 60: "Rotation Drive initialization failed", + 61: "Rotation Drive not initialized", + 62: "Rotation Drive movement error", + 63: "Rotation Drive position out of permitted area", + 65: "Wrist Twist Drive initialization failed", + 66: "Wrist Twist Drive not initialized", + 67: "Wrist Twist Drive movement error", + 68: "Wrist Twist Drive position out of permitted area", + 70: "Gripper Drive initialization failed", + 71: "Gripper Drive not initialized", + 72: "Gripper Drive movement error", + 73: "Gripper Drive position out of permitted area", + 80: "Plate not found", + 81: "Plate is still held", + 82: "No plate is held", +} + + +class VantageFirmwareError(Exception): + def __init__(self, errors, raw_response): + self.errors = errors + self.raw_response = raw_response + + def __str__(self): + return f"VantageFirmwareError(errors={self.errors}, raw_response={self.raw_response})" + + def __eq__(self, __value: object) -> bool: + return ( + isinstance(__value, VantageFirmwareError) + and self.errors == __value.errors + and self.raw_response == __value.raw_response + ) + + +def vantage_response_string_to_error( + string: str, +) -> VantageFirmwareError: + """Convert a Vantage firmware response string to a VantageFirmwareError. Assumes that the + response is an error response.""" + + try: + error_format = r"[A-Z0-9]{2}[0-9]{2}" + error_string = parse_vantage_fw_string(string, {"es": "str"})["es"] + error_codes = re.findall(error_format, error_string) + errors = {} + num_channels = 16 + for error in error_codes: + module, error_code = error[:2], error[2:] + error_code = int(error_code) + for channel in range(1, num_channels + 1): + if module == f"P{channel}": + errors[f"Pipetting channel {channel}"] = pip_errors.get(error_code, "Unknown error") + elif module in ("H0", "HM"): + errors["Core 96"] = core96_errors.get(error_code, "Unknown error") + elif module == "RM": + errors["IPG"] = ipg_errors.get(error_code, "Unknown error") + elif module == "AM": + errors["Cover"] = "Unknown error" + except ValueError: + module_id = string[:4] + module = modules = { + "I1AM": "Cover", + "C0AM": "Master", + "A1PM": "Pip", + "A1HM": "Core 96", + "A1RM": "IPG", + "A1AM": "Arm", + "A1XM": "X-arm", + }.get(module_id, "Unknown module") + error_string = parse_vantage_fw_string(string, {"et": "str"})["et"] + errors = {modules: error_string} + + return VantageFirmwareError(errors, string) + + +def _get_dispense_mode(jet: bool, empty: bool, blow_out: bool) -> Literal[0, 1, 2, 3, 4]: + """from docs: + 0 = part in jet + 1 = blow in jet (called "empty" in VENUS liquid editor) + 2 = Part at surface + 3 = Blow at surface (called "empty" in VENUS liquid editor) + 4 = Empty (truly empty) + """ + + if empty: + return 4 + if jet: + return 1 if blow_out else 0 + else: + return 3 if blow_out else 2 + + +class _MockVantageDeck(Deck): + """虚拟 Deck 占位,未配置真实 Deck 时使用。 + + 继承自 ``pylabrobot.resources.Deck``,提供合法的尺寸与名称,使得 + ``VantageBackend.deck`` 属性的断言不会直接崩溃;但该 Deck 不包含任何板位与 + 资源,所以真正的移液 / 夹爪动作仍会在 ``get_location_wrt`` 等处抛错。 + """ + + def __init__(self): + super().__init__( + size_x=1280.0, + size_y=700.0, + size_z=200.0, + name="mock_vantage_deck", + ) + + +class _MockVantageIO: + """虚拟 USB IO 占位,模拟 ``pylabrobot.io.usb.USB`` 的最小接口。 + + ``setup`` / ``stop`` / ``write`` 为空操作,``read`` 返回空字节串, + ``serialize`` 返回占位配置。用于真实 USB 设备不可达时让 ``VantageBackend`` + 能继续完成 Node 级别的初始化流程。 + """ + + def __init__(self): + self.port = "" + self.serial_number = "" + self.id_vendor = 0 + self.id_product = 0 + + async def setup(self): + return None + + async def stop(self): + return None + + async def write(self, data): + return None + + async def read(self, *args, **kwargs): + return b"" + + def serialize(self) -> dict: + return { + "port": self.port, + "serial_number": self.serial_number, + "id_vendor": self.id_vendor, + "id_product": self.id_product, + "human_readable_device_name": "Mock Hamilton Liquid Handler", + } + + +@device( + id="vantage_backend", + category=["Liquid Handling Workstation"], + description="Hamilton Vantage 自动化移液工作站,用于实验室自动移液、吸头装卸、液体吸取与分配、96通道板级处理、液面探测,以及通过集成夹爪搬运微孔板等实验耗材。", + # 复用 hamilton_vantage 分段近似网格,兼容 1.3 m / 2.0 m VANTAGE 外形。 + model={ + "type": "device", + "mesh": "hamilton_vantage", + "path": "https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/devices/hamilton_vantage/macro_device.xacro", + }, +) +class VantageBackend(HamiltonLiquidHandler): + """A Hamilton Vantage liquid handler.""" + + def __init__( + self, + device_address: Optional[int] = None, + serial_number: Optional[str] = None, + packet_read_timeout: int = 3, + read_timeout: int = 60, + write_timeout: int = 30, + ): + print("[UNILAB] VantageBackend.__init__() called", flush=True) + """Create a new STAR interface. + + Args: + device_address: the USB device address of the Hamilton Vantage. Only useful if using more than + one Hamilton machine over USB. + serial_number: the serial number of the Hamilton Vantage. + packet_read_timeout: timeout in seconds for reading a single packet. + read_timeout: timeout in seconds for reading a full response. + write_timeout: timeout in seconds for writing a command. + num_channels: the number of pipette channels present on the robot. + """ + + super().__init__( + device_address=device_address, + packet_read_timeout=packet_read_timeout, + read_timeout=read_timeout, + write_timeout=write_timeout, + id_product=0x8003, + serial_number=serial_number, + ) + + self._iswap_parked: Optional[bool] = None + self._num_channels: Optional[int] = 8 + self._traversal_height: float = 245.0 + + # —— Deck 兜底:_deck 由 LiquidHandlerBackend.__init__ 初始化为 None, + # 通常由外层 LiquidHandler.setup() 通过 set_deck() 挂载;若一直未被设置, + # 后续访问 self.deck 会直接 assert 失败。为避免 Node 初始化阶段崩溃, + # 在此装配一个 MockVantageDeck 占位,并发出 Warning 提示用户。 + if getattr(self, "_deck", None) is None: + warnings.warn( + "[UNILAB] VantageBackend 未配置 Deck (_deck is None),使用 MockVantageDeck 占位。" + "请在 setup() 前通过 set_deck(deck) 挂载真实 Deck,否则移液/夹爪动作将无法获得正确坐标。", + RuntimeWarning, + stacklevel=2, + ) + self._deck = _MockVantageDeck() + + # —— IO 兜底:若 super().__init__ 未能建立 self.io(异常情况), + # 或后续 setup 阶段 USB 连接失败,均以 MockVantageIO 占位。 + if getattr(self, "io", None) is None: + warnings.warn( + "[UNILAB] VantageBackend 未建立 USB IO (self.io is None),使用 MockVantageIO 占位," + "不会与真实 Vantage 硬件通信。", + RuntimeWarning, + stacklevel=2, + ) + self.io = _MockVantageIO() + + @property + @action(auto_prefix=True, description="获取模块 ID 长度") + def module_id_length(self) -> int: + _unilab_logger.debug("[UNILAB] VantageBackend.module_id_length() called") + return 4 + + @action(auto_prefix=True, description="获取固件响应中的模块 ID") + def get_id_from_fw_response(self, resp: str) -> Optional[int]: + _unilab_logger.debug("[UNILAB] VantageBackend.get_id_from_fw_response() called") + """Get the id from a firmware response.""" + parsed = parse_vantage_fw_string(resp, {"id": "int"}) + if "id" in parsed and parsed["id"] is not None: + return int(parsed["id"]) + return None + + @action(auto_prefix=True, description="检查固件响应是否包含错误") + def check_fw_string_error(self, resp: str): + _unilab_logger.debug("[UNILAB] VantageBackend.check_fw_string_error() called") + """Raise an error if the firmware response is an error response.""" + + if "er" in resp and "er0" not in resp: + error = vantage_response_string_to_error(resp) + raise error + + def _parse_response(self, resp: str, fmt: Dict[str, str]) -> dict: + _unilab_logger.debug("[UNILAB] VantageBackend._parse_response() called") + """Parse a firmware response.""" + return parse_vantage_fw_string(resp, fmt) + + @action(auto_prefix=True, description="连接并设置 Vantage 系统") + async def setup( + self, + skip_loading_cover: bool = False, + skip_core96: bool = False, + skip_ipg: bool = False, + ): + _unilab_logger.debug("[UNILAB] VantageBackend.setup() called") + """Creates a USB connection and finds read/write interfaces.""" + + # 若已经是 Mock IO,直接跳过所有硬件初始化步骤。 + if isinstance(self.io, _MockVantageIO): + warnings.warn( + "[UNILAB] VantageBackend.setup(): 当前运行在 Mock IO 模式,跳过硬件初始化,返回虚拟成功。", + RuntimeWarning, + stacklevel=2, + ) + await self.io.setup() + return + + try: + await super().setup() + except Exception as exc: + # USB 连接失败:切换到 Mock IO,跳过后续硬件初始化。 + warnings.warn( + f"[UNILAB] VantageBackend.setup() 连接真实 USB 设备失败 ({exc!r})," + "切换至 MockVantageIO 并跳过硬件初始化。真实动作调用仍会失败。", + RuntimeWarning, + stacklevel=2, + ) + self.io = _MockVantageIO() + await self.io.setup() + return + + tip_presences = await self.query_tip_presence() + self._num_channels = len(tip_presences) + + arm_initialized = await self.arm_request_instrument_initialization_status() + if not arm_initialized: + await self.arm_pre_initialize() + + # TODO: check which modules are actually installed. + + pip_channels_initialized = await self.pip_request_initialization_status() + if not pip_channels_initialized or any(tip_presences): + await self.pip_initialize( + x_position=[7095] * self.num_channels, + y_position=[3891, 3623, 3355, 3087, 2819, 2551, 2283, 2016], + begin_z_deposit_position=[int(self._traversal_height * 10)] * self.num_channels, + end_z_deposit_position=[1235] * self.num_channels, + minimal_height_at_command_end=[int(self._traversal_height * 10)] * self.num_channels, + tip_pattern=[True] * self.num_channels, + tip_type=[1] * self.num_channels, + TODO_DI_2=70, + ) + + loading_cover_initialized = await self.loading_cover_request_initialization_status() + if not loading_cover_initialized and not skip_loading_cover: + await self.loading_cover_initialize() + + core96_initialized = await self.core96_request_initialization_status() + if not core96_initialized and not skip_core96: + await self.core96_initialize( + x_position=7347, # TODO: get trash location from deck. + y_position=2684, # TODO: get trash location from deck. + minimal_traverse_height_at_begin_of_command=int(self._traversal_height * 10), + minimal_height_at_command_end=int(self._traversal_height * 10), + end_z_deposit_position=2420, + ) + + if not skip_ipg: + ipg_initialized = await self.ipg_request_initialization_status() + if not ipg_initialized: + await self.ipg_initialize() + if not await self.ipg_get_parking_status(): + await self.ipg_park() + + @property + @action(auto_prefix=True, description="获取通道数量") + def num_channels(self) -> int: + _unilab_logger.debug("[UNILAB] VantageBackend.num_channels() called") + """The number of channels on the robot.""" + if self._num_channels is None: + raise RuntimeError("num_channels is not set.") + return self._num_channels + + @action(auto_prefix=True, description="设置最小通过高度") + def set_minimum_traversal_height(self, traversal_height: float): + _unilab_logger.debug("[UNILAB] VantageBackend.set_minimum_traversal_height() called") + """Set the minimum traversal height for the robot. + + This refers to the bottom of the pipetting channel when no tip is present, or the bottom of the + tip when a tip is present. This value will be used as the default value for the + `minimal_traverse_height_at_begin_of_command` and `minimal_height_at_command_end` parameters + unless they are explicitly set. + """ + + assert 0 < traversal_height < 285, "Traversal height must be between 0 and 285 mm" + + self._traversal_height = traversal_height + + # ============== LiquidHandlerBackend methods ============== + + @action(auto_prefix=True, description="使用选定通道拾取吸头") + async def pick_up_tips( + self, + ops: List[Pickup], + use_channels: List[int], + minimal_traverse_height_at_begin_of_command: Optional[List[float]] = None, + minimal_height_at_command_end: Optional[List[float]] = None, + ): + _unilab_logger.debug("[UNILAB] VantageBackend.pick_up_tips() called") + x_positions, y_positions, tip_pattern = self._ops_to_fw_positions(ops, use_channels) + + tips = [cast(HamiltonTip, op.resource.get_tip()) for op in ops] + ttti = [await self.get_or_assign_tip_type_index(tip) for tip in tips] + + max_z = max(op.resource.get_location_wrt(self.deck).z + op.offset.z for op in ops) + max_total_tip_length = max(op.tip.total_tip_length for op in ops) + max_tip_length = max((op.tip.total_tip_length - op.tip.fitting_depth) for op in ops) + + # not sure why this is necessary, but it is according to log files and experiments + if self._get_hamilton_tip([op.resource for op in ops]).tip_size == TipSize.LOW_VOLUME: + max_tip_length += 2 + elif self._get_hamilton_tip([op.resource for op in ops]).tip_size != TipSize.STANDARD_VOLUME: + max_tip_length -= 2 + + try: + return await self.pip_tip_pick_up( + x_position=x_positions, + y_position=y_positions, + tip_pattern=tip_pattern, + tip_type=ttti, + begin_z_deposit_position=[round((max_z + max_total_tip_length) * 10)] * len(ops), + end_z_deposit_position=[round((max_z + max_tip_length) * 10)] * len(ops), + minimal_traverse_height_at_begin_of_command=[ + round(th * 10) + for th in minimal_traverse_height_at_begin_of_command or [self._traversal_height] + ] + * len(ops), + minimal_height_at_command_end=[ + round(th * 10) for th in minimal_height_at_command_end or [self._traversal_height] + ] + * len(ops), + tip_handling_method=[1 for _ in tips], # always appears to be 1 # tip.pickup_method.value + blow_out_air_volume=[0] * len(ops), # Why is this here? Who knows. + ) + except Exception as e: + raise e + + # @need_iswap_parked + @action(auto_prefix=True, description="丢弃吸头到目标资源") + async def drop_tips( + self, + ops: List[Drop], + use_channels: List[int], + minimal_traverse_height_at_begin_of_command: Optional[List[float]] = None, + minimal_height_at_command_end: Optional[List[float]] = None, + ): + _unilab_logger.debug("[UNILAB] VantageBackend.drop_tips() called") + """Drop tips to a resource.""" + + x_positions, y_positions, channels_involved = self._ops_to_fw_positions(ops, use_channels) + + max_z = max(op.resource.get_location_wrt(self.deck).z + op.offset.z for op in ops) + + try: + return await self.pip_tip_discard( + x_position=x_positions, + y_position=y_positions, + tip_pattern=channels_involved, + begin_z_deposit_position=[round((max_z + 10) * 10)] * len(ops), # +10 + end_z_deposit_position=[round(max_z * 10)] * len(ops), + minimal_traverse_height_at_begin_of_command=[ + round(th * 10) + for th in minimal_traverse_height_at_begin_of_command or [self._traversal_height] + ] + * len(ops), + minimal_height_at_command_end=[ + round(th * 10) for th in minimal_height_at_command_end or [self._traversal_height] + ] + * len(ops), + tip_handling_method=[0 for _ in ops], # Always appears to be 0, even in trash. + # tip_handling_method=[TipDropMethod.DROP.value if isinstance(op.resource, TipSpot) \ + # else TipDropMethod.PLACE_SHIFT.value for op in ops], + TODO_TR_2=0, + ) + except Exception as e: + raise e + + def _assert_valid_resources(self, resources: Sequence[Resource]) -> None: + _unilab_logger.debug("[UNILAB] VantageBackend._assert_valid_resources() called") + """Assert that resources are in a valid location for pipetting.""" + for resource in resources: + if resource.get_location_wrt(self.deck).z < 100: + raise ValueError( + f"Resource {resource} is too low: {resource.get_location_wrt(self.deck).z} < 100" + ) + + @action(auto_prefix=True, description="使用移液通道吸液") + async def aspirate( + self, + ops: List[SingleChannelAspiration], + use_channels: List[int], + jet: Optional[List[bool]] = None, + blow_out: Optional[List[bool]] = None, + hlcs: Optional[List[Optional[HamiltonLiquidClass]]] = None, + type_of_aspiration: Optional[List[int]] = None, + minimal_traverse_height_at_begin_of_command: Optional[List[float]] = None, + minimal_height_at_command_end: Optional[List[float]] = None, + lld_search_height: Optional[List[float]] = None, + clot_detection_height: Optional[List[float]] = None, + liquid_surface_at_function_without_lld: Optional[List[float]] = None, + pull_out_distance_to_take_transport_air_in_function_without_lld: Optional[List[float]] = None, + tube_2nd_section_height_measured_from_zm: Optional[List[float]] = None, + tube_2nd_section_ratio: Optional[List[float]] = None, + minimum_height: Optional[List[float]] = None, + immersion_depth: Optional[List[float]] = None, + surface_following_distance: Optional[List[float]] = None, + transport_air_volume: Optional[List[float]] = None, + pre_wetting_volume: Optional[List[float]] = None, + lld_mode: Optional[List[int]] = None, + lld_sensitivity: Optional[List[int]] = None, + pressure_lld_sensitivity: Optional[List[int]] = None, + aspirate_position_above_z_touch_off: Optional[List[float]] = None, + swap_speed: Optional[List[float]] = None, + settling_time: Optional[List[float]] = None, + mix_volume: Optional[List[float]] = None, + mix_cycles: Optional[List[int]] = None, + mix_position_in_z_direction_from_liquid_surface: Optional[List[float]] = None, + mix_speed: Optional[List[float]] = None, + surface_following_distance_during_mixing: Optional[List[float]] = None, + TODO_DA_5: Optional[List[int]] = None, + capacitive_mad_supervision_on_off: Optional[List[int]] = None, + pressure_mad_supervision_on_off: Optional[List[int]] = None, + tadm_algorithm_on_off: int = 0, + limit_curve_index: Optional[List[int]] = None, + recording_mode: int = 0, + disable_volume_correction: Optional[List[bool]] = None, + ): + _unilab_logger.debug("[UNILAB] VantageBackend.aspirate() called") + """Aspirate from (a) resource(s). + + See :meth:`pip_aspirate` (the firmware command) for parameter documentation. This method serves + as a wrapper for that command, and will convert operations into the appropriate format. This + method additionally provides default values based on firmware instructions sent by Venus on + Vantage, rather than machine default values (which are often not what you want). + + Args: + ops: The aspiration operations. + use_channels: The channels to use. + blow_out: Whether to search for a "blow out" liquid class. This is only used on dispense. + Note that in the VENUS liquid editor, the term "empty" is used for this, but in the firmware + documentation, "empty" is used for a different mode (dm4). + hlcs: The Hamiltonian liquid classes to use. If `None`, the liquid classes will be + determined automatically based on the tip and liquid used. + disable_volume_correction: Whether to disable volume correction for each operation. + """ + + if mix_volume is not None or mix_cycles is not None or mix_speed is not None: + raise NotImplementedError( + "Mixing through backend kwargs is deprecated. Use the `mix` parameter of LiquidHandler.dispense instead. " + "https://docs.pylabrobot.org/user_guide/00_liquid-handling/mixing.html" + ) + + x_positions, y_positions, channels_involved = self._ops_to_fw_positions(ops, use_channels) + + if jet is None: + jet = [False] * len(ops) + if blow_out is None: + blow_out = [False] * len(ops) + + if hlcs is None: + hlcs = [] + for j, bo, op in zip(jet, blow_out, ops): + hlcs.append( + get_vantage_liquid_class( + tip_volume=op.tip.maximal_volume, + is_core=False, + is_tip=True, + has_filter=op.tip.has_filter, + liquid=Liquid.WATER, # default to WATER + jet=j, + blow_out=bo, + ) + ) + + self._assert_valid_resources([op.resource for op in ops]) + + # correct volumes using the liquid class if not disabled + disable_volume_correction = disable_volume_correction or [False] * len(ops) + volumes = [ + hlc.compute_corrected_volume(op.volume) if hlc is not None and not disabled else op.volume + for op, hlc, disabled in zip(ops, hlcs, disable_volume_correction) + ] + + well_bottoms = [ + op.resource.get_location_wrt(self.deck).z + op.offset.z + op.resource.material_z_thickness + for op in ops + ] + liquid_surfaces_no_lld = liquid_surface_at_function_without_lld or [ + wb + (op.liquid_height or 0) for wb, op in zip(well_bottoms, ops) + ] + # -1 compared to STAR? + lld_search_heights = lld_search_height or [ + wb + + op.resource.get_absolute_size_z() + + (2.7 - 1 if isinstance(op.resource, Well) else 5) # ? + for wb, op in zip(well_bottoms, ops) + ] + + flow_rates = [ + op.flow_rate or (hlc.aspiration_flow_rate if hlc is not None else 100) + for op, hlc in zip(ops, hlcs) + ] + blow_out_air_volumes = [ + (op.blow_out_air_volume or (hlc.dispense_blow_out_volume if hlc is not None else 0)) + for op, hlc in zip(ops, hlcs) + ] + + return await self.pip_aspirate( + x_position=x_positions, + y_position=y_positions, + type_of_aspiration=type_of_aspiration or [0] * len(ops), + tip_pattern=channels_involved, + minimal_traverse_height_at_begin_of_command=[ + round(th * 10) + for th in minimal_traverse_height_at_begin_of_command or [self._traversal_height] + ] + * len(ops), + minimal_height_at_command_end=[ + round(th * 10) for th in minimal_height_at_command_end or [self._traversal_height] + ] + * len(ops), + lld_search_height=[round(ls * 10) for ls in lld_search_heights], + clot_detection_height=[round(cdh * 10) for cdh in clot_detection_height or [0] * len(ops)], + liquid_surface_at_function_without_lld=[round(lsn * 10) for lsn in liquid_surfaces_no_lld], + pull_out_distance_to_take_transport_air_in_function_without_lld=[ + round(pod * 10) + for pod in pull_out_distance_to_take_transport_air_in_function_without_lld + or [10.9] * len(ops) + ], + tube_2nd_section_height_measured_from_zm=[ + round(t2sh * 10) for t2sh in tube_2nd_section_height_measured_from_zm or [0] * len(ops) + ], + tube_2nd_section_ratio=[ + round(t2sr * 10) for t2sr in tube_2nd_section_ratio or [0] * len(ops) + ], + minimum_height=[round(wb * 10) for wb in minimum_height or well_bottoms], + immersion_depth=[round(id_ * 10) for id_ in immersion_depth or [0] * len(ops)], + surface_following_distance=[ + round(sfd * 10) for sfd in surface_following_distance or [0] * len(ops) + ], + aspiration_volume=[round(vol * 100) for vol in volumes], + aspiration_speed=[round(fr * 10) for fr in flow_rates], + transport_air_volume=[ + round(tav * 10) + for tav in transport_air_volume + or [hlc.aspiration_air_transport_volume if hlc is not None else 0 for hlc in hlcs] + ], + blow_out_air_volume=[round(bav * 100) for bav in blow_out_air_volumes], + pre_wetting_volume=[round(pwv * 100) for pwv in pre_wetting_volume or [0] * len(ops)], + lld_mode=lld_mode or [0] * len(ops), + lld_sensitivity=lld_sensitivity or [4] * len(ops), + pressure_lld_sensitivity=pressure_lld_sensitivity or [4] * len(ops), + aspirate_position_above_z_touch_off=[ + round(apz * 10) for apz in aspirate_position_above_z_touch_off or [0.5] * len(ops) + ], + swap_speed=[round(ss * 10) for ss in swap_speed or [2] * len(ops)], + settling_time=[round(st * 10) for st in settling_time or [1] * len(ops)], + mix_volume=[round(op.mix.volume * 100) if op.mix is not None else 0 for op in ops], + mix_cycles=[op.mix.repetitions if op.mix is not None else 0 for op in ops], + mix_position_in_z_direction_from_liquid_surface=[ + round(mp) for mp in mix_position_in_z_direction_from_liquid_surface or [0] * len(ops) + ], + mix_speed=[round(op.mix.flow_rate * 10) if op.mix is not None else 2500 for op in ops], + surface_following_distance_during_mixing=[ + round(sfdm * 10) for sfdm in surface_following_distance_during_mixing or [0] * len(ops) + ], + TODO_DA_5=TODO_DA_5 or [0] * len(ops), + capacitive_mad_supervision_on_off=capacitive_mad_supervision_on_off or [0] * len(ops), + pressure_mad_supervision_on_off=pressure_mad_supervision_on_off or [0] * len(ops), + tadm_algorithm_on_off=tadm_algorithm_on_off or 0, + limit_curve_index=limit_curve_index or [0] * len(ops), + recording_mode=recording_mode or 0, + ) + + @action(auto_prefix=True, description="使用移液通道分液") + async def dispense( + self, + ops: List[SingleChannelDispense], + use_channels: List[int], + jet: Optional[List[bool]] = None, + blow_out: Optional[List[bool]] = None, # "empty" in the VENUS liquid editor + empty: Optional[List[bool]] = None, # truly "empty", does not exist in liquid editor, dm4 + hlcs: Optional[List[Optional[HamiltonLiquidClass]]] = None, + type_of_dispensing_mode: Optional[List[int]] = None, + minimum_height: Optional[List[float]] = None, + pull_out_distance_to_take_transport_air_in_function_without_lld: Optional[List[float]] = None, + immersion_depth: Optional[List[float]] = None, + surface_following_distance: Optional[List[float]] = None, + tube_2nd_section_height_measured_from_zm: Optional[List[float]] = None, + tube_2nd_section_ratio: Optional[List[float]] = None, + minimal_traverse_height_at_begin_of_command: Optional[List[float]] = None, + minimal_height_at_command_end: Optional[List[float]] = None, + lld_search_height: Optional[List[float]] = None, + cut_off_speed: Optional[List[float]] = None, + stop_back_volume: Optional[List[float]] = None, + transport_air_volume: Optional[List[float]] = None, + lld_mode: Optional[List[int]] = None, + side_touch_off_distance: float = 0, + dispense_position_above_z_touch_off: Optional[List[float]] = None, + lld_sensitivity: Optional[List[int]] = None, + pressure_lld_sensitivity: Optional[List[int]] = None, + swap_speed: Optional[List[float]] = None, + settling_time: Optional[List[float]] = None, + mix_volume: Optional[List[float]] = None, + mix_cycles: Optional[List[int]] = None, + mix_position_in_z_direction_from_liquid_surface: Optional[List[float]] = None, + mix_speed: Optional[List[float]] = None, + surface_following_distance_during_mixing: Optional[List[float]] = None, + TODO_DD_2: Optional[List[int]] = None, + tadm_algorithm_on_off: int = 0, + limit_curve_index: Optional[List[int]] = None, + recording_mode: int = 0, + disable_volume_correction: Optional[List[bool]] = None, + ): + _unilab_logger.debug("[UNILAB] VantageBackend.dispense() called") + """Dispense to (a) resource(s). + + See :meth:`pip_dispense` (the firmware command) for parameter documentation. This method serves + as a wrapper for that command, and will convert operations into the appropriate format. This + method additionally provides default values based on firmware instructions sent by Venus on + Vantage, rather than machine default values (which are often not what you want). + + Args: + ops: The aspiration operations. + use_channels: The channels to use. + hlcs: The Hamiltonian liquid classes to use. If `None`, the liquid classes will be + determined automatically based on the tip and liquid used. + + jet: Whether to use jetting for each dispense. Defaults to `False` for all. Used for + determining the dispense mode. True for dispense mode 0 or 1. + blow_out: Whether to use "blow out" dispense mode for each dispense. Defaults to `False` for + all. This is labelled as "empty" in the VENUS liquid editor, but "blow out" in the firmware + documentation. True for dispense mode 1 or 3. + empty: Whether to use "empty" dispense mode for each dispense. Defaults to `False` for all. + Truly empty the tip, not available in the VENUS liquid editor, but is in the firmware + documentation. Dispense mode 4. + disable_volume_correction: Whether to disable volume correction for each operation. + """ + + if mix_volume is not None or mix_cycles is not None or mix_speed is not None: + raise NotImplementedError( + "Mixing through backend kwargs is deprecated. Use the `mix` parameter of LiquidHandler.dispense instead. " + "https://docs.pylabrobot.org/user_guide/00_liquid-handling/mixing.html" + ) + + x_positions, y_positions, channels_involved = self._ops_to_fw_positions(ops, use_channels) + + if jet is None: + jet = [False] * len(ops) + if empty is None: + empty = [False] * len(ops) + if blow_out is None: + blow_out = [False] * len(ops) + + if hlcs is None: + hlcs = [] + for j, bo, op in zip(jet, blow_out, ops): + hlcs.append( + get_vantage_liquid_class( + tip_volume=op.tip.maximal_volume, + is_core=False, + is_tip=True, + has_filter=op.tip.has_filter, + liquid=Liquid.WATER, # default to WATER + jet=j, + blow_out=bo, + ) + ) + + self._assert_valid_resources([op.resource for op in ops]) + + # correct volumes using the liquid class + disable_volume_correction = disable_volume_correction or [False] * len(ops) + volumes = [ + hlc.compute_corrected_volume(op.volume) if hlc is not None and not disabled else op.volume + for op, hlc, disabled in zip(ops, hlcs, disable_volume_correction) + ] + + well_bottoms = [ + op.resource.get_location_wrt(self.deck).z + op.offset.z + op.resource.material_z_thickness + for op in ops + ] + liquid_surfaces_no_lld = [wb + (op.liquid_height or 0) for wb, op in zip(well_bottoms, ops)] + # -1 compared to STAR? + lld_search_heights = lld_search_height or [ + wb + + op.resource.get_absolute_size_z() + + (2.7 - 1 if isinstance(op.resource, Well) else 5) # ? + for wb, op in zip(well_bottoms, ops) + ] + + flow_rates = [ + op.flow_rate or (hlc.dispense_flow_rate if hlc is not None else 100) + for op, hlc in zip(ops, hlcs) + ] + + blow_out_air_volumes = [ + (op.blow_out_air_volume or (hlc.dispense_blow_out_volume if hlc is not None else 0)) + for op, hlc in zip(ops, hlcs) + ] + + type_of_dispensing_mode = type_of_dispensing_mode or [ + _get_dispense_mode(jet=jet[i], empty=empty[i], blow_out=blow_out[i]) for i in range(len(ops)) + ] + + return await self.pip_dispense( + x_position=x_positions, + y_position=y_positions, + tip_pattern=channels_involved, + type_of_dispensing_mode=type_of_dispensing_mode, + minimum_height=[round(wb * 10) for wb in minimum_height or well_bottoms], + lld_search_height=[round(sh * 10) for sh in lld_search_heights], + liquid_surface_at_function_without_lld=[round(ls * 10) for ls in liquid_surfaces_no_lld], + pull_out_distance_to_take_transport_air_in_function_without_lld=[ + round(pod * 10) + for pod in pull_out_distance_to_take_transport_air_in_function_without_lld + or [5.0] * len(ops) + ], + immersion_depth=[round(id * 10) for id in immersion_depth or [0] * len(ops)], + surface_following_distance=[ + round(sfd * 10) for sfd in surface_following_distance or [2.1] * len(ops) + ], + tube_2nd_section_height_measured_from_zm=[ + round(t2sh * 10) for t2sh in tube_2nd_section_height_measured_from_zm or [0] * len(ops) + ], + tube_2nd_section_ratio=[ + round(t2sr * 10) for t2sr in tube_2nd_section_ratio or [0] * len(ops) + ], + minimal_traverse_height_at_begin_of_command=[ + round(mth * 10) + for mth in minimal_traverse_height_at_begin_of_command + or [self._traversal_height] * len(ops) + ], + minimal_height_at_command_end=[ + round(mh * 10) + for mh in minimal_height_at_command_end or [self._traversal_height] * len(ops) + ], + dispense_volume=[round(vol * 100) for vol in volumes], + dispense_speed=[round(fr * 10) for fr in flow_rates], + cut_off_speed=[round(cs * 10) for cs in cut_off_speed or [250] * len(ops)], + stop_back_volume=[round(sbv * 100) for sbv in stop_back_volume or [0] * len(ops)], + transport_air_volume=[ + round(tav * 10) + for tav in transport_air_volume + or [hlc.dispense_air_transport_volume if hlc is not None else 0 for hlc in hlcs] + ], + blow_out_air_volume=[round(boav * 100) for boav in blow_out_air_volumes], + lld_mode=lld_mode or [0] * len(ops), + side_touch_off_distance=round(side_touch_off_distance * 10), + dispense_position_above_z_touch_off=[ + round(dpz * 10) for dpz in dispense_position_above_z_touch_off or [0.5] * len(ops) + ], + lld_sensitivity=lld_sensitivity or [1] * len(ops), + pressure_lld_sensitivity=pressure_lld_sensitivity or [1] * len(ops), + swap_speed=[round(ss * 10) for ss in swap_speed or [1] * len(ops)], + settling_time=[round(st * 10) for st in settling_time or [0] * len(ops)], + mix_volume=[round(op.mix.volume * 100) if op.mix is not None else 0 for op in ops], + mix_cycles=[op.mix.repetitions if op.mix is not None else 0 for op in ops], + mix_position_in_z_direction_from_liquid_surface=[ + round(mp) for mp in mix_position_in_z_direction_from_liquid_surface or [0] * len(ops) + ], + mix_speed=[round(op.mix.flow_rate * 100) if op.mix is not None else 10 for op in ops], + surface_following_distance_during_mixing=[ + round(sfdm * 10) for sfdm in surface_following_distance_during_mixing or [0] * len(ops) + ], + TODO_DD_2=TODO_DD_2 or [0] * len(ops), + tadm_algorithm_on_off=tadm_algorithm_on_off or 0, + limit_curve_index=limit_curve_index or [0] * len(ops), + recording_mode=recording_mode or 0, + ) + + @action(auto_prefix=True, description="使用96头拾取吸头") + async def pick_up_tips96( + self, + pickup: PickupTipRack, + tip_handling_method: int = 0, + z_deposit_position: float = 216.4, + minimal_traverse_height_at_begin_of_command: Optional[float] = None, + minimal_height_at_command_end: Optional[float] = None, + ): + _unilab_logger.debug("[UNILAB] VantageBackend.pick_up_tips96() called") + # assert self.core96_head_installed, "96 head must be installed" + tip_spot_a1 = pickup.resource.get_item("A1") + prototypical_tip = None + for tip_spot in pickup.resource.get_all_items(): + if tip_spot.has_tip(): + prototypical_tip = tip_spot.get_tip() + break + if prototypical_tip is None: + raise ValueError("No tips found in the tip rack.") + assert isinstance(prototypical_tip, HamiltonTip), "Tip type must be HamiltonTip." + ttti = await self.get_or_assign_tip_type_index(prototypical_tip) + position = tip_spot_a1.get_location_wrt(self.deck) + tip_spot_a1.center() + pickup.offset + offset_z = pickup.offset.z + + return await self.core96_tip_pick_up( + x_position=round(position.x * 10), + y_position=round(position.y * 10), + tip_type=ttti, + tip_handling_method=tip_handling_method, + z_deposit_position=round((z_deposit_position + offset_z) * 10), + minimal_traverse_height_at_begin_of_command=round( + (minimal_traverse_height_at_begin_of_command or self._traversal_height) * 10 + ), + minimal_height_at_command_end=round( + (minimal_height_at_command_end or self._traversal_height) * 10 + ), + ) + + @action(auto_prefix=True, description="使用96头弃去吸头") + async def drop_tips96( + self, + drop: DropTipRack, + z_deposit_position: float = 216.4, + minimal_traverse_height_at_begin_of_command: Optional[float] = None, + minimal_height_at_command_end: Optional[float] = None, + ): + _unilab_logger.debug("[UNILAB] VantageBackend.drop_tips96() called") + # assert self.core96_head_installed, "96 head must be installed" + if isinstance(drop.resource, TipRack): + tip_spot_a1 = drop.resource.get_item("A1") + position = tip_spot_a1.get_location_wrt(self.deck) + tip_spot_a1.center() + drop.offset + else: + raise NotImplementedError( + "Only TipRacks are supported for dropping tips on Vantage", + f"got {drop.resource}", + ) + offset_z = drop.offset.z + + return await self.core96_tip_discard( + x_position=round(position.x * 10), + y_position=round(position.y * 10), + z_deposit_position=round((z_deposit_position + offset_z) * 10), + minimal_traverse_height_at_begin_of_command=round( + (minimal_traverse_height_at_begin_of_command or self._traversal_height) * 10 + ), + minimal_height_at_command_end=round( + (minimal_height_at_command_end or self._traversal_height) * 10 + ), + ) + + @action(auto_prefix=True, description="使用96头从板中吸液") + async def aspirate96( + self, + aspiration: Union[MultiHeadAspirationPlate, MultiHeadAspirationContainer], + jet: bool = False, + blow_out: bool = False, + hlc: Optional[HamiltonLiquidClass] = None, + type_of_aspiration: int = 0, + minimal_traverse_height_at_begin_of_command: Optional[float] = None, + minimal_height_at_command_end: Optional[float] = None, + pull_out_distance_to_take_transport_air_in_function_without_lld: float = 5, + tube_2nd_section_height_measured_from_zm: float = 0, + tube_2nd_section_ratio: float = 0, + immersion_depth: float = 0, + surface_following_distance: float = 0, + transport_air_volume: Optional[float] = None, + blow_out_air_volume: Optional[float] = None, + pre_wetting_volume: float = 0, + lld_mode: int = 0, + lld_sensitivity: int = 4, + swap_speed: Optional[float] = None, + settling_time: Optional[float] = None, + mix_volume: float = 0, + mix_cycles: int = 0, + mix_position_in_z_direction_from_liquid_surface: float = 0, + surface_following_distance_during_mixing: float = 0, + mix_speed: float = 0, + limit_curve_index: int = 0, + tadm_channel_pattern: Optional[List[bool]] = None, + tadm_algorithm_on_off: int = 0, + recording_mode: int = 0, + disable_volume_correction: bool = False, + ): + _unilab_logger.debug("[UNILAB] VantageBackend.aspirate96() called") + """Aspirate from a plate. + + Args: + jet: Whether to find a liquid class with "jet" mode. Only used on dispense. + blow_out: Whether to find a liquid class with "blow out" mode. Only used on dispense. Note + that this is called "empty" in the VENUS liquid editor, but "blow out" in the firmware + documentation. + hlc: The Hamiltonian liquid classes to use. If `None`, the liquid classes will be + determined automatically based on the tip and liquid used in the first well. + disable_volume_correction: Whether to disable volume correction. + """ + # assert self.core96_head_installed, "96 head must be installed" + + if mix_volume != 0 or mix_cycles != 0 or mix_speed != 0: + raise NotImplementedError( + "Mixing through backend kwargs is deprecated. Use the `mix` parameter of LiquidHandler.dispense96 instead. " + "https://docs.pylabrobot.org/user_guide/00_liquid-handling/mixing.html" + ) + + if isinstance(aspiration, MultiHeadAspirationPlate): + plate = aspiration.wells[0].parent + assert isinstance(plate, Plate), "MultiHeadAspirationPlate well parent must be a Plate" + rot = plate.get_absolute_rotation() + if rot.x % 360 != 0 or rot.y % 360 != 0: + raise ValueError("Plate rotation around x or y is not supported for 96 head operations") + if rot.z % 360 == 180: + ref_well = plate.get_well("H12") + elif rot.z % 360 == 0: + ref_well = plate.get_well("A1") + else: + raise ValueError("96 head only supports plate rotations of 0 or 180 degrees around z") + position = ( + ref_well.get_location_wrt(self.deck) + + ref_well.center() + + aspiration.offset + + Coordinate(z=ref_well.material_z_thickness) + ) + # -1 compared to STAR? + well_bottoms = position.z + lld_search_height = well_bottoms + ref_well.get_absolute_size_z() + 2.7 - 1 + else: + x_width = (12 - 1) * 9 # 12 tips in a row, 9 mm between them + y_width = (8 - 1) * 9 # 8 tips in a column, 9 mm between them + x_position = (aspiration.container.get_absolute_size_x() - x_width) / 2 + y_position = (aspiration.container.get_absolute_size_y() - y_width) / 2 + y_width + position = ( + aspiration.container.get_location_wrt(self.deck, z="cavity_bottom") + + Coordinate(x=x_position, y=y_position) + + aspiration.offset + ) + bottom = position.z + lld_search_height = bottom + aspiration.container.get_absolute_size_z() + 2.7 - 1 + + liquid_height = position.z + (aspiration.liquid_height or 0) + + tip = next(tip for tip in aspiration.tips if tip is not None) + if hlc is None: + hlc = get_vantage_liquid_class( + tip_volume=tip.maximal_volume, + is_core=True, + is_tip=True, + has_filter=tip.has_filter, + liquid=Liquid.WATER, # default to WATER + jet=jet, + blow_out=blow_out, + ) + + if disable_volume_correction or hlc is None: + volume = aspiration.volume + else: # hlc is not None and not disable_volume_correction + volume = hlc.compute_corrected_volume(aspiration.volume) + + transport_air_volume = transport_air_volume or ( + hlc.aspiration_air_transport_volume if hlc is not None else 0 + ) + blow_out_air_volume = blow_out_air_volume or ( + hlc.aspiration_blow_out_volume if hlc is not None else 0 + ) + flow_rate = aspiration.flow_rate or (hlc.aspiration_flow_rate if hlc is not None else 250) + swap_speed = swap_speed or (hlc.aspiration_swap_speed if hlc is not None else 100) + settling_time = settling_time or (hlc.aspiration_settling_time if hlc is not None else 5) + + return await self.core96_aspiration_of_liquid( + x_position=round(position.x * 10), + y_position=round(position.y * 10), + type_of_aspiration=type_of_aspiration, + minimal_traverse_height_at_begin_of_command=round( + (minimal_traverse_height_at_begin_of_command or self._traversal_height) * 10 + ), + minimal_height_at_command_end=round( + minimal_height_at_command_end or self._traversal_height * 10 + ), + lld_search_height=round(lld_search_height * 10), + liquid_surface_at_function_without_lld=round(liquid_height * 10), + pull_out_distance_to_take_transport_air_in_function_without_lld=round( + pull_out_distance_to_take_transport_air_in_function_without_lld * 10 + ), + minimum_height=round(well_bottoms * 10), + tube_2nd_section_height_measured_from_zm=round(tube_2nd_section_height_measured_from_zm * 10), + tube_2nd_section_ratio=round(tube_2nd_section_ratio * 10), + immersion_depth=round(immersion_depth * 10), + surface_following_distance=round(surface_following_distance * 10), + aspiration_volume=round(volume * 100), + aspiration_speed=round(flow_rate * 10), + transport_air_volume=round(transport_air_volume * 10), + blow_out_air_volume=round(blow_out_air_volume * 100), + pre_wetting_volume=round(pre_wetting_volume * 100), + lld_mode=lld_mode, + lld_sensitivity=lld_sensitivity, + swap_speed=round(swap_speed * 10), + settling_time=round(settling_time * 10), + mix_volume=round(aspiration.mix.volume * 100) if aspiration.mix is not None else 0, + mix_cycles=aspiration.mix.repetitions if aspiration.mix is not None else 0, + mix_position_in_z_direction_from_liquid_surface=round( + mix_position_in_z_direction_from_liquid_surface * 100 + ), + surface_following_distance_during_mixing=round( + surface_following_distance_during_mixing * 100 + ), + mix_speed=round(aspiration.mix.flow_rate * 10) if aspiration.mix is not None else 20, + limit_curve_index=limit_curve_index, + tadm_channel_pattern=tadm_channel_pattern, + tadm_algorithm_on_off=tadm_algorithm_on_off, + recording_mode=recording_mode, + ) + + @action(auto_prefix=True, description="使用96头向板中分液") + async def dispense96( + self, + dispense: Union[MultiHeadDispensePlate, MultiHeadDispenseContainer], + jet: bool = False, + blow_out: bool = False, # "empty" in the VENUS liquid editor + empty: bool = False, # truly "empty", does not exist in liquid editor, dm4 + hlc: Optional[HamiltonLiquidClass] = None, + type_of_dispensing_mode: Optional[int] = None, + tube_2nd_section_height_measured_from_zm: float = 0, + tube_2nd_section_ratio: float = 0, + pull_out_distance_to_take_transport_air_in_function_without_lld: float = 5.0, + immersion_depth: float = 0, + surface_following_distance: float = 2.9, + minimal_traverse_height_at_begin_of_command: Optional[float] = None, + minimal_height_at_command_end: Optional[float] = None, + cut_off_speed: float = 250.0, + stop_back_volume: float = 0, + transport_air_volume: Optional[float] = None, + blow_out_air_volume: Optional[float] = None, + lld_mode: int = 0, + lld_sensitivity: int = 4, + side_touch_off_distance: float = 0, + swap_speed: Optional[float] = None, + settling_time: Optional[float] = None, + mix_volume: float = 0, + mix_cycles: int = 0, + mix_position_in_z_direction_from_liquid_surface: float = 0, + surface_following_distance_during_mixing: float = 0, + mix_speed: Optional[float] = None, + limit_curve_index: int = 0, + tadm_channel_pattern: Optional[List[bool]] = None, + tadm_algorithm_on_off: int = 0, + recording_mode: int = 0, + disable_volume_correction: bool = False, + ): + _unilab_logger.debug("[UNILAB] VantageBackend.dispense96() called") + """Dispense to a plate using the 96 head. + + Args: + jet: whether to dispense in jet mode. + blow_out: whether to dispense in jet mode. In the VENUS liquid editor, this is called "empty". + Dispensing mode 1 or 3. + empty: whether to truly empty the tip. This does not exist in the liquid editor, but is in the + firmware documentation. Dispense mode 4. + liquid_class: the liquid class to use. If not provided, it will be determined based on the + liquid in the first well. + + type_of_dispensing_mode: the type of dispense mode to use. If not provided, it will be + determined based on the jet, blow_out, and empty parameters. + disable_volume_correction: Whether to disable volume correction. + """ + + if mix_volume != 0 or mix_cycles != 0 or mix_speed is not None: + raise NotImplementedError( + "Mixing through backend kwargs is deprecated. Use the `mix` parameter of LiquidHandler.dispense96 instead. " + "https://docs.pylabrobot.org/user_guide/00_liquid-handling/mixing.html" + ) + + if isinstance(dispense, MultiHeadDispensePlate): + plate = dispense.wells[0].parent + assert isinstance(plate, Plate), "MultiHeadDispensePlate well parent must be a Plate" + rot = plate.get_absolute_rotation() + if rot.x % 360 != 0 or rot.y % 360 != 0: + raise ValueError("Plate rotation around x or y is not supported for 96 head operations") + if rot.z % 360 == 180: + ref_well = plate.get_well("H12") + elif rot.z % 360 == 0: + ref_well = plate.get_well("A1") + else: + raise ValueError("96 head only supports plate rotations of 0 or 180 degrees around z") + position = ( + ref_well.get_location_wrt(self.deck) + + ref_well.center() + + dispense.offset + + Coordinate(z=ref_well.material_z_thickness) + ) + # -1 compared to STAR? + well_bottoms = position.z + lld_search_height = well_bottoms + ref_well.get_absolute_size_z() + 2.7 - 1 + else: + x_width = (12 - 1) * 9 # 12 tips in a row, 9 mm between them + y_width = (8 - 1) * 9 # 8 tips in a column, 9 mm between them + x_position = (dispense.container.get_absolute_size_x() - x_width) / 2 + y_position = (dispense.container.get_absolute_size_y() - y_width) / 2 + y_width + position = ( + dispense.container.get_location_wrt(self.deck, z="cavity_bottom") + + Coordinate(x=x_position, y=y_position) + + dispense.offset + ) + bottom = position.z + lld_search_height = bottom + dispense.container.get_absolute_size_z() + 2.7 - 1 + + liquid_height = position.z + (dispense.liquid_height or 0) + 10 + + tip = next(tip for tip in dispense.tips if tip is not None) + if hlc is None: + hlc = get_vantage_liquid_class( + tip_volume=tip.maximal_volume, + is_core=True, + is_tip=True, + has_filter=tip.has_filter, + liquid=Liquid.WATER, # default to WATER + jet=jet, + blow_out=blow_out, # see method docstring + ) + + if disable_volume_correction or hlc is None: + volume = dispense.volume + else: # hlc is not None and not disable_volume_correction + volume = hlc.compute_corrected_volume(dispense.volume) + + transport_air_volume = transport_air_volume or ( + hlc.dispense_air_transport_volume if hlc is not None else 0 + ) + blow_out_air_volume = blow_out_air_volume or ( + hlc.dispense_blow_out_volume if hlc is not None else 0 + ) + flow_rate = dispense.flow_rate or (hlc.dispense_flow_rate if hlc is not None else 250) + swap_speed = swap_speed or (hlc.dispense_swap_speed if hlc is not None else 100) + settling_time = settling_time or (hlc.dispense_settling_time if hlc is not None else 5) + type_of_dispensing_mode = type_of_dispensing_mode or _get_dispense_mode( + jet=jet, empty=empty, blow_out=blow_out + ) + + return await self.core96_dispensing_of_liquid( + x_position=round(position.x * 10), + y_position=round(position.y * 10), + type_of_dispensing_mode=type_of_dispensing_mode, + minimum_height=round(well_bottoms * 10), + tube_2nd_section_height_measured_from_zm=round(tube_2nd_section_height_measured_from_zm * 10), + tube_2nd_section_ratio=round(tube_2nd_section_ratio * 10), + lld_search_height=round(lld_search_height * 10), + liquid_surface_at_function_without_lld=round(liquid_height * 10), + pull_out_distance_to_take_transport_air_in_function_without_lld=round( + pull_out_distance_to_take_transport_air_in_function_without_lld * 10 + ), + immersion_depth=round(immersion_depth * 10), + surface_following_distance=round(surface_following_distance * 10), + minimal_traverse_height_at_begin_of_command=round( + (minimal_traverse_height_at_begin_of_command or self._traversal_height) * 10 + ), + minimal_height_at_command_end=round( + (minimal_height_at_command_end or self._traversal_height) * 10 + ), + dispense_volume=round(volume * 100), + dispense_speed=round(flow_rate * 10), + cut_off_speed=round(cut_off_speed * 10), + stop_back_volume=round(stop_back_volume * 100), + transport_air_volume=round(transport_air_volume * 10), + blow_out_air_volume=round(blow_out_air_volume * 100), + lld_mode=lld_mode, + lld_sensitivity=lld_sensitivity, + side_touch_off_distance=round(side_touch_off_distance * 10), + swap_speed=round(swap_speed * 10), + settling_time=round(settling_time * 10), + mix_volume=round(dispense.mix.volume * 100) if dispense.mix is not None else 0, + mix_cycles=dispense.mix.repetitions if dispense.mix is not None else 0, + mix_position_in_z_direction_from_liquid_surface=round( + mix_position_in_z_direction_from_liquid_surface * 10 + ), + surface_following_distance_during_mixing=round(surface_following_distance_during_mixing * 10), + mix_speed=round(dispense.mix.flow_rate * 10) if dispense.mix is not None else 10, + limit_curve_index=limit_curve_index, + tadm_channel_pattern=tadm_channel_pattern, + tadm_algorithm_on_off=tadm_algorithm_on_off, + recording_mode=recording_mode, + ) + + @action(auto_prefix=True, description="使用 IPG 夹取实验资源") + async def pick_up_resource( + self, + pickup: ResourcePickup, + grip_strength: int = 81, + plate_width_tolerance: float = 2.0, + acceleration_index: int = 4, + z_clearance_height: float = 0, + hotel_depth: float = 0, + minimal_height_at_command_end: float = 284.0, + ): + _unilab_logger.debug("[UNILAB] VantageBackend.pick_up_resource() called") + """Pick up a resource with the IPG. You probably want to use :meth:`move_resource`, which + allows you to pick up and move a resource with a single command.""" + + center = pickup.resource.get_location_wrt(self.deck, x="c", y="c", z="b") + pickup.offset + grip_height = center.z + pickup.resource.get_absolute_size_z() - pickup.pickup_distance_from_top + plate_width = pickup.resource.get_absolute_size_x() + + await self.ipg_grip_plate( + x_position=round(center.x * 10), + y_position=round(center.y * 10), + z_position=round(grip_height * 10), + grip_strength=grip_strength, + open_gripper_position=round(plate_width * 10) + 32, + plate_width=round(plate_width * 10) - 33, + plate_width_tolerance=round(plate_width_tolerance * 10), + acceleration_index=acceleration_index, + z_clearance_height=round(z_clearance_height * 10), + hotel_depth=round(hotel_depth * 10), + minimal_height_at_command_end=round( + (minimal_height_at_command_end or self._traversal_height) * 10 + ), + ) + + @action(auto_prefix=True, description="移动当前已夹取的实验资源") + async def move_picked_up_resource(self, move: ResourceMove): + _unilab_logger.debug("[UNILAB] VantageBackend.move_picked_up_resource() called") + """Move a resource picked up with the IPG. See :meth:`pick_up_resource`. + + You probably want to use :meth:`move_resource`, which allows you to pick up and move a resource + with a single command. + """ + + raise NotImplementedError() + + @action(auto_prefix=True, description="放下已夹持的实验资源") + async def drop_resource( + self, + drop: ResourceDrop, + z_clearance_height: float = 0, + press_on_distance: int = 5, + hotel_depth: float = 0, + minimal_height_at_command_end: float = 284.0, + ): + _unilab_logger.debug("[UNILAB] VantageBackend.drop_resource() called") + """Release a resource picked up with the IPG. See :meth:`pick_up_resource`. + + You probably want to use :meth:`move_resource`, which allows you to pick up and move a resource + with a single command. + """ + + center = drop.destination + drop.resource.center() + drop.offset + grip_height = center.z + drop.resource.get_absolute_size_z() - drop.pickup_distance_from_top + plate_width = drop.resource.get_absolute_size_x() + + await self.ipg_put_plate( + x_position=round(center.x * 10), + y_position=round(center.y * 10), + z_position=round(grip_height * 10), + z_clearance_height=round(z_clearance_height * 10), + open_gripper_position=round(plate_width * 10) + 32, + press_on_distance=press_on_distance, + hotel_depth=round(hotel_depth * 10), + minimal_height_at_command_end=round( + (minimal_height_at_command_end or self._traversal_height) * 10 + ), + ) + + @action(auto_prefix=True, description="使指定通道准备好手动操作") + async def prepare_for_manual_channel_operation(self, channel: int): + _unilab_logger.debug("[UNILAB] VantageBackend.prepare_for_manual_channel_operation() called") + """Prepare the robot for manual operation.""" + + return await self.expose_channel_n(channel_index=channel + 1) # ? + + @action(auto_prefix=True, description="将指定通道移动到 X 坐标") + async def move_channel_x(self, channel: int, x: float): + _unilab_logger.debug("[UNILAB] VantageBackend.move_channel_x() called") + """Move the specified channel to the specified x coordinate.""" + + return await self.x_arm_move_to_x_position(round(x * 10)) + + @action(auto_prefix=True, description="将指定通道移动到 Y 坐标") + async def move_channel_y(self, channel: int, y: float): + _unilab_logger.debug("[UNILAB] VantageBackend.move_channel_y() called") + """Move the specified channel to the specified y coordinate.""" + + return await self.position_single_channel_in_y_direction(channel + 1, round(y * 10)) + + @action(auto_prefix=True, description="将指定通道移动到 Z 坐标") + async def move_channel_z(self, channel: int, z: float): + _unilab_logger.debug("[UNILAB] VantageBackend.move_channel_z() called") + """Move the specified channel to the specified z coordinate.""" + + return await self.position_single_channel_in_z_direction(channel + 1, round(z * 10)) + + @action(auto_prefix=True, description="检查通道是否可以拾取吸头") + def can_pick_up_tip(self, channel_idx: int, tip: Tip) -> bool: + _unilab_logger.debug("[UNILAB] VantageBackend.can_pick_up_tip() called") + if not isinstance(tip, HamiltonTip): + return False + if tip.tip_size in {TipSize.XL}: + return False + return True + + # ============== Firmware Commands ============== + + @action(auto_prefix=True, description="设置 LED 颜色与闪烁模式") + async def set_led_color( + self, + mode: Union[Literal["on"], Literal["off"], Literal["blink"]], + intensity: int, + white: int, + red: int, + green: int, + blue: int, + uv: int, + blink_interval: Optional[int] = None, + ): + _unilab_logger.debug("[UNILAB] VantageBackend.set_led_color() called") + """Set the LED color. + + Args: + mode: The mode of the LED. One of "on", "off", or "blink". + intensity: The intensity of the LED. 0-100. + white: The white color of the LED. 0-100. + red: The red color of the LED. 0-100. + green: The green color of the LED. 0-100. + blue: The blue color of the LED. 0-100. + uv: The UV color of the LED. 0-100. + blink_interval: The blink interval in ms. Only used if mode is "blink". + """ + + if blink_interval is not None: + if mode != "blink": + raise ValueError("blink_interval is only used when mode is 'blink'.") + + return await self.send_command( + module="C0AM", + command="LI", + li={ + "on": 1, + "off": 0, + "blink": 2, + }[mode], + os=intensity, + ok=blink_interval or 750, # default non zero value + ol=f"{white} {red} {green} {blue} {uv}", + ) + + @action(auto_prefix=True, description="打开或关闭上样盖") + async def set_loading_cover(self, cover_open: bool): + _unilab_logger.debug("[UNILAB] VantageBackend.set_loading_cover() called") + """Set the loading cover. + + Args: + cover_open: Whether the cover should be open or closed. + """ + + return await self.send_command(module="I1AM", command="LP", lp=not cover_open) + + @action(auto_prefix=True, description="获取上样盖初始化状态") + async def loading_cover_request_initialization_status(self) -> bool: + _unilab_logger.debug("[UNILAB] VantageBackend.loading_cover_request_initialization_status() called") + """Request the loading cover initialization status. + + This command was based on the STAR command (QW) and the VStarTranslator log. + + Returns: + True if the cover module is initialized, False otherwise. + """ + + resp = await self.send_command(module="I1AM", command="QW", fmt={"qw": "int"}) + return resp is not None and resp["qw"] == 1 + + @action(auto_prefix=True, description="初始化上样盖") + async def loading_cover_initialize(self): + _unilab_logger.debug("[UNILAB] VantageBackend.loading_cover_initialize() called") + """Initialize the loading cover.""" + + return await self.send_command( + module="I1AM", + command="MI", + ) + + @action(auto_prefix=True, description="获取机械臂模块初始化状态") + async def arm_request_instrument_initialization_status( + self, + ) -> bool: + _unilab_logger.debug("[UNILAB] VantageBackend.arm_request_instrument_initialization_status() called") + """Request the instrument initialization status. + + This command was based on the STAR command (QW) and the VStarTranslator log. A1AM corresponds + to "arm". + + Returns: + True if the arm module is initialized, False otherwise. + """ + + resp = await self.send_command(module="A1AM", command="QW", fmt={"qw": "int"}) + return resp is not None and resp["qw"] == 1 + + @action(auto_prefix=True, description="预初始化机械臂模块") + async def arm_pre_initialize(self): + _unilab_logger.debug("[UNILAB] VantageBackend.arm_pre_initialize() called") + """Initialize the arm module.""" + + return await self.send_command(module="A1AM", command="MI") + + @action(auto_prefix=True, description="获取移液模块初始化状态") + async def pip_request_initialization_status(self) -> bool: + _unilab_logger.debug("[UNILAB] VantageBackend.pip_request_initialization_status() called") + """Request the pip initialization status. + + This command was based on the STAR command (QW) and the VStarTranslator log. A1PM corresponds + to all pip channels together. + + Returns: + True if the pip channels module is initialized, False otherwise. + """ + + resp = await self.send_command(module="A1PM", command="QW", fmt={"qw": "int"}) + return resp is not None and resp["qw"] == 1 + + @action(auto_prefix=True, description="初始化移液模块") + async def pip_initialize( + self, + x_position: List[int], + y_position: List[int], + begin_z_deposit_position: Optional[List[int]] = None, + end_z_deposit_position: Optional[List[int]] = None, + minimal_height_at_command_end: Optional[List[int]] = None, + tip_pattern: Optional[List[bool]] = None, + tip_type: Optional[List[int]] = None, + TODO_DI_2: int = 0, + ): + _unilab_logger.debug("[UNILAB] VantageBackend.pip_initialize() called") + """Initialize + + Args: + x_position: X Position [0.1mm]. + y_position: Y Position [0.1mm]. + begin_z_deposit_position: Begin of tip deposit process (Z- discard range) [0.1mm] ?? + end_z_deposit_position: Z deposit position [0.1mm] (collar bearing position). + minimal_height_at_command_end: Minimal height at command end [0.1mm]. + tip_pattern: Tip pattern (channels involved). [0 = not involved, 1 = involved]. + tip_type: Tip type (see command TT). + TODO_DI_2: Unknown. + """ + + if not all(0 <= x <= 50000 for x in x_position): + raise ValueError("x_position must be in range 0 to 50000") + + if y_position is None: + y_position = [3000] * self.num_channels + elif not all(0 <= x <= 6500 for x in y_position): + raise ValueError("y_position must be in range 0 to 6500") + + if begin_z_deposit_position is None: + begin_z_deposit_position = [0] * self.num_channels + elif not all(0 <= x <= 3600 for x in begin_z_deposit_position): + raise ValueError("begin_z_deposit_position must be in range 0 to 3600") + + if end_z_deposit_position is None: + end_z_deposit_position = [0] * self.num_channels + elif not all(0 <= x <= 3600 for x in end_z_deposit_position): + raise ValueError("end_z_deposit_position must be in range 0 to 3600") + + if minimal_height_at_command_end is None: + minimal_height_at_command_end = [3600] * self.num_channels + elif not all(0 <= x <= 3600 for x in minimal_height_at_command_end): + raise ValueError("minimal_height_at_command_end must be in range 0 to 3600") + + if tip_pattern is None: + tip_pattern = [False] * self.num_channels + elif not all(0 <= x <= 1 for x in tip_pattern): + raise ValueError("tip_pattern must be in range 0 to 1") + + if tip_type is None: + tip_type = [4] * self.num_channels + elif not all(0 <= x <= 199 for x in tip_type): + raise ValueError("tip_type must be in range 0 to 199") + + if not -1000 <= TODO_DI_2 <= 1000: + raise ValueError("TODO_DI_2 must be in range -1000 to 1000") + + return await self.send_command( + module="A1PM", + command="DI", + xp=x_position, + yp=y_position, + tp=begin_z_deposit_position, + tz=end_z_deposit_position, + te=minimal_height_at_command_end, + tm=tip_pattern, + tt=tip_type, + ts=TODO_DI_2, + ) + + @action(auto_prefix=True, description="定义吸头或针的参数") + async def define_tip_needle( + self, + tip_type_table_index: int, + has_filter: bool, + tip_length: int, + maximum_tip_volume: int, + tip_size: TipSize, + pickup_method: TipPickupMethod, + ): + _unilab_logger.debug("[UNILAB] VantageBackend.define_tip_needle() called") + """Tip/needle definition. + + Args: + tip_type_table_index: tip_table_index + filter: with(out) filter + tip_length: Tip length [0.1mm] + maximum_tip_volume: Maximum volume of tip [0.1ul] Note! it's automatically limited to max. + channel capacity + tip_type: Type of tip collar (Tip type identification) + pickup_method: pick up method. Attention! The values set here are temporary and apply only + until power OFF or RESET. After power ON the default values apply. (see Table 3) + """ + + if not 0 <= tip_type_table_index <= 99: + raise ValueError( + "tip_type_table_index must be between 0 and 99, but is " f"{tip_type_table_index}" + ) + if not 0 <= tip_type_table_index <= 99: + raise ValueError( + "tip_type_table_index must be between 0 and 99, but is " f"{tip_type_table_index}" + ) + if not 1 <= tip_length <= 1999: + raise ValueError("tip_length must be between 1 and 1999, but is " f"{tip_length}") + if not 1 <= maximum_tip_volume <= 56000: + raise ValueError( + "maximum_tip_volume must be between 1 and 56000, but is " f"{maximum_tip_volume}" + ) + + return await self.send_command( + module="A1AM", + command="TT", + ti=f"{tip_type_table_index:02}", + tf=has_filter, + tl=f"{tip_length:04}", + tv=f"{maximum_tip_volume:05}", + tg=tip_size.value, + tu=pickup_method.value, + ) + + @action(auto_prefix=True, description="使用移液模块吸液") + async def pip_aspirate( + self, + x_position: List[int], + y_position: List[int], + type_of_aspiration: Optional[List[int]] = None, + tip_pattern: Optional[List[bool]] = None, + minimal_traverse_height_at_begin_of_command: Optional[List[int]] = None, + minimal_height_at_command_end: Optional[List[int]] = None, + lld_search_height: Optional[List[int]] = None, + clot_detection_height: Optional[List[int]] = None, + liquid_surface_at_function_without_lld: Optional[List[int]] = None, + pull_out_distance_to_take_transport_air_in_function_without_lld: Optional[List[int]] = None, + tube_2nd_section_height_measured_from_zm: Optional[List[int]] = None, + tube_2nd_section_ratio: Optional[List[int]] = None, + minimum_height: Optional[List[int]] = None, + immersion_depth: Optional[List[int]] = None, + surface_following_distance: Optional[List[int]] = None, + aspiration_volume: Optional[List[int]] = None, + TODO_DA_2: Optional[List[int]] = None, + aspiration_speed: Optional[List[int]] = None, + transport_air_volume: Optional[List[int]] = None, + blow_out_air_volume: Optional[List[int]] = None, + pre_wetting_volume: Optional[List[int]] = None, + lld_mode: Optional[List[int]] = None, + lld_sensitivity: Optional[List[int]] = None, + pressure_lld_sensitivity: Optional[List[int]] = None, + aspirate_position_above_z_touch_off: Optional[List[int]] = None, + TODO_DA_4: Optional[List[int]] = None, + swap_speed: Optional[List[int]] = None, + settling_time: Optional[List[int]] = None, + mix_volume: Optional[List[int]] = None, + mix_cycles: Optional[List[int]] = None, + mix_position_in_z_direction_from_liquid_surface: Optional[List[int]] = None, + mix_speed: Optional[List[int]] = None, + surface_following_distance_during_mixing: Optional[List[int]] = None, + TODO_DA_5: Optional[List[int]] = None, + capacitive_mad_supervision_on_off: Optional[List[int]] = None, + pressure_mad_supervision_on_off: Optional[List[int]] = None, + tadm_algorithm_on_off: int = 0, + limit_curve_index: Optional[List[int]] = None, + recording_mode: int = 0, + ): + _unilab_logger.debug("[UNILAB] VantageBackend.pip_aspirate() called") + """Aspiration of liquid + + Args: + type_of_aspiration: Type of aspiration (0 = simple 1 = sequence 2 = cup emptied). + tip_pattern: Tip pattern (channels involved). [0 = not involved, 1 = involved]. + x_position: X Position [0.1mm]. + y_position: Y Position [0.1mm]. + minimal_traverse_height_at_begin_of_command: Minimal traverse height at begin of command + [0.1mm]. + minimal_height_at_command_end: Minimal height at command end [0.1mm]. + lld_search_height: LLD search height [0.1mm]. + clot_detection_height: (0). + liquid_surface_at_function_without_lld: Liquid surface at function without LLD [0.1mm]. + pull_out_distance_to_take_transport_air_in_function_without_lld: + Pull out distance to take transp. air in function without LLD [0.1mm]. + tube_2nd_section_height_measured_from_zm: Tube 2nd section height measured from zm [0.1mm]. + tube_2nd_section_ratio: Tube 2nd section ratio. + minimum_height: Minimum height (maximum immersion depth) [0.1mm]. + immersion_depth: Immersion depth [0.1mm]. + surface_following_distance: Surface following distance [0.1mm]. + aspiration_volume: Aspiration volume [0.01ul]. + TODO_DA_2: (0). + aspiration_speed: Aspiration speed [0.1ul]/s. + transport_air_volume: Transport air volume [0.1ul]. + blow_out_air_volume: Blow out air volume [0.01ul]. + pre_wetting_volume: Pre wetting volume [0.1ul]. + lld_mode: LLD Mode (0 = off). + lld_sensitivity: LLD sensitivity (1 = high, 4 = low). + pressure_lld_sensitivity: Pressure LLD sensitivity (1= high, 4=low). + aspirate_position_above_z_touch_off: (0). + TODO_DA_4: (0). + swap_speed: Swap speed (on leaving liquid) [0.1mm/s]. + settling_time: Settling time [0.1s]. + mix_volume: Mix volume [0.1ul]. + mix_cycles: Mix cycles. + mix_position_in_z_direction_from_liquid_surface: Mix position in Z direction from liquid + surface[0.1mm]. + mix_speed: Mix speed [0.1ul/s]. + surface_following_distance_during_mixing: Surface following distance during mixing [0.1mm]. + TODO_DA_5: (0). + capacitive_mad_supervision_on_off: Capacitive MAD supervision on/off (0 = OFF). + pressure_mad_supervision_on_off: Pressure MAD supervision on/off (0 = OFF). + tadm_algorithm_on_off: TADM algorithm on/off (0 = off). + limit_curve_index: Limit curve index. + recording_mode: Recording mode (0 = no 1 = TADM errors only 2 = all TADM measurements). + """ + + if type_of_aspiration is None: + type_of_aspiration = [0] * self.num_channels + elif not all(0 <= x <= 2 for x in type_of_aspiration): + raise ValueError("type_of_aspiration must be in range 0 to 2") + + if tip_pattern is None: + tip_pattern = [False] * self.num_channels + elif not all(0 <= x <= 1 for x in tip_pattern): + raise ValueError("tip_pattern must be in range 0 to 1") + + if not all(0 <= x <= 50000 for x in x_position): + raise ValueError("x_position must be in range 0 to 50000") + + if y_position is None: + y_position = [3000] * self.num_channels + elif not all(0 <= x <= 6500 for x in y_position): + raise ValueError("y_position must be in range 0 to 6500") + + if minimal_traverse_height_at_begin_of_command is None: + minimal_traverse_height_at_begin_of_command = [3600] * self.num_channels + elif not all(0 <= x <= 3600 for x in minimal_traverse_height_at_begin_of_command): + raise ValueError("minimal_traverse_height_at_begin_of_command must be in range 0 to 3600") + + if minimal_height_at_command_end is None: + minimal_height_at_command_end = [3600] * self.num_channels + elif not all(0 <= x <= 3600 for x in minimal_height_at_command_end): + raise ValueError("minimal_height_at_command_end must be in range 0 to 3600") + + if lld_search_height is None: + lld_search_height = [0] * self.num_channels + elif not all(0 <= x <= 3600 for x in lld_search_height): + raise ValueError("lld_search_height must be in range 0 to 3600") + + if clot_detection_height is None: + clot_detection_height = [60] * self.num_channels + elif not all(0 <= x <= 500 for x in clot_detection_height): + raise ValueError("clot_detection_height must be in range 0 to 500") + + if liquid_surface_at_function_without_lld is None: + liquid_surface_at_function_without_lld = [3600] * self.num_channels + elif not all(0 <= x <= 3600 for x in liquid_surface_at_function_without_lld): + raise ValueError("liquid_surface_at_function_without_lld must be in range 0 to 3600") + + if pull_out_distance_to_take_transport_air_in_function_without_lld is None: + pull_out_distance_to_take_transport_air_in_function_without_lld = [50] * self.num_channels + elif not all( + 0 <= x <= 3600 for x in pull_out_distance_to_take_transport_air_in_function_without_lld + ): + raise ValueError( + "pull_out_distance_to_take_transport_air_in_function_without_lld must be " + "in range 0 to 3600" + ) + + if tube_2nd_section_height_measured_from_zm is None: + tube_2nd_section_height_measured_from_zm = [0] * self.num_channels + elif not all(0 <= x <= 3600 for x in tube_2nd_section_height_measured_from_zm): + raise ValueError("tube_2nd_section_height_measured_from_zm must be in range 0 to 3600") + + if tube_2nd_section_ratio is None: + tube_2nd_section_ratio = [0] * self.num_channels + elif not all(0 <= x <= 10000 for x in tube_2nd_section_ratio): + raise ValueError("tube_2nd_section_ratio must be in range 0 to 10000") + + if minimum_height is None: + minimum_height = [3600] * self.num_channels + elif not all(0 <= x <= 3600 for x in minimum_height): + raise ValueError("minimum_height must be in range 0 to 3600") + + if immersion_depth is None: + immersion_depth = [0] * self.num_channels + elif not all(-3600 <= x <= 3600 for x in immersion_depth): + raise ValueError("immersion_depth must be in range -3600 to 3600") + + if surface_following_distance is None: + surface_following_distance = [0] * self.num_channels + elif not all(0 <= x <= 3600 for x in surface_following_distance): + raise ValueError("surface_following_distance must be in range 0 to 3600") + + if aspiration_volume is None: + aspiration_volume = [0] * self.num_channels + elif not all(0 <= x <= 125000 for x in aspiration_volume): + raise ValueError("aspiration_volume must be in range 0 to 125000") + + if TODO_DA_2 is None: + TODO_DA_2 = [0] * self.num_channels + elif not all(0 <= x <= 125000 for x in TODO_DA_2): + raise ValueError("TODO_DA_2 must be in range 0 to 125000") + + if aspiration_speed is None: + aspiration_speed = [500] * self.num_channels + elif not all(10 <= x <= 10000 for x in aspiration_speed): + raise ValueError("aspiration_speed must be in range 10 to 10000") + + if transport_air_volume is None: + transport_air_volume = [0] * self.num_channels + elif not all(0 <= x <= 500 for x in transport_air_volume): + raise ValueError("transport_air_volume must be in range 0 to 500") + + if blow_out_air_volume is None: + blow_out_air_volume = [0] * self.num_channels + elif not all(0 <= x <= 125000 for x in blow_out_air_volume): + raise ValueError("blow_out_air_volume must be in range 0 to 125000") + + if pre_wetting_volume is None: + pre_wetting_volume = [0] * self.num_channels + elif not all(0 <= x <= 999 for x in pre_wetting_volume): + raise ValueError("pre_wetting_volume must be in range 0 to 999") + + if lld_mode is None: + lld_mode = [1] * self.num_channels + elif not all(0 <= x <= 4 for x in lld_mode): + raise ValueError("lld_mode must be in range 0 to 4") + + if lld_sensitivity is None: + lld_sensitivity = [1] * self.num_channels + elif not all(1 <= x <= 4 for x in lld_sensitivity): + raise ValueError("lld_sensitivity must be in range 1 to 4") + + if pressure_lld_sensitivity is None: + pressure_lld_sensitivity = [1] * self.num_channels + elif not all(1 <= x <= 4 for x in pressure_lld_sensitivity): + raise ValueError("pressure_lld_sensitivity must be in range 1 to 4") + + if aspirate_position_above_z_touch_off is None: + aspirate_position_above_z_touch_off = [5] * self.num_channels + elif not all(0 <= x <= 100 for x in aspirate_position_above_z_touch_off): + raise ValueError("aspirate_position_above_z_touch_off must be in range 0 to 100") + + if TODO_DA_4 is None: + TODO_DA_4 = [0] * self.num_channels + elif not all(0 <= x <= 1 for x in TODO_DA_4): + raise ValueError("TODO_DA_4 must be in range 0 to 1") + + if swap_speed is None: + swap_speed = [100] * self.num_channels + elif not all(3 <= x <= 1600 for x in swap_speed): + raise ValueError("swap_speed must be in range 3 to 1600") + + if settling_time is None: + settling_time = [5] * self.num_channels + elif not all(0 <= x <= 99 for x in settling_time): + raise ValueError("settling_time must be in range 0 to 99") + + if mix_volume is None: + mix_volume = [0] * self.num_channels + elif not all(0 <= x <= 12500 for x in mix_volume): + raise ValueError("mix_volume must be in range 0 to 12500") + + if mix_cycles is None: + mix_cycles = [0] * self.num_channels + elif not all(0 <= x <= 99 for x in mix_cycles): + raise ValueError("mix_cycles must be in range 0 to 99") + + if mix_position_in_z_direction_from_liquid_surface is None: + mix_position_in_z_direction_from_liquid_surface = [250] * self.num_channels + elif not all(0 <= x <= 900 for x in mix_position_in_z_direction_from_liquid_surface): + raise ValueError("mix_position_in_z_direction_from_liquid_surface must be in range 0 to 900") + + if mix_speed is None: + mix_speed = [500] * self.num_channels + elif not all(10 <= x <= 10000 for x in mix_speed): + raise ValueError("mix_speed must be in range 10 to 10000") + + if surface_following_distance_during_mixing is None: + surface_following_distance_during_mixing = [0] * self.num_channels + elif not all(0 <= x <= 3600 for x in surface_following_distance_during_mixing): + raise ValueError("surface_following_distance_during_mixing must be in range 0 to 3600") + + if TODO_DA_5 is None: + TODO_DA_5 = [0] * self.num_channels + elif not all(0 <= x <= 1 for x in TODO_DA_5): + raise ValueError("TODO_DA_5 must be in range 0 to 1") + + if capacitive_mad_supervision_on_off is None: + capacitive_mad_supervision_on_off = [0] * self.num_channels + elif not all(0 <= x <= 1 for x in capacitive_mad_supervision_on_off): + raise ValueError("capacitive_mad_supervision_on_off must be in range 0 to 1") + + if pressure_mad_supervision_on_off is None: + pressure_mad_supervision_on_off = [0] * self.num_channels + elif not all(0 <= x <= 1 for x in pressure_mad_supervision_on_off): + raise ValueError("pressure_mad_supervision_on_off must be in range 0 to 1") + + if not 0 <= tadm_algorithm_on_off <= 1: + raise ValueError("tadm_algorithm_on_off must be in range 0 to 1") + + if limit_curve_index is None: + limit_curve_index = [0] * self.num_channels + elif not all(0 <= x <= 999 for x in limit_curve_index): + raise ValueError("limit_curve_index must be in range 0 to 999") + + if not 0 <= recording_mode <= 2: + raise ValueError("recording_mode must be in range 0 to 2") + + return await self.send_command( + module="A1PM", + command="DA", + at=type_of_aspiration, + tm=tip_pattern, + xp=x_position, + yp=y_position, + th=minimal_traverse_height_at_begin_of_command, + te=minimal_height_at_command_end, + lp=lld_search_height, + ch=clot_detection_height, + zl=liquid_surface_at_function_without_lld, + po=pull_out_distance_to_take_transport_air_in_function_without_lld, + zu=tube_2nd_section_height_measured_from_zm, + zr=tube_2nd_section_ratio, + zx=minimum_height, + ip=immersion_depth, + fp=surface_following_distance, + av=aspiration_volume, + # ar=TODO_DA_2, # this parameters is not used by VoV + as_=aspiration_speed, + ta=transport_air_volume, + ba=blow_out_air_volume, + oa=pre_wetting_volume, + lm=lld_mode, + ll=lld_sensitivity, + lv=pressure_lld_sensitivity, + zo=aspirate_position_above_z_touch_off, + # lg=TODO_DA_4, + de=swap_speed, + wt=settling_time, + mv=mix_volume, + mc=mix_cycles, + mp=mix_position_in_z_direction_from_liquid_surface, + ms=mix_speed, + mh=surface_following_distance_during_mixing, + la=TODO_DA_5, + lb=capacitive_mad_supervision_on_off, + lc=pressure_mad_supervision_on_off, + gj=tadm_algorithm_on_off, + gi=limit_curve_index, + gk=recording_mode, + ) + + @action(auto_prefix=True, description="使用移液模块分液") + async def pip_dispense( + self, + x_position: List[int], + y_position: List[int], + type_of_dispensing_mode: Optional[List[int]] = None, + tip_pattern: Optional[List[bool]] = None, + minimum_height: Optional[List[int]] = None, + lld_search_height: Optional[List[int]] = None, + liquid_surface_at_function_without_lld: Optional[List[int]] = None, + pull_out_distance_to_take_transport_air_in_function_without_lld: Optional[List[int]] = None, + immersion_depth: Optional[List[int]] = None, + surface_following_distance: Optional[List[int]] = None, + tube_2nd_section_height_measured_from_zm: Optional[List[int]] = None, + tube_2nd_section_ratio: Optional[List[int]] = None, + minimal_traverse_height_at_begin_of_command: Optional[List[int]] = None, + minimal_height_at_command_end: Optional[List[int]] = None, + dispense_volume: Optional[List[int]] = None, + dispense_speed: Optional[List[int]] = None, + cut_off_speed: Optional[List[int]] = None, + stop_back_volume: Optional[List[int]] = None, + transport_air_volume: Optional[List[int]] = None, + blow_out_air_volume: Optional[List[int]] = None, + lld_mode: Optional[List[int]] = None, + side_touch_off_distance: int = 0, + dispense_position_above_z_touch_off: Optional[List[int]] = None, + lld_sensitivity: Optional[List[int]] = None, + pressure_lld_sensitivity: Optional[List[int]] = None, + swap_speed: Optional[List[int]] = None, + settling_time: Optional[List[int]] = None, + mix_volume: Optional[List[int]] = None, + mix_cycles: Optional[List[int]] = None, + mix_position_in_z_direction_from_liquid_surface: Optional[List[int]] = None, + mix_speed: Optional[List[int]] = None, + surface_following_distance_during_mixing: Optional[List[int]] = None, + TODO_DD_2: Optional[List[int]] = None, + tadm_algorithm_on_off: int = 0, + limit_curve_index: Optional[List[int]] = None, + recording_mode: int = 0, + ): + _unilab_logger.debug("[UNILAB] VantageBackend.pip_dispense() called") + """Dispensing of liquid + + Args: + type_of_dispensing_mode: Type of dispensing mode 0 = part in jet 1 = blow in jet 2 = Part at + surface 3 = Blow at surface 4 = Empty. + tip_pattern: Tip pattern (channels involved). [0 = not involved, 1 = involved]. + x_position: X Position [0.1mm]. + y_position: Y Position [0.1mm]. + minimum_height: Minimum height (maximum immersion depth) [0.1mm]. + lld_search_height: LLD search height [0.1mm]. + liquid_surface_at_function_without_lld: Liquid surface at function without LLD [0.1mm]. + pull_out_distance_to_take_transport_air_in_function_without_lld: + Pull out distance to take transp. air in function without LLD [0.1mm] + . + immersion_depth: Immersion depth [0.1mm]. + surface_following_distance: Surface following distance [0.1mm]. + tube_2nd_section_height_measured_from_zm: Tube 2nd section height measured from zm [0.1mm]. + tube_2nd_section_ratio: Tube 2nd section ratio. + minimal_traverse_height_at_begin_of_command: Minimal traverse height at begin of command + [0.1mm]. + minimal_height_at_command_end: Minimal height at command end [0.1mm]. + dispense_volume: Dispense volume [0.01ul]. + dispense_speed: Dispense speed [0.1ul/s]. + cut_off_speed: Cut off speed [0.1ul/s]. + stop_back_volume: Stop back volume [0.1ul]. + transport_air_volume: Transport air volume [0.1ul]. + blow_out_air_volume: Blow out air volume [0.01ul]. + lld_mode: LLD Mode (0 = off). + side_touch_off_distance: Side touch off distance [0.1mm]. + dispense_position_above_z_touch_off: (0). + lld_sensitivity: LLD sensitivity (1 = high, 4 = low). + pressure_lld_sensitivity: Pressure LLD sensitivity (1= high, 4=low). + swap_speed: Swap speed (on leaving liquid) [0.1mm/s]. + settling_time: Settling time [0.1s]. + mix_volume: Mix volume [0.1ul]. + mix_cycles: Mix cycles. + mix_position_in_z_direction_from_liquid_surface: Mix position in Z direction from liquid + surface[0.1mm]. + mix_speed: Mix speed [0.1ul/s]. + surface_following_distance_during_mixing: Surface following distance during mixing [0.1mm]. + TODO_DD_2: (0). + tadm_algorithm_on_off: TADM algorithm on/off (0 = off). + limit_curve_index: Limit curve index. + recording_mode: + Recording mode (0 = no 1 = TADM errors only 2 = all TADM measurements) + . + """ + + if type_of_dispensing_mode is None: + type_of_dispensing_mode = [0] * self.num_channels + elif not all(0 <= x <= 4 for x in type_of_dispensing_mode): + raise ValueError("type_of_dispensing_mode must be in range 0 to 4") + + if tip_pattern is None: + tip_pattern = [False] * self.num_channels + elif not all(0 <= x <= 1 for x in tip_pattern): + raise ValueError("tip_pattern must be in range 0 to 1") + + if not all(0 <= x <= 50000 for x in x_position): + raise ValueError("x_position must be in range 0 to 50000") + + if y_position is None: + y_position = [3000] * self.num_channels + elif not all(0 <= x <= 6500 for x in y_position): + raise ValueError("y_position must be in range 0 to 6500") + + if minimum_height is None: + minimum_height = [3600] * self.num_channels + elif not all(0 <= x <= 3600 for x in minimum_height): + raise ValueError("minimum_height must be in range 0 to 3600") + + if lld_search_height is None: + lld_search_height = [0] * self.num_channels + elif not all(0 <= x <= 3600 for x in lld_search_height): + raise ValueError("lld_search_height must be in range 0 to 3600") + + if liquid_surface_at_function_without_lld is None: + liquid_surface_at_function_without_lld = [3600] * self.num_channels + elif not all(0 <= x <= 3600 for x in liquid_surface_at_function_without_lld): + raise ValueError("liquid_surface_at_function_without_lld must be in range 0 to 3600") + + if pull_out_distance_to_take_transport_air_in_function_without_lld is None: + pull_out_distance_to_take_transport_air_in_function_without_lld = [50] * self.num_channels + elif not all( + 0 <= x <= 3600 for x in pull_out_distance_to_take_transport_air_in_function_without_lld + ): + raise ValueError( + "pull_out_distance_to_take_transport_air_in_function_without_lld must be " + "in range 0 to 3600" + ) + + if immersion_depth is None: + immersion_depth = [0] * self.num_channels + elif not all(-3600 <= x <= 3600 for x in immersion_depth): + raise ValueError("immersion_depth must be in range -3600 to 3600") + + if surface_following_distance is None: + surface_following_distance = [0] * self.num_channels + elif not all(0 <= x <= 3600 for x in surface_following_distance): + raise ValueError("surface_following_distance must be in range 0 to 3600") + + if tube_2nd_section_height_measured_from_zm is None: + tube_2nd_section_height_measured_from_zm = [0] * self.num_channels + elif not all(0 <= x <= 3600 for x in tube_2nd_section_height_measured_from_zm): + raise ValueError("tube_2nd_section_height_measured_from_zm must be in range 0 to 3600") + + if tube_2nd_section_ratio is None: + tube_2nd_section_ratio = [0] * self.num_channels + elif not all(0 <= x <= 10000 for x in tube_2nd_section_ratio): + raise ValueError("tube_2nd_section_ratio must be in range 0 to 10000") + + if minimal_traverse_height_at_begin_of_command is None: + minimal_traverse_height_at_begin_of_command = [3600] * self.num_channels + elif not all(0 <= x <= 3600 for x in minimal_traverse_height_at_begin_of_command): + raise ValueError("minimal_traverse_height_at_begin_of_command must be in range 0 to 3600") + + if minimal_height_at_command_end is None: + minimal_height_at_command_end = [3600] * self.num_channels + elif not all(0 <= x <= 3600 for x in minimal_height_at_command_end): + raise ValueError("minimal_height_at_command_end must be in range 0 to 3600") + + if dispense_volume is None: + dispense_volume = [0] * self.num_channels + elif not all(0 <= x <= 125000 for x in dispense_volume): + raise ValueError("dispense_volume must be in range 0 to 125000") + + if dispense_speed is None: + dispense_speed = [500] * self.num_channels + elif not all(10 <= x <= 10000 for x in dispense_speed): + raise ValueError("dispense_speed must be in range 10 to 10000") + + if cut_off_speed is None: + cut_off_speed = [250] * self.num_channels + elif not all(10 <= x <= 10000 for x in cut_off_speed): + raise ValueError("cut_off_speed must be in range 10 to 10000") + + if stop_back_volume is None: + stop_back_volume = [0] * self.num_channels + elif not all(0 <= x <= 180 for x in stop_back_volume): + raise ValueError("stop_back_volume must be in range 0 to 180") + + if transport_air_volume is None: + transport_air_volume = [0] * self.num_channels + elif not all(0 <= x <= 500 for x in transport_air_volume): + raise ValueError("transport_air_volume must be in range 0 to 500") + + if blow_out_air_volume is None: + blow_out_air_volume = [0] * self.num_channels + elif not all(0 <= x <= 125000 for x in blow_out_air_volume): + raise ValueError("blow_out_air_volume must be in range 0 to 125000") + + if lld_mode is None: + lld_mode = [1] * self.num_channels + elif not all(0 <= x <= 4 for x in lld_mode): + raise ValueError("lld_mode must be in range 0 to 4") + + if not 0 <= side_touch_off_distance <= 45: + raise ValueError("side_touch_off_distance must be in range 0 to 45") + + if dispense_position_above_z_touch_off is None: + dispense_position_above_z_touch_off = [5] * self.num_channels + elif not all(0 <= x <= 100 for x in dispense_position_above_z_touch_off): + raise ValueError("dispense_position_above_z_touch_off must be in range 0 to 100") + + if lld_sensitivity is None: + lld_sensitivity = [1] * self.num_channels + elif not all(1 <= x <= 4 for x in lld_sensitivity): + raise ValueError("lld_sensitivity must be in range 1 to 4") + + if pressure_lld_sensitivity is None: + pressure_lld_sensitivity = [1] * self.num_channels + elif not all(1 <= x <= 4 for x in pressure_lld_sensitivity): + raise ValueError("pressure_lld_sensitivity must be in range 1 to 4") + + if swap_speed is None: + swap_speed = [100] * self.num_channels + elif not all(3 <= x <= 1600 for x in swap_speed): + raise ValueError("swap_speed must be in range 3 to 1600") + + if settling_time is None: + settling_time = [5] * self.num_channels + elif not all(0 <= x <= 99 for x in settling_time): + raise ValueError("settling_time must be in range 0 to 99") + + if mix_volume is None: + mix_volume = [0] * self.num_channels + elif not all(0 <= x <= 12500 for x in mix_volume): + raise ValueError("mix_volume must be in range 0 to 12500") + + if mix_cycles is None: + mix_cycles = [0] * self.num_channels + elif not all(0 <= x <= 99 for x in mix_cycles): + raise ValueError("mix_cycles must be in range 0 to 99") + + if mix_position_in_z_direction_from_liquid_surface is None: + mix_position_in_z_direction_from_liquid_surface = [250] * self.num_channels + elif not all(0 <= x <= 900 for x in mix_position_in_z_direction_from_liquid_surface): + raise ValueError("mix_position_in_z_direction_from_liquid_surface must be in range 0 to 900") + + if mix_speed is None: + mix_speed = [500] * self.num_channels + elif not all(10 <= x <= 10000 for x in mix_speed): + raise ValueError("mix_speed must be in range 10 to 10000") + + if surface_following_distance_during_mixing is None: + surface_following_distance_during_mixing = [0] * self.num_channels + elif not all(0 <= x <= 3600 for x in surface_following_distance_during_mixing): + raise ValueError("surface_following_distance_during_mixing must be in range 0 to 3600") + + if TODO_DD_2 is None: + TODO_DD_2 = [0] * self.num_channels + elif not all(0 <= x <= 1 for x in TODO_DD_2): + raise ValueError("TODO_DD_2 must be in range 0 to 1") + + if not 0 <= tadm_algorithm_on_off <= 1: + raise ValueError("tadm_algorithm_on_off must be in range 0 to 1") + + if limit_curve_index is None: + limit_curve_index = [0] * self.num_channels + elif not all(0 <= x <= 999 for x in limit_curve_index): + raise ValueError("limit_curve_index must be in range 0 to 999") + + if not 0 <= recording_mode <= 2: + raise ValueError("recording_mode must be in range 0 to 2") + + return await self.send_command( + module="A1PM", + command="DD", + dm=type_of_dispensing_mode, + tm=tip_pattern, + xp=x_position, + yp=y_position, + zx=minimum_height, + lp=lld_search_height, + zl=liquid_surface_at_function_without_lld, + po=pull_out_distance_to_take_transport_air_in_function_without_lld, + ip=immersion_depth, + fp=surface_following_distance, + zu=tube_2nd_section_height_measured_from_zm, + zr=tube_2nd_section_ratio, + th=minimal_traverse_height_at_begin_of_command, + te=minimal_height_at_command_end, + dv=[f"{vol:04}" for vol in dispense_volume], # it appears at least 4 digits are needed + ds=dispense_speed, + ss=cut_off_speed, + rv=stop_back_volume, + ta=transport_air_volume, + ba=blow_out_air_volume, + lm=lld_mode, + dj=side_touch_off_distance, + zo=dispense_position_above_z_touch_off, + ll=lld_sensitivity, + lv=pressure_lld_sensitivity, + de=swap_speed, + wt=settling_time, + mv=mix_volume, + mc=mix_cycles, + mp=mix_position_in_z_direction_from_liquid_surface, + ms=mix_speed, + mh=surface_following_distance_during_mixing, + la=TODO_DD_2, + gj=tadm_algorithm_on_off, + gi=limit_curve_index, + gk=recording_mode, + ) + + @action(auto_prefix=True, description="执行同步吸液与分液") + async def simultaneous_aspiration_dispensation_of_liquid( + self, + x_position: List[int], + y_position: List[int], + type_of_aspiration: Optional[List[int]] = None, + type_of_dispensing_mode: Optional[List[int]] = None, + tip_pattern: Optional[List[bool]] = None, + TODO_DM_1: Optional[List[int]] = None, + minimal_traverse_height_at_begin_of_command: Optional[List[int]] = None, + minimal_height_at_command_end: Optional[List[int]] = None, + lld_search_height: Optional[List[int]] = None, + clot_detection_height: Optional[List[int]] = None, + liquid_surface_at_function_without_lld: Optional[List[int]] = None, + pull_out_distance_to_take_transport_air_in_function_without_lld: Optional[List[int]] = None, + minimum_height: Optional[List[int]] = None, + immersion_depth: Optional[List[int]] = None, + surface_following_distance: Optional[List[int]] = None, + tube_2nd_section_height_measured_from_zm: Optional[List[int]] = None, + tube_2nd_section_ratio: Optional[List[int]] = None, + aspiration_volume: Optional[List[int]] = None, + TODO_DM_3: Optional[List[int]] = None, + aspiration_speed: Optional[List[int]] = None, + dispense_volume: Optional[List[int]] = None, + dispense_speed: Optional[List[int]] = None, + cut_off_speed: Optional[List[int]] = None, + stop_back_volume: Optional[List[int]] = None, + transport_air_volume: Optional[List[int]] = None, + blow_out_air_volume: Optional[List[int]] = None, + pre_wetting_volume: Optional[List[int]] = None, + lld_mode: Optional[List[int]] = None, + aspirate_position_above_z_touch_off: Optional[List[int]] = None, + lld_sensitivity: Optional[List[int]] = None, + pressure_lld_sensitivity: Optional[List[int]] = None, + swap_speed: Optional[List[int]] = None, + settling_time: Optional[List[int]] = None, + mix_volume: Optional[List[int]] = None, + mix_cycles: Optional[List[int]] = None, + mix_position_in_z_direction_from_liquid_surface: Optional[List[int]] = None, + mix_speed: Optional[List[int]] = None, + surface_following_distance_during_mixing: Optional[List[int]] = None, + TODO_DM_5: Optional[List[int]] = None, + capacitive_mad_supervision_on_off: Optional[List[int]] = None, + pressure_mad_supervision_on_off: Optional[List[int]] = None, + tadm_algorithm_on_off: int = 0, + limit_curve_index: Optional[List[int]] = None, + recording_mode: int = 0, + ): + _unilab_logger.debug("[UNILAB] VantageBackend.simultaneous_aspiration_dispensation_of_liquid() called") + """Simultaneous aspiration & dispensation of liquid + + Args: + type_of_aspiration: Type of aspiration (0 = simple 1 = sequence 2 = cup emptied). + type_of_dispensing_mode: Type of dispensing mode 0 = part in jet 1 = blow in jet 2 = Part at + surface 3 = Blow at surface 4 = Empty. + tip_pattern: Tip pattern (channels involved). [0 = not involved, 1 = involved]. + TODO_DM_1: (0). + x_position: X Position [0.1mm]. + y_position: Y Position [0.1mm]. + minimal_traverse_height_at_begin_of_command: Minimal traverse height at begin of command + [0.1mm]. + minimal_height_at_command_end: Minimal height at command end [0.1mm]. + lld_search_height: LLD search height [0.1mm]. + clot_detection_height: (0). + liquid_surface_at_function_without_lld: Liquid surface at function without LLD [0.1mm]. + pull_out_distance_to_take_transport_air_in_function_without_lld: + Pull out distance to take transp. air in function without LLD [0.1mm] + . + minimum_height: Minimum height (maximum immersion depth) [0.1mm]. + immersion_depth: Immersion depth [0.1mm]. + surface_following_distance: Surface following distance [0.1mm]. + tube_2nd_section_height_measured_from_zm: Tube 2nd section height measured from zm [0.1mm]. + tube_2nd_section_ratio: Tube 2nd section ratio. + aspiration_volume: Aspiration volume [0.01ul]. + TODO_DM_3: (0). + aspiration_speed: Aspiration speed [0.1ul]/s. + dispense_volume: Dispense volume [0.01ul]. + dispense_speed: Dispense speed [0.1ul/s]. + cut_off_speed: Cut off speed [0.1ul/s]. + stop_back_volume: Stop back volume [0.1ul]. + transport_air_volume: Transport air volume [0.1ul]. + blow_out_air_volume: Blow out air volume [0.01ul]. + pre_wetting_volume: Pre wetting volume [0.1ul]. + lld_mode: LLD Mode (0 = off). + aspirate_position_above_z_touch_off: (0). + lld_sensitivity: LLD sensitivity (1 = high, 4 = low). + pressure_lld_sensitivity: Pressure LLD sensitivity (1= high, 4=low). + swap_speed: Swap speed (on leaving liquid) [0.1mm/s]. + settling_time: Settling time [0.1s]. + mix_volume: Mix volume [0.1ul]. + mix_cycles: Mix cycles. + mix_position_in_z_direction_from_liquid_surface: Mix position in Z direction from liquid + surface[0.1mm]. + mix_speed: Mix speed [0.1ul/s]. + surface_following_distance_during_mixing: Surface following distance during mixing [0.1mm]. + TODO_DM_5: (0). + capacitive_mad_supervision_on_off: Capacitive MAD supervision on/off (0 = OFF). + pressure_mad_supervision_on_off: Pressure MAD supervision on/off (0 = OFF). + tadm_algorithm_on_off: TADM algorithm on/off (0 = off). + limit_curve_index: Limit curve index. + recording_mode: + Recording mode (0 = no 1 = TADM errors only 2 = all TADM measurements) + . + """ + + if type_of_aspiration is None: + type_of_aspiration = [0] * self.num_channels + elif not all(0 <= x <= 2 for x in type_of_aspiration): + raise ValueError("type_of_aspiration must be in range 0 to 2") + + if type_of_dispensing_mode is None: + type_of_dispensing_mode = [0] * self.num_channels + elif not all(0 <= x <= 4 for x in type_of_dispensing_mode): + raise ValueError("type_of_dispensing_mode must be in range 0 to 4") + + if tip_pattern is None: + tip_pattern = [False] * self.num_channels + elif not all(0 <= x <= 1 for x in tip_pattern): + raise ValueError("tip_pattern must be in range 0 to 1") + + if TODO_DM_1 is None: + TODO_DM_1 = [0] * self.num_channels + elif not all(0 <= x <= 1 for x in TODO_DM_1): + raise ValueError("TODO_DM_1 must be in range 0 to 1") + + if not all(0 <= x <= 50000 for x in x_position): + raise ValueError("x_position must be in range 0 to 50000") + + if y_position is None: + y_position = [3000] * self.num_channels + elif not all(0 <= x <= 6500 for x in y_position): + raise ValueError("y_position must be in range 0 to 6500") + + if minimal_traverse_height_at_begin_of_command is None: + minimal_traverse_height_at_begin_of_command = [3600] * self.num_channels + elif not all(0 <= x <= 3600 for x in minimal_traverse_height_at_begin_of_command): + raise ValueError("minimal_traverse_height_at_begin_of_command must be in range 0 to 3600") + + if minimal_height_at_command_end is None: + minimal_height_at_command_end = [3600] * self.num_channels + elif not all(0 <= x <= 3600 for x in minimal_height_at_command_end): + raise ValueError("minimal_height_at_command_end must be in range 0 to 3600") + + if lld_search_height is None: + lld_search_height = [0] * self.num_channels + elif not all(0 <= x <= 3600 for x in lld_search_height): + raise ValueError("lld_search_height must be in range 0 to 3600") + + if clot_detection_height is None: + clot_detection_height = [60] * self.num_channels + elif not all(0 <= x <= 500 for x in clot_detection_height): + raise ValueError("clot_detection_height must be in range 0 to 500") + + if liquid_surface_at_function_without_lld is None: + liquid_surface_at_function_without_lld = [3600] * self.num_channels + elif not all(0 <= x <= 3600 for x in liquid_surface_at_function_without_lld): + raise ValueError("liquid_surface_at_function_without_lld must be in range 0 to 3600") + + if pull_out_distance_to_take_transport_air_in_function_without_lld is None: + pull_out_distance_to_take_transport_air_in_function_without_lld = [50] * self.num_channels + elif not all( + 0 <= x <= 3600 for x in pull_out_distance_to_take_transport_air_in_function_without_lld + ): + raise ValueError( + "pull_out_distance_to_take_transport_air_in_function_without_lld must be " + "in range 0 to 3600" + ) + + if minimum_height is None: + minimum_height = [3600] * self.num_channels + elif not all(0 <= x <= 3600 for x in minimum_height): + raise ValueError("minimum_height must be in range 0 to 3600") + + if immersion_depth is None: + immersion_depth = [0] * self.num_channels + elif not all(-3600 <= x <= 3600 for x in immersion_depth): + raise ValueError("immersion_depth must be in range -3600 to 3600") + + if surface_following_distance is None: + surface_following_distance = [0] * self.num_channels + elif not all(0 <= x <= 3600 for x in surface_following_distance): + raise ValueError("surface_following_distance must be in range 0 to 3600") + + if tube_2nd_section_height_measured_from_zm is None: + tube_2nd_section_height_measured_from_zm = [0] * self.num_channels + elif not all(0 <= x <= 3600 for x in tube_2nd_section_height_measured_from_zm): + raise ValueError("tube_2nd_section_height_measured_from_zm must be in range 0 to 3600") + + if tube_2nd_section_ratio is None: + tube_2nd_section_ratio = [0] * self.num_channels + elif not all(0 <= x <= 10000 for x in tube_2nd_section_ratio): + raise ValueError("tube_2nd_section_ratio must be in range 0 to 10000") + + if aspiration_volume is None: + aspiration_volume = [0] * self.num_channels + elif not all(0 <= x <= 125000 for x in aspiration_volume): + raise ValueError("aspiration_volume must be in range 0 to 125000") + + if TODO_DM_3 is None: + TODO_DM_3 = [0] * self.num_channels + elif not all(0 <= x <= 125000 for x in TODO_DM_3): + raise ValueError("TODO_DM_3 must be in range 0 to 125000") + + if aspiration_speed is None: + aspiration_speed = [500] * self.num_channels + elif not all(10 <= x <= 10000 for x in aspiration_speed): + raise ValueError("aspiration_speed must be in range 10 to 10000") + + if dispense_volume is None: + dispense_volume = [0] * self.num_channels + elif not all(0 <= x <= 125000 for x in dispense_volume): + raise ValueError("dispense_volume must be in range 0 to 125000") + + if dispense_speed is None: + dispense_speed = [500] * self.num_channels + elif not all(10 <= x <= 10000 for x in dispense_speed): + raise ValueError("dispense_speed must be in range 10 to 10000") + + if cut_off_speed is None: + cut_off_speed = [250] * self.num_channels + elif not all(10 <= x <= 10000 for x in cut_off_speed): + raise ValueError("cut_off_speed must be in range 10 to 10000") + + if stop_back_volume is None: + stop_back_volume = [0] * self.num_channels + elif not all(0 <= x <= 180 for x in stop_back_volume): + raise ValueError("stop_back_volume must be in range 0 to 180") + + if transport_air_volume is None: + transport_air_volume = [0] * self.num_channels + elif not all(0 <= x <= 500 for x in transport_air_volume): + raise ValueError("transport_air_volume must be in range 0 to 500") + + if blow_out_air_volume is None: + blow_out_air_volume = [0] * self.num_channels + elif not all(0 <= x <= 125000 for x in blow_out_air_volume): + raise ValueError("blow_out_air_volume must be in range 0 to 125000") + + if pre_wetting_volume is None: + pre_wetting_volume = [0] * self.num_channels + elif not all(0 <= x <= 999 for x in pre_wetting_volume): + raise ValueError("pre_wetting_volume must be in range 0 to 999") + + if lld_mode is None: + lld_mode = [1] * self.num_channels + elif not all(0 <= x <= 4 for x in lld_mode): + raise ValueError("lld_mode must be in range 0 to 4") + + if aspirate_position_above_z_touch_off is None: + aspirate_position_above_z_touch_off = [5] * self.num_channels + elif not all(0 <= x <= 100 for x in aspirate_position_above_z_touch_off): + raise ValueError("aspirate_position_above_z_touch_off must be in range 0 to 100") + + if lld_sensitivity is None: + lld_sensitivity = [1] * self.num_channels + elif not all(1 <= x <= 4 for x in lld_sensitivity): + raise ValueError("lld_sensitivity must be in range 1 to 4") + + if pressure_lld_sensitivity is None: + pressure_lld_sensitivity = [1] * self.num_channels + elif not all(1 <= x <= 4 for x in pressure_lld_sensitivity): + raise ValueError("pressure_lld_sensitivity must be in range 1 to 4") + + if swap_speed is None: + swap_speed = [100] * self.num_channels + elif not all(3 <= x <= 1600 for x in swap_speed): + raise ValueError("swap_speed must be in range 3 to 1600") + + if settling_time is None: + settling_time = [5] * self.num_channels + elif not all(0 <= x <= 99 for x in settling_time): + raise ValueError("settling_time must be in range 0 to 99") + + if mix_volume is None: + mix_volume = [0] * self.num_channels + elif not all(0 <= x <= 12500 for x in mix_volume): + raise ValueError("mix_volume must be in range 0 to 12500") + + if mix_cycles is None: + mix_cycles = [0] * self.num_channels + elif not all(0 <= x <= 99 for x in mix_cycles): + raise ValueError("mix_cycles must be in range 0 to 99") + + if mix_position_in_z_direction_from_liquid_surface is None: + mix_position_in_z_direction_from_liquid_surface = [250] * self.num_channels + elif not all(0 <= x <= 900 for x in mix_position_in_z_direction_from_liquid_surface): + raise ValueError("mix_position_in_z_direction_from_liquid_surface must be in range 0 to 900") + + if mix_speed is None: + mix_speed = [500] * self.num_channels + elif not all(10 <= x <= 10000 for x in mix_speed): + raise ValueError("mix_speed must be in range 10 to 10000") + + if surface_following_distance_during_mixing is None: + surface_following_distance_during_mixing = [0] * self.num_channels + elif not all(0 <= x <= 3600 for x in surface_following_distance_during_mixing): + raise ValueError("surface_following_distance_during_mixing must be in range 0 to 3600") + + if TODO_DM_5 is None: + TODO_DM_5 = [0] * self.num_channels + elif not all(0 <= x <= 1 for x in TODO_DM_5): + raise ValueError("TODO_DM_5 must be in range 0 to 1") + + if capacitive_mad_supervision_on_off is None: + capacitive_mad_supervision_on_off = [0] * self.num_channels + elif not all(0 <= x <= 1 for x in capacitive_mad_supervision_on_off): + raise ValueError("capacitive_mad_supervision_on_off must be in range 0 to 1") + + if pressure_mad_supervision_on_off is None: + pressure_mad_supervision_on_off = [0] * self.num_channels + elif not all(0 <= x <= 1 for x in pressure_mad_supervision_on_off): + raise ValueError("pressure_mad_supervision_on_off must be in range 0 to 1") + + if not 0 <= tadm_algorithm_on_off <= 1: + raise ValueError("tadm_algorithm_on_off must be in range 0 to 1") + + if limit_curve_index is None: + limit_curve_index = [0] * self.num_channels + elif not all(0 <= x <= 999 for x in limit_curve_index): + raise ValueError("limit_curve_index must be in range 0 to 999") + + if not 0 <= recording_mode <= 2: + raise ValueError("recording_mode must be in range 0 to 2") + + return await self.send_command( + module="A1PM", + command="DM", + at=type_of_aspiration, + dm=type_of_dispensing_mode, + tm=tip_pattern, + dd=TODO_DM_1, + xp=x_position, + yp=y_position, + th=minimal_traverse_height_at_begin_of_command, + te=minimal_height_at_command_end, + lp=lld_search_height, + ch=clot_detection_height, + zl=liquid_surface_at_function_without_lld, + po=pull_out_distance_to_take_transport_air_in_function_without_lld, + zx=minimum_height, + ip=immersion_depth, + fp=surface_following_distance, + zu=tube_2nd_section_height_measured_from_zm, + zr=tube_2nd_section_ratio, + av=aspiration_volume, + ar=TODO_DM_3, + as_=aspiration_speed, + dv=dispense_volume, + ds=dispense_speed, + ss=cut_off_speed, + rv=stop_back_volume, + ta=transport_air_volume, + ba=blow_out_air_volume, + oa=pre_wetting_volume, + lm=lld_mode, + zo=aspirate_position_above_z_touch_off, + ll=lld_sensitivity, + lv=pressure_lld_sensitivity, + de=swap_speed, + wt=settling_time, + mv=mix_volume, + mc=mix_cycles, + mp=mix_position_in_z_direction_from_liquid_surface, + ms=mix_speed, + mh=surface_following_distance_during_mixing, + la=TODO_DM_5, + lb=capacitive_mad_supervision_on_off, + lc=pressure_mad_supervision_on_off, + gj=tadm_algorithm_on_off, + gi=limit_curve_index, + gk=recording_mode, + ) + + @action(auto_prefix=True, description="在运动过程中进行飞行分液") + async def dispense_on_fly( + self, + y_position: List[int], + tip_pattern: Optional[List[bool]] = None, + first_shoot_x_pos: int = 0, # 1 + dispense_on_fly_pos_command_end: int = 0, # 2 + x_acceleration_distance_before_first_shoot: int = 100, # 3 + space_between_shoots: int = 900, # 4 + x_speed: int = 270, + number_of_shoots: int = 1, # 5 + minimal_traverse_height_at_begin_of_command: Optional[List[int]] = None, + minimal_height_at_command_end: Optional[List[int]] = None, + liquid_surface_at_function_without_lld: Optional[List[int]] = None, + dispense_volume: Optional[List[int]] = None, + dispense_speed: Optional[List[int]] = None, + cut_off_speed: Optional[List[int]] = None, + stop_back_volume: Optional[List[int]] = None, + transport_air_volume: Optional[List[int]] = None, + tadm_algorithm_on_off: int = 0, + limit_curve_index: Optional[List[int]] = None, + recording_mode: int = 0, + ): + _unilab_logger.debug("[UNILAB] VantageBackend.dispense_on_fly() called") + """Dispense on fly + + Args: + tip_pattern: Tip pattern (channels involved). [0 = not involved, 1 = involved]. + first_shoot_x_pos: First shoot X-position [0.1mm] + dispense_on_fly_pos_command_end: Dispense on fly position on command end [0.1mm] + x_acceleration_distance_before_first_shoot: X- acceleration distance before first shoot + [0.1mm] Space between shoots (raster pitch) [0.01mm] + space_between_shoots: Space between shoots (raster pitch) [0.01mm] + x_speed: X speed [0.1mm/s]. + number_of_shoots: Number of shoots + minimal_traverse_height_at_begin_of_command: Minimal traverse height at begin of command + [0.1mm]. + minimal_height_at_command_end: Minimal height at command end [0.1mm]. + y_position: Y Position [0.1mm]. + liquid_surface_at_function_without_lld: Liquid surface at function without LLD [0.1mm]. + dispense_volume: Dispense volume [0.01ul]. + dispense_speed: Dispense speed [0.1ul/s]. + cut_off_speed: Cut off speed [0.1ul/s]. + stop_back_volume: Stop back volume [0.1ul]. + transport_air_volume: Transport air volume [0.1ul]. + tadm_algorithm_on_off: TADM algorithm on/off (0 = off). + limit_curve_index: Limit curve index. + recording_mode: Recording mode (0 = no 1 = TADM errors only 2 = all TADM measurements). + """ + + if tip_pattern is None: + tip_pattern = [False] * self.num_channels + elif not all(0 <= x <= 1 for x in tip_pattern): + raise ValueError("tip_pattern must be in range 0 to 1") + + if not -50000 <= first_shoot_x_pos <= 50000: + raise ValueError("first_shoot_x_pos must be in range -50000 to 50000") + + if not -50000 <= dispense_on_fly_pos_command_end <= 50000: + raise ValueError("dispense_on_fly_pos_command_end must be in range -50000 to 50000") + + if not 0 <= x_acceleration_distance_before_first_shoot <= 900: + raise ValueError("x_acceleration_distance_before_first_shoot must be in range 0 to 900") + + if not 1 <= space_between_shoots <= 2500: + raise ValueError("space_between_shoots must be in range 1 to 2500") + + if not 20 <= x_speed <= 25000: + raise ValueError("x_speed must be in range 20 to 25000") + + if not 1 <= number_of_shoots <= 48: + raise ValueError("number_of_shoots must be in range 1 to 48") + + if minimal_traverse_height_at_begin_of_command is None: + minimal_traverse_height_at_begin_of_command = [3600] * self.num_channels + elif not all(0 <= x <= 3600 for x in minimal_traverse_height_at_begin_of_command): + raise ValueError("minimal_traverse_height_at_begin_of_command must be in range 0 to 3600") + + if minimal_height_at_command_end is None: + minimal_height_at_command_end = [3600] * self.num_channels + elif not all(0 <= x <= 3600 for x in minimal_height_at_command_end): + raise ValueError("minimal_height_at_command_end must be in range 0 to 3600") + + if y_position is None: + y_position = [3000] * self.num_channels + elif not all(0 <= x <= 6500 for x in y_position): + raise ValueError("y_position must be in range 0 to 6500") + + if liquid_surface_at_function_without_lld is None: + liquid_surface_at_function_without_lld = [3600] * self.num_channels + elif not all(0 <= x <= 3600 for x in liquid_surface_at_function_without_lld): + raise ValueError("liquid_surface_at_function_without_lld must be in range 0 to 3600") + + if dispense_volume is None: + dispense_volume = [0] * self.num_channels + elif not all(0 <= x <= 125000 for x in dispense_volume): + raise ValueError("dispense_volume must be in range 0 to 125000") + + if dispense_speed is None: + dispense_speed = [500] * self.num_channels + elif not all(10 <= x <= 10000 for x in dispense_speed): + raise ValueError("dispense_speed must be in range 10 to 10000") + + if cut_off_speed is None: + cut_off_speed = [250] * self.num_channels + elif not all(10 <= x <= 10000 for x in cut_off_speed): + raise ValueError("cut_off_speed must be in range 10 to 10000") + + if stop_back_volume is None: + stop_back_volume = [0] * self.num_channels + elif not all(0 <= x <= 180 for x in stop_back_volume): + raise ValueError("stop_back_volume must be in range 0 to 180") + + if transport_air_volume is None: + transport_air_volume = [0] * self.num_channels + elif not all(0 <= x <= 500 for x in transport_air_volume): + raise ValueError("transport_air_volume must be in range 0 to 500") + + if not 0 <= tadm_algorithm_on_off <= 1: + raise ValueError("tadm_algorithm_on_off must be in range 0 to 1") + + if limit_curve_index is None: + limit_curve_index = [0] * self.num_channels + elif not all(0 <= x <= 999 for x in limit_curve_index): + raise ValueError("limit_curve_index must be in range 0 to 999") + + if not 0 <= recording_mode <= 2: + raise ValueError("recording_mode must be in range 0 to 2") + + return await self.send_command( + module="A1PM", + command="DF", + tm=tip_pattern, + xa=first_shoot_x_pos, + xf=dispense_on_fly_pos_command_end, + xh=x_acceleration_distance_before_first_shoot, + xy=space_between_shoots, + xv=x_speed, + xi=number_of_shoots, + th=minimal_traverse_height_at_begin_of_command, + te=minimal_height_at_command_end, + yp=y_position, + zl=liquid_surface_at_function_without_lld, + dv=dispense_volume, + ds=dispense_speed, + ss=cut_off_speed, + rv=stop_back_volume, + ta=transport_air_volume, + gj=tadm_algorithm_on_off, + gi=limit_curve_index, + gk=recording_mode, + ) + + @action(auto_prefix=True, description="执行纳升级脉冲分液") + async def nano_pulse_dispense( + self, + x_position: List[int], + y_position: List[int], + TODO_DB_0: Optional[List[int]] = None, + liquid_surface_at_function_without_lld: Optional[List[int]] = None, + minimal_traverse_height_at_begin_of_command: Optional[List[int]] = None, + minimal_height_at_command_end: Optional[List[int]] = None, + TODO_DB_1: Optional[List[int]] = None, + TODO_DB_2: Optional[List[int]] = None, + TODO_DB_3: Optional[List[int]] = None, + TODO_DB_4: Optional[List[int]] = None, + TODO_DB_5: Optional[List[int]] = None, + TODO_DB_6: Optional[List[int]] = None, + TODO_DB_7: Optional[List[int]] = None, + TODO_DB_8: Optional[List[int]] = None, + TODO_DB_9: Optional[List[int]] = None, + TODO_DB_10: Optional[List[int]] = None, + TODO_DB_11: Optional[List[int]] = None, + TODO_DB_12: Optional[List[int]] = None, + ): + _unilab_logger.debug("[UNILAB] VantageBackend.nano_pulse_dispense() called") + """Nano pulse dispense + + Args: + TODO_DB_0: (0). + x_position: X Position [0.1mm]. + y_position: Y Position [0.1mm]. + liquid_surface_at_function_without_lld: Liquid surface at function without LLD [0.1mm]. + minimal_traverse_height_at_begin_of_command: Minimal traverse height at begin of command + [0.1mm]. + minimal_height_at_command_end: Minimal height at command end [0.1mm]. + TODO_DB_1: (0). + TODO_DB_2: (0). + TODO_DB_3: (0). + TODO_DB_4: (0). + TODO_DB_5: (0). + TODO_DB_6: (0). + TODO_DB_7: (0). + TODO_DB_8: (0). + TODO_DB_9: (0). + TODO_DB_10: (0). + TODO_DB_11: (0). + TODO_DB_12: (0). + """ + + if TODO_DB_0 is None: + TODO_DB_0 = [1] * self.num_channels + elif not all(0 <= x <= 1 for x in TODO_DB_0): + raise ValueError("TODO_DB_0 must be in range 0 to 1") + + if not all(0 <= x <= 50000 for x in x_position): + raise ValueError("x_position must be in range 0 to 50000") + + if y_position is None: + y_position = [3000] * self.num_channels + elif not all(0 <= x <= 6500 for x in y_position): + raise ValueError("y_position must be in range 0 to 6500") + + if liquid_surface_at_function_without_lld is None: + liquid_surface_at_function_without_lld = [3600] * self.num_channels + elif not all(0 <= x <= 3600 for x in liquid_surface_at_function_without_lld): + raise ValueError("liquid_surface_at_function_without_lld must be in range 0 to 3600") + + if minimal_traverse_height_at_begin_of_command is None: + minimal_traverse_height_at_begin_of_command = [3600] * self.num_channels + elif not all(0 <= x <= 3600 for x in minimal_traverse_height_at_begin_of_command): + raise ValueError("minimal_traverse_height_at_begin_of_command must be in range 0 to 3600") + + if minimal_height_at_command_end is None: + minimal_height_at_command_end = [3600] * self.num_channels + elif not all(0 <= x <= 3600 for x in minimal_height_at_command_end): + raise ValueError("minimal_height_at_command_end must be in range 0 to 3600") + + if TODO_DB_1 is None: + TODO_DB_1 = [0] * self.num_channels + elif not all(0 <= x <= 20000 for x in TODO_DB_1): + raise ValueError("TODO_DB_1 must be in range 0 to 20000") + + if TODO_DB_2 is None: + TODO_DB_2 = [0] * self.num_channels + elif not all(0 <= x <= 1 for x in TODO_DB_2): + raise ValueError("TODO_DB_2 must be in range 0 to 1") + + if TODO_DB_3 is None: + TODO_DB_3 = [0] * self.num_channels + elif not all(0 <= x <= 10000 for x in TODO_DB_3): + raise ValueError("TODO_DB_3 must be in range 0 to 10000") + + if TODO_DB_4 is None: + TODO_DB_4 = [0] * self.num_channels + elif not all(0 <= x <= 100 for x in TODO_DB_4): + raise ValueError("TODO_DB_4 must be in range 0 to 100") + + if TODO_DB_5 is None: + TODO_DB_5 = [0] * self.num_channels + elif not all(0 <= x <= 1 for x in TODO_DB_5): + raise ValueError("TODO_DB_5 must be in range 0 to 1") + + if TODO_DB_6 is None: + TODO_DB_6 = [0] * self.num_channels + elif not all(0 <= x <= 10000 for x in TODO_DB_6): + raise ValueError("TODO_DB_6 must be in range 0 to 10000") + + if TODO_DB_7 is None: + TODO_DB_7 = [0] * self.num_channels + elif not all(0 <= x <= 100 for x in TODO_DB_7): + raise ValueError("TODO_DB_7 must be in range 0 to 100") + + if TODO_DB_8 is None: + TODO_DB_8 = [0] * self.num_channels + elif not all(0 <= x <= 1 for x in TODO_DB_8): + raise ValueError("TODO_DB_8 must be in range 0 to 1") + + if TODO_DB_9 is None: + TODO_DB_9 = [0] * self.num_channels + elif not all(0 <= x <= 10000 for x in TODO_DB_9): + raise ValueError("TODO_DB_9 must be in range 0 to 10000") + + if TODO_DB_10 is None: + TODO_DB_10 = [0] * self.num_channels + elif not all(0 <= x <= 100 for x in TODO_DB_10): + raise ValueError("TODO_DB_10 must be in range 0 to 100") + + if TODO_DB_11 is None: + TODO_DB_11 = [0] * self.num_channels + elif not all(0 <= x <= 3600 for x in TODO_DB_11): + raise ValueError("TODO_DB_11 must be in range 0 to 3600") + + if TODO_DB_12 is None: + TODO_DB_12 = [1] * self.num_channels + elif not all(0 <= x <= 1 for x in TODO_DB_12): + raise ValueError("TODO_DB_12 must be in range 0 to 1") + + return await self.send_command( + module="A1PM", + command="DB", + tm=TODO_DB_0, + xp=x_position, + yp=y_position, + zl=liquid_surface_at_function_without_lld, + th=minimal_traverse_height_at_begin_of_command, + te=minimal_height_at_command_end, + pe=TODO_DB_1, + pd=TODO_DB_2, + pf=TODO_DB_3, + pg=TODO_DB_4, + ph=TODO_DB_5, + pj=TODO_DB_6, + pk=TODO_DB_7, + pl=TODO_DB_8, + pp=TODO_DB_9, + pq=TODO_DB_10, + pi=TODO_DB_11, + pm=TODO_DB_12, + ) + + @action(auto_prefix=True, description="清洗吸头") + async def wash_tips( + self, + x_position: List[int], + y_position: List[int], + tip_pattern: Optional[List[bool]] = None, + minimal_traverse_height_at_begin_of_command: Optional[List[int]] = None, + liquid_surface_at_function_without_lld: Optional[List[int]] = None, + aspiration_volume: Optional[List[int]] = None, + aspiration_speed: Optional[List[int]] = None, + dispense_speed: Optional[List[int]] = None, + swap_speed: Optional[List[int]] = None, + soak_time: int = 0, + wash_cycles: int = 0, + minimal_height_at_command_end: Optional[List[int]] = None, + ): + _unilab_logger.debug("[UNILAB] VantageBackend.wash_tips() called") + """Wash tips + + Args: + tip_pattern: Tip pattern (channels involved). [0 = not involved, 1 = involved]. + x_position: X Position [0.1mm]. + y_position: Y Position [0.1mm]. + minimal_traverse_height_at_begin_of_command: Minimal traverse height at begin of command + [0.1mm]. + liquid_surface_at_function_without_lld: Liquid surface at function without LLD [0.1mm]. + aspiration_volume: Aspiration volume [0.01ul]. + aspiration_speed: Aspiration speed [0.1ul]/s. + dispense_speed: Dispense speed [0.1ul/s]. + swap_speed: Swap speed (on leaving liquid) [0.1mm/s]. + soak_time: (0). + wash_cycles: (0). + minimal_height_at_command_end: Minimal height at command end [0.1mm]. + """ + + if tip_pattern is None: + tip_pattern = [False] * self.num_channels + elif not all(0 <= x <= 1 for x in tip_pattern): + raise ValueError("tip_pattern must be in range 0 to 1") + + if not all(0 <= x <= 50000 for x in x_position): + raise ValueError("x_position must be in range 0 to 50000") + + if y_position is None: + y_position = [3000] * self.num_channels + elif not all(0 <= x <= 6500 for x in y_position): + raise ValueError("y_position must be in range 0 to 6500") + + if minimal_traverse_height_at_begin_of_command is None: + minimal_traverse_height_at_begin_of_command = [3600] * self.num_channels + elif not all(0 <= x <= 3600 for x in minimal_traverse_height_at_begin_of_command): + raise ValueError("minimal_traverse_height_at_begin_of_command must be in range 0 to 3600") + + if liquid_surface_at_function_without_lld is None: + liquid_surface_at_function_without_lld = [3600] * self.num_channels + elif not all(0 <= x <= 3600 for x in liquid_surface_at_function_without_lld): + raise ValueError("liquid_surface_at_function_without_lld must be in range 0 to 3600") + + if aspiration_volume is None: + aspiration_volume = [0] * self.num_channels + elif not all(0 <= x <= 125000 for x in aspiration_volume): + raise ValueError("aspiration_volume must be in range 0 to 125000") + + if aspiration_speed is None: + aspiration_speed = [500] * self.num_channels + elif not all(10 <= x <= 10000 for x in aspiration_speed): + raise ValueError("aspiration_speed must be in range 10 to 10000") + + if dispense_speed is None: + dispense_speed = [500] * self.num_channels + elif not all(10 <= x <= 10000 for x in dispense_speed): + raise ValueError("dispense_speed must be in range 10 to 10000") + + if swap_speed is None: + swap_speed = [100] * self.num_channels + elif not all(3 <= x <= 1600 for x in swap_speed): + raise ValueError("swap_speed must be in range 3 to 1600") + + if not 0 <= soak_time <= 3600: + raise ValueError("soak_time must be in range 0 to 3600") + + if not 0 <= wash_cycles <= 99: + raise ValueError("wash_cycles must be in range 0 to 99") + + if minimal_height_at_command_end is None: + minimal_height_at_command_end = [3600] * self.num_channels + elif not all(0 <= x <= 3600 for x in minimal_height_at_command_end): + raise ValueError("minimal_height_at_command_end must be in range 0 to 3600") + + return await self.send_command( + module="A1PM", + command="DW", + tm=tip_pattern, + xp=x_position, + yp=y_position, + th=minimal_traverse_height_at_begin_of_command, + zl=liquid_surface_at_function_without_lld, + av=aspiration_volume, + as_=aspiration_speed, + ds=dispense_speed, + de=swap_speed, + sa=soak_time, + dc=wash_cycles, + te=minimal_height_at_command_end, + ) + + @action(auto_prefix=True, description="使用移液模块拾取吸头") + async def pip_tip_pick_up( + self, + x_position: List[int], + y_position: List[int], + tip_pattern: Optional[List[bool]] = None, + tip_type: Optional[List[int]] = None, + begin_z_deposit_position: Optional[List[int]] = None, + end_z_deposit_position: Optional[List[int]] = None, + minimal_traverse_height_at_begin_of_command: Optional[List[int]] = None, + minimal_height_at_command_end: Optional[List[int]] = None, + blow_out_air_volume: Optional[List[int]] = None, + tip_handling_method: Optional[List[int]] = None, + ): + _unilab_logger.debug("[UNILAB] VantageBackend.pip_tip_pick_up() called") + """Tip Pick up + + Args: + x_position: X Position [0.1mm]. + y_position: Y Position [0.1mm]. + tip_pattern: Tip pattern (channels involved). [0 = not involved, 1 = involved]. + tip_type: Tip type (see command TT). + begin_z_deposit_position: (0). + end_z_deposit_position: Z deposit position [0.1mm] (collar bearing position). + minimal_traverse_height_at_begin_of_command: Minimal traverse height at begin of command + [0.1mm]. + minimal_height_at_command_end: Minimal height at command end [0.1mm]. + blow_out_air_volume: Blow out air volume [0.01ul]. + tip_handling_method: Tip handling method. (Unconfirmed, but likely: 0 = auto selection (see + command TT parameter tu), 1 = pick up out of rack, 2 = pick up out of wash liquid (slowly)) + """ + + if not all(0 <= x <= 50000 for x in x_position): + raise ValueError("x_position must be in range 0 to 50000") + + if y_position is None: + y_position = [3000] * self.num_channels + elif not all(0 <= x <= 6500 for x in y_position): + raise ValueError("y_position must be in range 0 to 6500") + + if tip_pattern is None: + tip_pattern = [False] * self.num_channels + elif not all(0 <= x <= 1 for x in tip_pattern): + raise ValueError("tip_pattern must be in range 0 to 1") + + if tip_type is None: + tip_type = [4] * self.num_channels + elif not all(0 <= x <= 199 for x in tip_type): + raise ValueError("tip_type must be in range 0 to 199") + + if begin_z_deposit_position is None: + begin_z_deposit_position = [0] * self.num_channels + elif not all(0 <= x <= 3600 for x in begin_z_deposit_position): + raise ValueError("begin_z_deposit_position must be in range 0 to 3600") + + if end_z_deposit_position is None: + end_z_deposit_position = [0] * self.num_channels + elif not all(0 <= x <= 3600 for x in end_z_deposit_position): + raise ValueError("end_z_deposit_position must be in range 0 to 3600") + + if minimal_traverse_height_at_begin_of_command is None: + minimal_traverse_height_at_begin_of_command = [3600] * self.num_channels + elif not all(0 <= x <= 3600 for x in minimal_traverse_height_at_begin_of_command): + raise ValueError("minimal_traverse_height_at_begin_of_command must be in range 0 to 3600") + + if minimal_height_at_command_end is None: + minimal_height_at_command_end = [3600] * self.num_channels + elif not all(0 <= x <= 3600 for x in minimal_height_at_command_end): + raise ValueError("minimal_height_at_command_end must be in range 0 to 3600") + + if blow_out_air_volume is None: + blow_out_air_volume = [0] * self.num_channels + elif not all(0 <= x <= 125000 for x in blow_out_air_volume): + raise ValueError("blow_out_air_volume must be in range 0 to 125000") + + if tip_handling_method is None: + tip_handling_method = [0] * self.num_channels + elif not all(0 <= x <= 9 for x in tip_handling_method): + raise ValueError("tip_handling_method must be in range 0 to 9") + + return await self.send_command( + module="A1PM", + command="TP", + xp=x_position, + yp=y_position, + tm=tip_pattern, + tt=tip_type, + tp=begin_z_deposit_position, + tz=end_z_deposit_position, + th=minimal_traverse_height_at_begin_of_command, + te=minimal_height_at_command_end, + ba=blow_out_air_volume, + td=tip_handling_method, + ) + + @action(auto_prefix=True, description="使用移液模块弃去吸头") + async def pip_tip_discard( + self, + x_position: List[int], + y_position: List[int], + begin_z_deposit_position: Optional[List[int]] = None, + end_z_deposit_position: Optional[List[int]] = None, + minimal_traverse_height_at_begin_of_command: Optional[List[int]] = None, + minimal_height_at_command_end: Optional[List[int]] = None, + tip_pattern: Optional[List[bool]] = None, + TODO_TR_2: int = 0, + tip_handling_method: Optional[List[int]] = None, + ): + _unilab_logger.debug("[UNILAB] VantageBackend.pip_tip_discard() called") + """Tip Discard + + Args: + x_position: X Position [0.1mm]. + y_position: Y Position [0.1mm]. + begin_z_deposit_position: (0). + end_z_deposit_position: Z deposit position [0.1mm] (collar bearing position). + minimal_traverse_height_at_begin_of_command: Minimal traverse height at begin of command + [0.1mm]. + minimal_height_at_command_end: Minimal height at command end [0.1mm]. + tip_pattern: Tip pattern (channels involved). [0 = not involved, 1 = involved]. + TODO_TR_2: (0). + tip_handling_method: Tip handling method. + """ + + if not all(0 <= x <= 50000 for x in x_position): + raise ValueError("x_position must be in range 0 to 50000") + + if y_position is None: + y_position = [3000] * self.num_channels + elif not all(0 <= x <= 6500 for x in y_position): + raise ValueError("y_position must be in range 0 to 6500") + + if begin_z_deposit_position is None: + begin_z_deposit_position = [0] * self.num_channels + elif not all(0 <= x <= 3600 for x in begin_z_deposit_position): + raise ValueError("begin_z_deposit_position must be in range 0 to 3600") + + if end_z_deposit_position is None: + end_z_deposit_position = [0] * self.num_channels + elif not all(0 <= x <= 3600 for x in end_z_deposit_position): + raise ValueError("end_z_deposit_position must be in range 0 to 3600") + + if minimal_traverse_height_at_begin_of_command is None: + minimal_traverse_height_at_begin_of_command = [3600] * self.num_channels + elif not all(0 <= x <= 3600 for x in minimal_traverse_height_at_begin_of_command): + raise ValueError("minimal_traverse_height_at_begin_of_command must be in range 0 to 3600") + + if minimal_height_at_command_end is None: + minimal_height_at_command_end = [3600] * self.num_channels + elif not all(0 <= x <= 3600 for x in minimal_height_at_command_end): + raise ValueError("minimal_height_at_command_end must be in range 0 to 3600") + + if tip_pattern is None: + tip_pattern = [False] * self.num_channels + elif not all(0 <= x <= 1 for x in tip_pattern): + raise ValueError("tip_pattern must be in range 0 to 1") + + if not -1000 <= TODO_TR_2 <= 1000: + raise ValueError("TODO_TR_2 must be in range -1000 to 1000") + + if tip_handling_method is None: + tip_handling_method = [0] * self.num_channels + elif not all(0 <= x <= 9 for x in tip_handling_method): + raise ValueError("tip_handling_method must be in range 0 to 9") + + return await self.send_command( + module="A1PM", + command="TR", + xp=x_position, + yp=y_position, + tp=begin_z_deposit_position, + tz=end_z_deposit_position, + th=minimal_traverse_height_at_begin_of_command, + te=minimal_height_at_command_end, + tm=tip_pattern, + ts=TODO_TR_2, + td=tip_handling_method, + ) + + @action(auto_prefix=True, description="在 X 方向搜索示教信号") + async def search_for_teach_in_signal_in_x_direction( + self, + channel_index: int = 1, + x_search_distance: int = 0, + x_speed: int = 270, + ): + _unilab_logger.debug("[UNILAB] VantageBackend.search_for_teach_in_signal_in_x_direction() called") + """Search for Teach in signal in X direction + + Args: + channel_index: Channel index. + x_search_distance: X search distance [0.1mm]. + x_speed: X speed [0.1mm/s]. + """ + + if not 1 <= channel_index <= 16: + raise ValueError("channel_index must be in range 1 to 16") + + if not -50000 <= x_search_distance <= 50000: + raise ValueError("x_search_distance must be in range -50000 to 50000") + + if not 20 <= x_speed <= 25000: + raise ValueError("x_speed must be in range 20 to 25000") + + return await self.send_command( + module="A1PM", + command="DL", + pn=channel_index, + xs=x_search_distance, + xv=x_speed, + ) + + @action(auto_prefix=True, description="将所有通道移动到同一 Y 位置") + async def position_all_channels_in_y_direction( + self, + y_position: List[int], + ): + _unilab_logger.debug("[UNILAB] VantageBackend.position_all_channels_in_y_direction() called") + """Position all channels in Y direction + + Args: + y_position: Y Position [0.1mm]. + """ + + if y_position is None: + y_position = [3000] * self.num_channels + elif not all(0 <= x <= 6500 for x in y_position): + raise ValueError("y_position must be in range 0 to 6500") + + return await self.send_command( + module="A1PM", + command="DY", + yp=y_position, + ) + + @action(auto_prefix=True, description="将所有通道移动到同一 Z 位置") + async def position_all_channels_in_z_direction( + self, + z_position: Optional[List[int]] = None, + ): + _unilab_logger.debug("[UNILAB] VantageBackend.position_all_channels_in_z_direction() called") + """Position all channels in Z direction + + Args: + z_position: Z Position [0.1mm]. + """ + + if z_position is None: + z_position = [0] * self.num_channels + elif not all(0 <= x <= 3600 for x in z_position): + raise ValueError("z_position must be in range 0 to 3600") + + return await self.send_command( + module="A1PM", + command="DZ", + zp=z_position, + ) + + @action(auto_prefix=True, description="将单个通道移动到 Y 位置") + async def position_single_channel_in_y_direction( + self, + channel_index: int = 1, + y_position: int = 3000, + ): + _unilab_logger.debug("[UNILAB] VantageBackend.position_single_channel_in_y_direction() called") + """Position single channel in Y direction + + Args: + channel_index: Channel index. + y_position: Y Position [0.1mm]. + """ + + if not 1 <= channel_index <= 16: + raise ValueError("channel_index must be in range 1 to 16") + + if not 0 <= y_position <= 6500: + raise ValueError("y_position must be in range 0 to 6500") + + return await self.send_command( + module="A1PM", + command="DV", + pn=channel_index, + yj=y_position, + ) + + @action(auto_prefix=True, description="将单个通道移动到 Z 位置") + async def position_single_channel_in_z_direction( + self, + channel_index: int = 1, + z_position: int = 0, + ): + _unilab_logger.debug("[UNILAB] VantageBackend.position_single_channel_in_z_direction() called") + """Position single channel in Z direction + + Args: + channel_index: Channel index. + z_position: Z Position [0.1mm]. + """ + + if not 1 <= channel_index <= 16: + raise ValueError("channel_index must be in range 1 to 16") + + if not 0 <= z_position <= 3600: + raise ValueError("z_position must be in range 0 to 3600") + + return await self.send_command( + module="A1PM", + command="DU", + pn=channel_index, + zj=z_position, + ) + + @action(auto_prefix=True, description="将移液模块移动到指定位置") + async def move_to_defined_position( + self, + x_position: List[int], + y_position: List[int], + tip_pattern: Optional[List[bool]] = None, + minimal_traverse_height_at_begin_of_command: Optional[List[int]] = None, + z_position: Optional[List[int]] = None, + ): + _unilab_logger.debug("[UNILAB] VantageBackend.move_to_defined_position() called") + """Move to defined position + + Args: + tip_pattern: Tip pattern (channels involved). [0 = not involved, 1 = involved]. + x_position: X Position [0.1mm]. + y_position: Y Position [0.1mm]. + minimal_traverse_height_at_begin_of_command: Minimal traverse height at begin of command + [0.1mm]. + z_position: Z Position [0.1mm]. + """ + + if tip_pattern is None: + tip_pattern = [False] * self.num_channels + elif not all(0 <= x <= 1 for x in tip_pattern): + raise ValueError("tip_pattern must be in range 0 to 1") + + if not all(0 <= x <= 50000 for x in x_position): + raise ValueError("x_position must be in range 0 to 50000") + + if y_position is None: + y_position = [3000] * self.num_channels + elif not all(0 <= x <= 6500 for x in y_position): + raise ValueError("y_position must be in range 0 to 6500") + + if minimal_traverse_height_at_begin_of_command is None: + minimal_traverse_height_at_begin_of_command = [3600] * self.num_channels + elif not all(0 <= x <= 3600 for x in minimal_traverse_height_at_begin_of_command): + raise ValueError("minimal_traverse_height_at_begin_of_command must be in range 0 to 3600") + + if z_position is None: + z_position = [0] * self.num_channels + elif not all(0 <= x <= 3600 for x in z_position): + raise ValueError("z_position must be in range 0 to 3600") + + return await self.send_command( + module="A1PM", + command="DN", + tm=tip_pattern, + xp=x_position, + yp=y_position, + th=minimal_traverse_height_at_begin_of_command, + zp=z_position, + ) + + @action(auto_prefix=True, description="使用指定通道对架位进行示教") + async def teach_rack_using_channel_n( + self, + channel_index: int = 1, + gap_center_x_direction: int = 0, + gap_center_y_direction: int = 3000, + gap_center_z_direction: int = 0, + minimal_height_at_command_end: Optional[List[int]] = None, + ): + _unilab_logger.debug("[UNILAB] VantageBackend.teach_rack_using_channel_n() called") + """Teach rack using channel n + + Attention! Channels not involved must first be taken out of measurement range. + + Args: + channel_index: Channel index. + gap_center_x_direction: Gap center X direction [0.1mm]. + gap_center_y_direction: Gap center Y direction [0.1mm]. + gap_center_z_direction: Gap center Z direction [0.1mm]. + minimal_height_at_command_end: Minimal height at command end [0.1mm]. + """ + + if not 1 <= channel_index <= 16: + raise ValueError("channel_index must be in range 1 to 16") + + if not -50000 <= gap_center_x_direction <= 50000: + raise ValueError("gap_center_x_direction must be in range -50000 to 50000") + + if not 0 <= gap_center_y_direction <= 6500: + raise ValueError("gap_center_y_direction must be in range 0 to 6500") + + if not 0 <= gap_center_z_direction <= 3600: + raise ValueError("gap_center_z_direction must be in range 0 to 3600") + + if minimal_height_at_command_end is None: + minimal_height_at_command_end = [3600] * self.num_channels + elif not all(0 <= x <= 3600 for x in minimal_height_at_command_end): + raise ValueError("minimal_height_at_command_end must be in range 0 to 3600") + + return await self.send_command( + module="A1PM", + command="DT", + pn=channel_index, + xa=gap_center_x_direction, + yj=gap_center_y_direction, + zj=gap_center_z_direction, + te=minimal_height_at_command_end, + ) + + @action(auto_prefix=True, description="暴露指定通道") + async def expose_channel_n( + self, + channel_index: int = 1, + ): + _unilab_logger.debug("[UNILAB] VantageBackend.expose_channel_n() called") + """Expose channel n + + Args: + channel_index: Channel index. + """ + + if not 1 <= channel_index <= 16: + raise ValueError("channel_index must be in range 1 to 16") + + return await self.send_command( + module="A1PM", + command="DQ", + pn=channel_index, + ) + + @action(auto_prefix=True, description="计算校验和并与 Flash EPROM 中保存的数值比较") + async def calculates_check_sums_and_compares_them_with_the_value_saved_in_flash_eprom( + self, + TODO_DC_0: int = 0, + TODO_DC_1: int = 3000, + tip_type: Optional[List[int]] = None, + TODO_DC_2: Optional[List[int]] = None, + z_deposit_position: Optional[List[int]] = None, + minimal_traverse_height_at_begin_of_command: Optional[List[int]] = None, + first_pip_channel_node_no: int = 1, + ): + _unilab_logger.debug("[UNILAB] VantageBackend.calculates_check_sums_and_compares_them_with_the_value_saved_in_flash_eprom() called") + """Calculates check sums and compares them with the value saved in Flash EPROM + + Args: + TODO_DC_0: (0). + TODO_DC_1: (0). + tip_type: Tip type (see command TT). + TODO_DC_2: (0). + z_deposit_position: Z deposit position [0.1mm] (collar bearing position). + minimal_traverse_height_at_begin_of_command: Minimal traverse height at begin of command + [0.1mm]. + first_pip_channel_node_no: First (lower) pip. channel node no. (0 = disabled). + """ + + if not -50000 <= TODO_DC_0 <= 50000: + raise ValueError("TODO_DC_0 must be in range -50000 to 50000") + + if not 0 <= TODO_DC_1 <= 6500: + raise ValueError("TODO_DC_1 must be in range 0 to 6500") + + if tip_type is None: + tip_type = [4] * self.num_channels + elif not all(0 <= x <= 199 for x in tip_type): + raise ValueError("tip_type must be in range 0 to 199") + + if TODO_DC_2 is None: + TODO_DC_2 = [0] * self.num_channels + elif not all(0 <= x <= 3600 for x in TODO_DC_2): + raise ValueError("TODO_DC_2 must be in range 0 to 3600") + + if z_deposit_position is None: + z_deposit_position = [0] * self.num_channels + elif not all(0 <= x <= 3600 for x in z_deposit_position): + raise ValueError("z_deposit_position must be in range 0 to 3600") + + if minimal_traverse_height_at_begin_of_command is None: + minimal_traverse_height_at_begin_of_command = [3600] * self.num_channels + elif not all(0 <= x <= 3600 for x in minimal_traverse_height_at_begin_of_command): + raise ValueError("minimal_traverse_height_at_begin_of_command must be in range 0 to 3600") + + if not 1 <= first_pip_channel_node_no <= 16: + raise ValueError("first_pip_channel_node_no must be in range 1 to 16") + + return await self.send_command( + module="A1PM", + command="DC", + xa=TODO_DC_0, + yj=TODO_DC_1, + tt=tip_type, + tp=TODO_DC_2, + tz=z_deposit_position, + th=minimal_traverse_height_at_begin_of_command, + pa=first_pip_channel_node_no, + ) + + @action(auto_prefix=True, description="弃去 CoRe 夹爪工具") + async def discard_core_gripper_tool( + self, + gripper_tool_x_position: int = 0, + first_gripper_tool_y_pos: int = 3000, + tip_type: Optional[List[int]] = None, + begin_z_deposit_position: Optional[List[int]] = None, + end_z_deposit_position: Optional[List[int]] = None, + minimal_traverse_height_at_begin_of_command: Optional[List[int]] = None, + first_pip_channel_node_no: int = 1, + minimal_height_at_command_end: Optional[List[int]] = None, + ): + _unilab_logger.debug("[UNILAB] VantageBackend.discard_core_gripper_tool() called") + """Discard CoRe gripper tool + + Args: + gripper_tool_x_position: (0). + first_gripper_tool_y_pos: First (lower channel) CoRe gripper tool Y pos. [0.1mm] + tip_type: Tip type (see command TT). + begin_z_deposit_position: (0). + end_z_deposit_position: Z deposit position [0.1mm] (collar bearing position). + minimal_traverse_height_at_begin_of_command: Minimal traverse height at begin of command + [0.1mm]. + first_pip_channel_node_no: First (lower) pip. channel node no. (0 = disabled). + minimal_height_at_command_end: Minimal height at command end [0.1mm]. + """ + + if not -50000 <= gripper_tool_x_position <= 50000: + raise ValueError("gripper_tool_x_position must be in range -50000 to 50000") + + if not 0 <= first_gripper_tool_y_pos <= 6500: + raise ValueError("first_gripper_tool_y_pos must be in range 0 to 6500") + + if tip_type is None: + tip_type = [4] * self.num_channels + elif not all(0 <= x <= 199 for x in tip_type): + raise ValueError("tip_type must be in range 0 to 199") + + if begin_z_deposit_position is None: + begin_z_deposit_position = [0] * self.num_channels + elif not all(0 <= x <= 3600 for x in begin_z_deposit_position): + raise ValueError("begin_z_deposit_position must be in range 0 to 3600") + + if end_z_deposit_position is None: + end_z_deposit_position = [0] * self.num_channels + elif not all(0 <= x <= 3600 for x in end_z_deposit_position): + raise ValueError("end_z_deposit_position must be in range 0 to 3600") + + if minimal_traverse_height_at_begin_of_command is None: + minimal_traverse_height_at_begin_of_command = [3600] * self.num_channels + elif not all(0 <= x <= 3600 for x in minimal_traverse_height_at_begin_of_command): + raise ValueError("minimal_traverse_height_at_begin_of_command must be in range 0 to 3600") + + if not 1 <= first_pip_channel_node_no <= 16: + raise ValueError("first_pip_channel_node_no must be in range 1 to 16") + + if minimal_height_at_command_end is None: + minimal_height_at_command_end = [3600] * self.num_channels + elif not all(0 <= x <= 3600 for x in minimal_height_at_command_end): + raise ValueError("minimal_height_at_command_end must be in range 0 to 3600") + + return await self.send_command( + module="A1PM", + command="DJ", + xa=gripper_tool_x_position, + yj=first_gripper_tool_y_pos, + tt=tip_type, + tp=begin_z_deposit_position, + tz=end_z_deposit_position, + th=minimal_traverse_height_at_begin_of_command, + pa=first_pip_channel_node_no, + te=minimal_height_at_command_end, + ) + + @action(auto_prefix=True, description="夹取微孔板") + async def grip_plate( + self, + plate_center_x_direction: int = 0, + plate_center_y_direction: int = 3000, + plate_center_z_direction: int = 0, + z_speed: int = 1287, + open_gripper_position: int = 860, + plate_width: int = 800, + acceleration_index: int = 4, + grip_strength: int = 30, + minimal_traverse_height_at_begin_of_command: Optional[List[int]] = None, + minimal_height_at_command_end: Optional[List[int]] = None, + ): + _unilab_logger.debug("[UNILAB] VantageBackend.grip_plate() called") + """Grip plate + + Args: + plate_center_x_direction: Plate center X direction [0.1mm]. + plate_center_y_direction: Plate center Y direction [0.1mm]. + plate_center_z_direction: Plate center Z direction [0.1mm]. + z_speed: Z speed [0.1mm/sec]. + open_gripper_position: Open gripper position [0.1mm]. + plate_width: Plate width [0.1mm]. + acceleration_index: Acceleration index. + grip_strength: Grip strength (0 = low 99 = high). + minimal_traverse_height_at_begin_of_command: Minimal traverse height at begin of command + [0.1mm]. + minimal_height_at_command_end: Minimal height at command end [0.1mm]. + """ + + if not -50000 <= plate_center_x_direction <= 50000: + raise ValueError("plate_center_x_direction must be in range -50000 to 50000") + + if not 0 <= plate_center_y_direction <= 6500: + raise ValueError("plate_center_y_direction must be in range 0 to 6500") + + if not 0 <= plate_center_z_direction <= 3600: + raise ValueError("plate_center_z_direction must be in range 0 to 3600") + + if not 3 <= z_speed <= 1600: + raise ValueError("z_speed must be in range 3 to 1600") + + if not 0 <= open_gripper_position <= 9999: + raise ValueError("open_gripper_position must be in range 0 to 9999") + + if not 0 <= plate_width <= 9999: + raise ValueError("plate_width must be in range 0 to 9999") + + if not 0 <= acceleration_index <= 4: + raise ValueError("acceleration_index must be in range 0 to 4") + + if not 0 <= grip_strength <= 99: + raise ValueError("grip_strength must be in range 0 to 99") + + if minimal_traverse_height_at_begin_of_command is None: + minimal_traverse_height_at_begin_of_command = [3600] * self.num_channels + elif not all(0 <= x <= 3600 for x in minimal_traverse_height_at_begin_of_command): + raise ValueError("minimal_traverse_height_at_begin_of_command must be in range 0 to 3600") + + if minimal_height_at_command_end is None: + minimal_height_at_command_end = [3600] * self.num_channels + elif not all(0 <= x <= 3600 for x in minimal_height_at_command_end): + raise ValueError("minimal_height_at_command_end must be in range 0 to 3600") + + return await self.send_command( + module="A1PM", + command="DG", + xa=plate_center_x_direction, + yj=plate_center_y_direction, + zj=plate_center_z_direction, + zy=z_speed, + yo=open_gripper_position, + yg=plate_width, + ai=acceleration_index, + yw=grip_strength, + th=minimal_traverse_height_at_begin_of_command, + te=minimal_height_at_command_end, + ) + + @action(auto_prefix=True, description="放置微孔板") + async def put_plate( + self, + plate_center_x_direction: int = 0, + plate_center_y_direction: int = 3000, + plate_center_z_direction: int = 0, + press_on_distance: int = 5, + z_speed: int = 1287, + open_gripper_position: int = 860, + minimal_traverse_height_at_begin_of_command: Optional[List[int]] = None, + minimal_height_at_command_end: Optional[List[int]] = None, + ): + _unilab_logger.debug("[UNILAB] VantageBackend.put_plate() called") + """Put plate + + Args: + plate_center_x_direction: Plate center X direction [0.1mm]. + plate_center_y_direction: Plate center Y direction [0.1mm]. + plate_center_z_direction: Plate center Z direction [0.1mm]. + press_on_distance: Press on distance [0.1mm]. + z_speed: Z speed [0.1mm/sec]. + open_gripper_position: Open gripper position [0.1mm]. + minimal_traverse_height_at_begin_of_command: Minimal traverse height at begin of command + [0.1mm]. + minimal_height_at_command_end: Minimal height at command end [0.1mm]. + """ + + if not -50000 <= plate_center_x_direction <= 50000: + raise ValueError("plate_center_x_direction must be in range -50000 to 50000") + + if not 0 <= plate_center_y_direction <= 6500: + raise ValueError("plate_center_y_direction must be in range 0 to 6500") + + if not 0 <= plate_center_z_direction <= 3600: + raise ValueError("plate_center_z_direction must be in range 0 to 3600") + + if not 0 <= press_on_distance <= 999: + raise ValueError("press_on_distance must be in range 0 to 999") + + if not 3 <= z_speed <= 1600: + raise ValueError("z_speed must be in range 3 to 1600") + + if not 0 <= open_gripper_position <= 9999: + raise ValueError("open_gripper_position must be in range 0 to 9999") + + if minimal_traverse_height_at_begin_of_command is None: + minimal_traverse_height_at_begin_of_command = [3600] * self.num_channels + elif not all(0 <= x <= 3600 for x in minimal_traverse_height_at_begin_of_command): + raise ValueError("minimal_traverse_height_at_begin_of_command must be in range 0 to 3600") + + if minimal_height_at_command_end is None: + minimal_height_at_command_end = [3600] * self.num_channels + elif not all(0 <= x <= 3600 for x in minimal_height_at_command_end): + raise ValueError("minimal_height_at_command_end must be in range 0 to 3600") + + return await self.send_command( + module="A1PM", + command="DR", + xa=plate_center_x_direction, + yj=plate_center_y_direction, + zj=plate_center_z_direction, + zi=press_on_distance, + zy=z_speed, + yo=open_gripper_position, + th=minimal_traverse_height_at_begin_of_command, + te=minimal_height_at_command_end, + ) + + @action(auto_prefix=True, description="将夹爪移动到指定板位") + async def move_to_position( + self, + plate_center_x_direction: int = 0, + plate_center_y_direction: int = 3000, + plate_center_z_direction: int = 0, + z_speed: int = 1287, + minimal_traverse_height_at_begin_of_command: Optional[List[int]] = None, + ): + _unilab_logger.debug("[UNILAB] VantageBackend.move_to_position() called") + """Move to position + + Args: + plate_center_x_direction: Plate center X direction [0.1mm]. + plate_center_y_direction: Plate center Y direction [0.1mm]. + plate_center_z_direction: Plate center Z direction [0.1mm]. + z_speed: Z speed [0.1mm/sec]. + minimal_traverse_height_at_begin_of_command: Minimal traverse height at begin of command + [0.1mm]. + """ + + if not -50000 <= plate_center_x_direction <= 50000: + raise ValueError("plate_center_x_direction must be in range -50000 to 50000") + + if not 0 <= plate_center_y_direction <= 6500: + raise ValueError("plate_center_y_direction must be in range 0 to 6500") + + if not 0 <= plate_center_z_direction <= 3600: + raise ValueError("plate_center_z_direction must be in range 0 to 3600") + + if not 3 <= z_speed <= 1600: + raise ValueError("z_speed must be in range 3 to 1600") + + if minimal_traverse_height_at_begin_of_command is None: + minimal_traverse_height_at_begin_of_command = [3600] * self.num_channels + elif not all(0 <= x <= 3600 for x in minimal_traverse_height_at_begin_of_command): + raise ValueError("minimal_traverse_height_at_begin_of_command must be in range 0 to 3600") + + return await self.send_command( + module="A1PM", + command="DH", + xa=plate_center_x_direction, + yj=plate_center_y_direction, + zj=plate_center_z_direction, + zy=z_speed, + th=minimal_traverse_height_at_begin_of_command, + ) + + @action(auto_prefix=True, description="释放当前夹持的物体") + async def release_object( + self, + first_pip_channel_node_no: int = 1, + ): + _unilab_logger.debug("[UNILAB] VantageBackend.release_object() called") + """Release object + + Args: + first_pip_channel_node_no: First (lower) pip. channel node no. (0 = disabled). + """ + + if not 1 <= first_pip_channel_node_no <= 16: + raise ValueError("first_pip_channel_node_no must be in range 1 to 16") + + return await self.send_command( + module="A1PM", + command="DO", + pa=first_pip_channel_node_no, + ) + + @action(auto_prefix=True, description="设置本模块参数") + async def set_any_parameter_within_this_module(self): + _unilab_logger.debug("[UNILAB] VantageBackend.set_any_parameter_within_this_module() called") + """Set any parameter within this module""" + + return await self.send_command( + module="A1PM", + command="AA", + ) + + @action(auto_prefix=True, description="获取所有通道的 Y 位置") + async def request_y_positions_of_all_channels(self): + _unilab_logger.debug("[UNILAB] VantageBackend.request_y_positions_of_all_channels() called") + """Request Y Positions of all channels""" + + return await self.send_command( + module="A1PM", + command="RY", + ) + + @action(auto_prefix=True, description="获取指定通道的 Y 位置") + async def request_y_position_of_channel_n(self, channel_index: int = 1): + _unilab_logger.debug("[UNILAB] VantageBackend.request_y_position_of_channel_n() called") + """Request Y Position of channel n""" + + return await self.send_command( + module="A1PM", + command="RB", + pn=channel_index, + ) + + @action(auto_prefix=True, description="获取所有通道的 Z 位置") + async def request_z_positions_of_all_channels(self): + _unilab_logger.debug("[UNILAB] VantageBackend.request_z_positions_of_all_channels() called") + """Request Z Positions of all channels""" + + return await self.send_command( + module="A1PM", + command="RZ", + ) + + @action(auto_prefix=True, description="获取指定通道的 Z 位置") + async def request_z_position_of_channel_n(self, channel_index: int = 1): + _unilab_logger.debug("[UNILAB] VantageBackend.request_z_position_of_channel_n() called") + """Request Z Position of channel n""" + + return await self.send_command( + module="A1PM", + command="RD", + pn=channel_index, + ) + + @action(auto_prefix=True, description="查询移液通道的吸头在位状态") + async def query_tip_presence(self) -> List[bool]: + _unilab_logger.debug("[UNILAB] VantageBackend.query_tip_presence() called") + """Query Tip presence""" + + resp = await self.send_command(module="A1PM", command="QA", fmt={"rt": "[int]"}) + presences_int = cast(List[int], resp["rt"]) + return [bool(p) for p in presences_int] + + @action(auto_prefix=True, description="获取上一次液面探测高度") + async def request_height_of_last_lld(self): + _unilab_logger.debug("[UNILAB] VantageBackend.request_height_of_last_lld() called") + """Request height of last LLD""" + + return await self.send_command( + module="A1PM", + command="RL", + ) + + @action(auto_prefix=True, description="获取通道飞行分液状态") + async def request_channel_dispense_on_fly_status(self): + _unilab_logger.debug("[UNILAB] VantageBackend.request_channel_dispense_on_fly_status() called") + """Request channel dispense on fly status""" + + return await self.send_command( + module="A1PM", + command="QF", + ) + + @action(auto_prefix=True, description="获取 CoRe96 头初始化状态") + async def core96_request_initialization_status(self) -> bool: + _unilab_logger.debug("[UNILAB] VantageBackend.core96_request_initialization_status() called") + """Request CoRe96 initialization status + + This method is inferred from I1AM and A1AM commands ("QW"). + + Returns: + bool: True if initialized, False otherwise. + """ + + resp = await self.send_command(module="A1HM", command="QW", fmt={"qw": "int"}) + return resp is not None and resp["qw"] == 1 + + @action(auto_prefix=True, description="初始化 CoRe96 头") + async def core96_initialize( + self, + x_position: int = 5000, + y_position: int = 5000, + z_position: int = 0, + minimal_traverse_height_at_begin_of_command: int = 3900, + minimal_height_at_command_end: int = 3900, + end_z_deposit_position: int = 0, + tip_type: int = 4, + ): + _unilab_logger.debug("[UNILAB] VantageBackend.core96_initialize() called") + """Initialize 96 head. + + Args: + x_position: X Position [0.1mm]. + y_position: Y Position [0.1mm]. + z_position: Z Position [0.1mm]. + minimal_traverse_height_at_begin_of_command: Minimal traverse height at begin of command + [0.1mm]. + minimal_height_at_command_end: Minimal height at command end [0.1mm]. + end_z_deposit_position: Z deposit position [0.1mm] (collar bearing position). (not documented, + but present in the log files.) + tip_type: Tip type (see command TT). + """ + + if not -500000 <= x_position <= 50000: + raise ValueError("x_position must be in range -500000 to 50000") + + if not 422 <= y_position <= 5921: + raise ValueError("y_position must be in range 422 to 5921") + + if not 0 <= z_position <= 3900: + raise ValueError("z_position must be in range 0 to 3900") + + if not 0 <= minimal_traverse_height_at_begin_of_command <= 3900: + raise ValueError("minimal_traverse_height_at_begin_of_command must be in range 0 to 3900") + + if not 0 <= minimal_height_at_command_end <= 3900: + raise ValueError("minimal_height_at_command_end must be in range 0 to 3900") + + if not 0 <= end_z_deposit_position <= 3600: + raise ValueError("end_z_deposit_position must be in range 0 to 3600") + + if not 0 <= tip_type <= 199: + raise ValueError("tip_type must be in range 0 to 199") + + return await self.send_command( + module="A1HM", + command="DI", + xp=x_position, + yp=y_position, + zp=z_position, + th=minimal_traverse_height_at_begin_of_command, + te=minimal_height_at_command_end, + tz=end_z_deposit_position, + tt=tip_type, + ) + + @action(auto_prefix=True, description="使用 CoRe96 头吸液") + async def core96_aspiration_of_liquid( + self, + type_of_aspiration: int = 0, + x_position: int = 5000, + y_position: int = 5000, + minimal_traverse_height_at_begin_of_command: int = 3900, + minimal_height_at_command_end: int = 3900, + lld_search_height: int = 0, + liquid_surface_at_function_without_lld: int = 3900, + pull_out_distance_to_take_transport_air_in_function_without_lld: int = 50, + minimum_height: int = 3900, + tube_2nd_section_height_measured_from_zm: int = 0, + tube_2nd_section_ratio: int = 0, + immersion_depth: int = 0, + surface_following_distance: int = 0, + aspiration_volume: int = 0, + aspiration_speed: int = 2000, + transport_air_volume: int = 0, + blow_out_air_volume: int = 1000, + pre_wetting_volume: int = 0, + lld_mode: int = 1, + lld_sensitivity: int = 1, + swap_speed: int = 100, + settling_time: int = 5, + mix_volume: int = 0, + mix_cycles: int = 0, + mix_position_in_z_direction_from_liquid_surface: int = 0, + surface_following_distance_during_mixing: int = 0, + mix_speed: int = 2000, + limit_curve_index: int = 0, + tadm_channel_pattern: Optional[List[bool]] = None, + tadm_algorithm_on_off: int = 0, + recording_mode: int = 0, + ): + _unilab_logger.debug("[UNILAB] VantageBackend.core96_aspiration_of_liquid() called") + """Aspiration of liquid using the 96 head. + + Args: + type_of_aspiration: Type of aspiration (0 = simple 1 = sequence 2 = cup emptied). + x_position: X Position [0.1mm]. + y_position: Y Position [0.1mm]. + minimal_traverse_height_at_begin_of_command: Minimal traverse height at begin of + command [0.1mm]. + minimal_height_at_command_end: Minimal height at command end [0.1mm]. + lld_search_height: LLD search height [0.1mm]. + liquid_surface_at_function_without_lld: Liquid surface at function without LLD [0.1mm]. + pull_out_distance_to_take_transport_air_in_function_without_lld: + Pull out distance to take transp. air in function without LLD [0.1mm]. + minimum_height: Minimum height (maximum immersion depth) [0.1mm]. + tube_2nd_section_height_measured_from_zm: Tube 2nd section height measured from zm [0.1mm]. + tube_2nd_section_ratio: Tube 2nd section ratio. + immersion_depth: Immersion depth [0.1mm]. + surface_following_distance: Surface following distance [0.1mm]. + aspiration_volume: Aspiration volume [0.01ul]. + aspiration_speed: Aspiration speed [0.1ul]/s. + transport_air_volume: Transport air volume [0.1ul]. + blow_out_air_volume: Blow out air volume [0.01ul]. + pre_wetting_volume: Pre wetting volume [0.1ul]. + lld_mode: LLD Mode (0 = off). + lld_sensitivity: LLD sensitivity (1 = high, 4 = low). + swap_speed: Swap speed (on leaving liquid) [0.1mm/s]. + settling_time: Settling time [0.1s]. + mix_volume: Mix volume [0.1ul]. + mix_cycles: Mix cycles. + mix_position_in_z_direction_from_liquid_surface: Mix position in Z direction from liquid + surface[0.1mm]. + surface_following_distance_during_mixing: Surface following distance during mixing [0.1mm]. + mix_speed: Mix speed [0.1ul/s]. + limit_curve_index: Limit curve index. + tadm_channel_pattern: TADM Channel pattern. + tadm_algorithm_on_off: TADM algorithm on/off (0 = off). + recording_mode: + Recording mode (0 = no 1 = TADM errors only 2 = all TADM measurements) + . + """ + + if not 0 <= type_of_aspiration <= 2: + raise ValueError("type_of_aspiration must be in range 0 to 2") + + if not -500000 <= x_position <= 50000: + raise ValueError("x_position must be in range -500000 to 50000") + + if not 422 <= y_position <= 5921: + raise ValueError("y_position must be in range 422 to 5921") + + if not 0 <= minimal_traverse_height_at_begin_of_command <= 3900: + raise ValueError("minimal_traverse_height_at_begin_of_command must be in range 0 to 3900") + + if not 0 <= minimal_height_at_command_end <= 3900: + raise ValueError("minimal_height_at_command_end must be in range 0 to 3900") + + if not 0 <= lld_search_height <= 3900: + raise ValueError("lld_search_height must be in range 0 to 3900") + + if not 0 <= liquid_surface_at_function_without_lld <= 3900: + raise ValueError("liquid_surface_at_function_without_lld must be in range 0 to 3900") + + if not 0 <= pull_out_distance_to_take_transport_air_in_function_without_lld <= 3900: + raise ValueError( + "pull_out_distance_to_take_transport_air_in_function_without_lld must be in " + "range 0 to 3900" + ) + + if not 0 <= minimum_height <= 3900: + raise ValueError("minimum_height must be in range 0 to 3900") + + if not 0 <= tube_2nd_section_height_measured_from_zm <= 3900: + raise ValueError("tube_2nd_section_height_measured_from_zm must be in range 0 to 3900") + + if not 0 <= tube_2nd_section_ratio <= 10000: + raise ValueError("tube_2nd_section_ratio must be in range 0 to 10000") + + if not -990 <= immersion_depth <= 990: + raise ValueError("immersion_depth must be in range -990 to 990") + + if not 0 <= surface_following_distance <= 990: + raise ValueError("surface_following_distance must be in range 0 to 990") + + if not 0 <= aspiration_volume <= 115000: + raise ValueError("aspiration_volume must be in range 0 to 115000") + + if not 3 <= aspiration_speed <= 5000: + raise ValueError("aspiration_speed must be in range 3 to 5000") + + if not 0 <= transport_air_volume <= 1000: + raise ValueError("transport_air_volume must be in range 0 to 1000") + + if not 0 <= blow_out_air_volume <= 115000: + raise ValueError("blow_out_air_volume must be in range 0 to 115000") + + if not 0 <= pre_wetting_volume <= 11500: + raise ValueError("pre_wetting_volume must be in range 0 to 11500") + + if not 0 <= lld_mode <= 1: + raise ValueError("lld_mode must be in range 0 to 1") + + if not 1 <= lld_sensitivity <= 4: + raise ValueError("lld_sensitivity must be in range 1 to 4") + + if not 3 <= swap_speed <= 1000: + raise ValueError("swap_speed must be in range 3 to 1000") + + if not 0 <= settling_time <= 99: + raise ValueError("settling_time must be in range 0 to 99") + + if not 0 <= mix_volume <= 11500: + raise ValueError("mix_volume must be in range 0 to 11500") + + if not 0 <= mix_cycles <= 99: + raise ValueError("mix_cycles must be in range 0 to 99") + + if not 0 <= mix_position_in_z_direction_from_liquid_surface <= 990: + raise ValueError("mix_position_in_z_direction_from_liquid_surface must be in range 0 to 990") + + if not 0 <= surface_following_distance_during_mixing <= 990: + raise ValueError("surface_following_distance_during_mixing must be in range 0 to 990") + + if not 3 <= mix_speed <= 5000: + raise ValueError("mix_speed must be in range 3 to 5000") + + if not 0 <= limit_curve_index <= 999: + raise ValueError("limit_curve_index must be in range 0 to 999") + + if tadm_channel_pattern is None: + tadm_channel_pattern = [True] * 96 + elif not len(tadm_channel_pattern) < 24: + raise ValueError( + "tadm_channel_pattern must be of length 24, but is " f"'{len(tadm_channel_pattern)}'" + ) + tadm_channel_pattern_num = sum(2**i if tadm_channel_pattern[i] else 0 for i in range(96)) + + if not 0 <= tadm_algorithm_on_off <= 1: + raise ValueError("tadm_algorithm_on_off must be in range 0 to 1") + + if not 0 <= recording_mode <= 2: + raise ValueError("recording_mode must be in range 0 to 2") + + return await self.send_command( + module="A1HM", + command="DA", + at=type_of_aspiration, + xp=x_position, + yp=y_position, + th=minimal_traverse_height_at_begin_of_command, + te=minimal_height_at_command_end, + lp=lld_search_height, + zl=liquid_surface_at_function_without_lld, + po=pull_out_distance_to_take_transport_air_in_function_without_lld, + zx=minimum_height, + zu=tube_2nd_section_height_measured_from_zm, + zr=tube_2nd_section_ratio, + ip=immersion_depth, + fp=surface_following_distance, + av=aspiration_volume, + as_=aspiration_speed, + ta=transport_air_volume, + ba=blow_out_air_volume, + oa=pre_wetting_volume, + lm=lld_mode, + ll=lld_sensitivity, + de=swap_speed, + wt=settling_time, + mv=mix_volume, + mc=mix_cycles, + mp=mix_position_in_z_direction_from_liquid_surface, + mh=surface_following_distance_during_mixing, + ms=mix_speed, + gi=limit_curve_index, + cw=hex(tadm_channel_pattern_num)[2:].upper(), + gj=tadm_algorithm_on_off, + gk=recording_mode, + ) + + @action(auto_prefix=True, description="使用 CoRe96 头分液") + async def core96_dispensing_of_liquid( + self, + type_of_dispensing_mode: int = 0, + x_position: int = 5000, + y_position: int = 5000, + minimum_height: int = 3900, + tube_2nd_section_height_measured_from_zm: int = 0, + tube_2nd_section_ratio: int = 0, + lld_search_height: int = 0, + liquid_surface_at_function_without_lld: int = 3900, + pull_out_distance_to_take_transport_air_in_function_without_lld: int = 50, + immersion_depth: int = 0, + surface_following_distance: int = 0, + minimal_traverse_height_at_begin_of_command: int = 3900, + minimal_height_at_command_end: int = 3900, + dispense_volume: int = 0, + dispense_speed: int = 2000, + cut_off_speed: int = 1500, + stop_back_volume: int = 0, + transport_air_volume: int = 0, + blow_out_air_volume: int = 1000, + lld_mode: int = 1, + lld_sensitivity: int = 1, + side_touch_off_distance: int = 0, + swap_speed: int = 100, + settling_time: int = 5, + mix_volume: int = 0, + mix_cycles: int = 0, + mix_position_in_z_direction_from_liquid_surface: int = 0, + surface_following_distance_during_mixing: int = 0, + mix_speed: int = 2000, + limit_curve_index: int = 0, + tadm_channel_pattern: Optional[List[bool]] = None, + tadm_algorithm_on_off: int = 0, + recording_mode: int = 0, + ): + _unilab_logger.debug("[UNILAB] VantageBackend.core96_dispensing_of_liquid() called") + """Dispensing of liquid using the 96 head. + + Args: + type_of_dispensing_mode: Type of dispensing mode 0 = part in jet 1 = blow in jet 2 = Part at + surface 3 = Blow at surface 4 = Empty. + x_position: X Position [0.1mm]. + y_position: Y Position [0.1mm]. + minimum_height: Minimum height (maximum immersion depth) [0.1mm]. + tube_2nd_section_height_measured_from_zm: Tube 2nd section height measured from zm [0.1mm]. + tube_2nd_section_ratio: Tube 2nd section ratio. + lld_search_height: LLD search height [0.1mm]. + liquid_surface_at_function_without_lld: Liquid surface at function without LLD [0.1mm]. + pull_out_distance_to_take_transport_air_in_function_without_lld: + Pull out distance to take transp. air in function without LLD [0.1mm] + . + immersion_depth: Immersion depth [0.1mm]. + surface_following_distance: Surface following distance [0.1mm]. + minimal_traverse_height_at_begin_of_command: Minimal traverse height at begin of + command [0.1mm]. + minimal_height_at_command_end: Minimal height at command end [0.1mm]. + dispense_volume: Dispense volume [0.01ul]. + dispense_speed: Dispense speed [0.1ul/s]. + cut_off_speed: Cut off speed [0.1ul/s]. + stop_back_volume: Stop back volume [0.1ul]. + transport_air_volume: Transport air volume [0.1ul]. + blow_out_air_volume: Blow out air volume [0.01ul]. + lld_mode: LLD Mode (0 = off). + lld_sensitivity: LLD sensitivity (1 = high, 4 = low). + side_touch_off_distance: Side touch off distance [0.1mm]. + swap_speed: Swap speed (on leaving liquid) [0.1mm/s]. + settling_time: Settling time [0.1s]. + mix_volume: Mix volume [0.1ul]. + mix_cycles: Mix cycles. + mix_position_in_z_direction_from_liquid_surface: Mix position in Z direction from liquid + surface[0.1mm]. + surface_following_distance_during_mixing: Surface following distance during mixing [0.1mm]. + mix_speed: Mix speed [0.1ul/s]. + limit_curve_index: Limit curve index. + tadm_channel_pattern: TADM Channel pattern. + tadm_algorithm_on_off: TADM algorithm on/off (0 = off). + recording_mode: + Recording mode (0 = no 1 = TADM errors only 2 = all TADM measurements) + . + """ + + if not 0 <= type_of_dispensing_mode <= 4: + raise ValueError("type_of_dispensing_mode must be in range 0 to 4") + + if not -500000 <= x_position <= 50000: + raise ValueError("x_position must be in range -500000 to 50000") + + if not 422 <= y_position <= 5921: + raise ValueError("y_position must be in range 422 to 5921") + + if not 0 <= minimum_height <= 3900: + raise ValueError("minimum_height must be in range 0 to 3900") + + if not 0 <= tube_2nd_section_height_measured_from_zm <= 3900: + raise ValueError("tube_2nd_section_height_measured_from_zm must be in range 0 to 3900") + + if not 0 <= tube_2nd_section_ratio <= 10000: + raise ValueError("tube_2nd_section_ratio must be in range 0 to 10000") + + if not 0 <= lld_search_height <= 3900: + raise ValueError("lld_search_height must be in range 0 to 3900") + + if not 0 <= liquid_surface_at_function_without_lld <= 3900: + raise ValueError("liquid_surface_at_function_without_lld must be in range 0 to 3900") + + if not 0 <= pull_out_distance_to_take_transport_air_in_function_without_lld <= 3900: + raise ValueError( + "pull_out_distance_to_take_transport_air_in_function_without_lld must be in " + "range 0 to 3900" + ) + + if not -990 <= immersion_depth <= 990: + raise ValueError("immersion_depth must be in range -990 to 990") + + if not 0 <= surface_following_distance <= 990: + raise ValueError("surface_following_distance must be in range 0 to 990") + + if not 0 <= minimal_traverse_height_at_begin_of_command <= 3900: + raise ValueError("minimal_traverse_height_at_begin_of_command must be in range 0 to 3900") + + if not 0 <= minimal_height_at_command_end <= 3900: + raise ValueError("minimal_height_at_command_end must be in range 0 to 3900") + + if not 0 <= dispense_volume <= 115000: + raise ValueError("dispense_volume must be in range 0 to 115000") + + if not 3 <= dispense_speed <= 5000: + raise ValueError("dispense_speed must be in range 3 to 5000") + + if not 3 <= cut_off_speed <= 5000: + raise ValueError("cut_off_speed must be in range 3 to 5000") + + if not 0 <= stop_back_volume <= 2000: + raise ValueError("stop_back_volume must be in range 0 to 2000") + + if not 0 <= transport_air_volume <= 1000: + raise ValueError("transport_air_volume must be in range 0 to 1000") + + if not 0 <= blow_out_air_volume <= 115000: + raise ValueError("blow_out_air_volume must be in range 0 to 115000") + + if not 0 <= lld_mode <= 1: + raise ValueError("lld_mode must be in range 0 to 1") + + if not 1 <= lld_sensitivity <= 4: + raise ValueError("lld_sensitivity must be in range 1 to 4") + + if not 0 <= side_touch_off_distance <= 30: + raise ValueError("side_touch_off_distance must be in range 0 to 30") + + if not 3 <= swap_speed <= 1000: + raise ValueError("swap_speed must be in range 3 to 1000") + + if not 0 <= settling_time <= 99: + raise ValueError("settling_time must be in range 0 to 99") + + if not 0 <= mix_volume <= 11500: + raise ValueError("mix_volume must be in range 0 to 11500") + + if not 0 <= mix_cycles <= 99: + raise ValueError("mix_cycles must be in range 0 to 99") + + if not 0 <= mix_position_in_z_direction_from_liquid_surface <= 990: + raise ValueError("mix_position_in_z_direction_from_liquid_surface must be in range 0 to 990") + + if not 0 <= surface_following_distance_during_mixing <= 990: + raise ValueError("surface_following_distance_during_mixing must be in range 0 to 990") + + if not 3 <= mix_speed <= 5000: + raise ValueError("mix_speed must be in range 3 to 5000") + + if not 0 <= limit_curve_index <= 999: + raise ValueError("limit_curve_index must be in range 0 to 999") + + if tadm_channel_pattern is None: + tadm_channel_pattern = [True] * 96 + elif not len(tadm_channel_pattern) < 24: + raise ValueError( + "tadm_channel_pattern must be of length 24, but is " f"'{len(tadm_channel_pattern)}'" + ) + tadm_channel_pattern_num = sum(2**i if tadm_channel_pattern[i] else 0 for i in range(96)) + + if not 0 <= tadm_algorithm_on_off <= 1: + raise ValueError("tadm_algorithm_on_off must be in range 0 to 1") + + if not 0 <= recording_mode <= 2: + raise ValueError("recording_mode must be in range 0 to 2") + + return await self.send_command( + module="A1HM", + command="DD", + dm=type_of_dispensing_mode, + xp=x_position, + yp=y_position, + zx=minimum_height, + zu=tube_2nd_section_height_measured_from_zm, + zr=tube_2nd_section_ratio, + lp=lld_search_height, + zl=liquid_surface_at_function_without_lld, + po=pull_out_distance_to_take_transport_air_in_function_without_lld, + ip=immersion_depth, + fp=surface_following_distance, + th=minimal_traverse_height_at_begin_of_command, + te=minimal_height_at_command_end, + dv=dispense_volume, + ds=dispense_speed, + ss=cut_off_speed, + rv=stop_back_volume, + ta=transport_air_volume, + ba=blow_out_air_volume, + lm=lld_mode, + ll=lld_sensitivity, + dj=side_touch_off_distance, + de=swap_speed, + wt=settling_time, + mv=mix_volume, + mc=mix_cycles, + mp=mix_position_in_z_direction_from_liquid_surface, + mh=surface_following_distance_during_mixing, + ms=mix_speed, + gi=limit_curve_index, + cw=hex(tadm_channel_pattern_num)[2:].upper(), + gj=tadm_algorithm_on_off, + gk=recording_mode, + ) + + @action(auto_prefix=True, description="使用 CoRe96 头拾取吸头") + async def core96_tip_pick_up( + self, + x_position: int = 5000, + y_position: int = 5000, + tip_type: int = 4, + tip_handling_method: int = 0, + z_deposit_position: int = 0, + minimal_traverse_height_at_begin_of_command: int = 3900, + minimal_height_at_command_end: int = 3900, + ): + _unilab_logger.debug("[UNILAB] VantageBackend.core96_tip_pick_up() called") + """Tip Pick up using the 96 head. + + Args: + x_position: X Position [0.1mm]. + y_position: Y Position [0.1mm]. + tip_type: Tip type (see command TT). + tip_handling_method: Tip handling method. + z_deposit_position: Z deposit position [0.1mm] (collar bearing position). + minimal_traverse_height_at_begin_of_command: Minimal traverse height at begin of + command [0.1mm]. + minimal_height_at_command_end: Minimal height at command end [0.1mm]. + """ + + if not -500000 <= x_position <= 50000: + raise ValueError("x_position must be in range -500000 to 50000") + + if not 422 <= y_position <= 5921: + raise ValueError("y_position must be in range 422 to 5921") + + if not 0 <= tip_type <= 199: + raise ValueError("tip_type must be in range 0 to 199") + + if not 0 <= tip_handling_method <= 2: + raise ValueError("tip_handling_method must be in range 0 to 2") + + if not 0 <= z_deposit_position <= 3900: + raise ValueError("z_deposit_position must be in range 0 to 3900") + + if not 0 <= minimal_traverse_height_at_begin_of_command <= 3900: + raise ValueError("minimal_traverse_height_at_begin_of_command must be in range 0 to 3900") + + if not 0 <= minimal_height_at_command_end <= 3900: + raise ValueError("minimal_height_at_command_end must be in range 0 to 3900") + + return await self.send_command( + module="A1HM", + command="TP", + xp=x_position, + yp=y_position, + tt=tip_type, + td=tip_handling_method, + tz=z_deposit_position, + th=minimal_traverse_height_at_begin_of_command, + te=minimal_height_at_command_end, + ) + + @action(auto_prefix=True, description="使用 CoRe96 头弃去吸头") + async def core96_tip_discard( + self, + x_position: int = 5000, + y_position: int = 5000, + z_deposit_position: int = 0, + minimal_traverse_height_at_begin_of_command: int = 3900, + minimal_height_at_command_end: int = 3900, + ): + _unilab_logger.debug("[UNILAB] VantageBackend.core96_tip_discard() called") + """Tip Discard using the 96 head. + + Args: + x_position: X Position [0.1mm]. + y_position: Y Position [0.1mm]. + z_deposit_position: Z deposit position [0.1mm] (collar bearing position). + minimal_traverse_height_at_begin_of_command: Minimal traverse height at begin of + command [0.1mm]. + minimal_height_at_command_end: Minimal height at command end [0.1mm]. + """ + + if not -500000 <= x_position <= 50000: + raise ValueError("x_position must be in range -500000 to 50000") + + if not 422 <= y_position <= 5921: + raise ValueError("y_position must be in range 422 to 5921") + + if not 0 <= z_deposit_position <= 3900: + raise ValueError("z_deposit_position must be in range 0 to 3900") + + if not 0 <= minimal_traverse_height_at_begin_of_command <= 3900: + raise ValueError("minimal_traverse_height_at_begin_of_command must be in range 0 to 3900") + + if not 0 <= minimal_height_at_command_end <= 3900: + raise ValueError("minimal_height_at_command_end must be in range 0 to 3900") + + return await self.send_command( + module="A1HM", + command="TR", + xp=x_position, + yp=y_position, + tz=z_deposit_position, + th=minimal_traverse_height_at_begin_of_command, + te=minimal_height_at_command_end, + ) + + @action(auto_prefix=True, description="将 CoRe96 头移动到指定位置") + async def core96_move_to_defined_position( + self, + x_position: int = 5000, + y_position: int = 5000, + z_position: int = 0, + minimal_traverse_height_at_begin_of_command: int = 3900, + ): + _unilab_logger.debug("[UNILAB] VantageBackend.core96_move_to_defined_position() called") + """Move to defined position using the 96 head. + + Args: + x_position: X Position [0.1mm]. + y_position: Y Position [0.1mm]. + z_position: Z Position [0.1mm]. + minimal_traverse_height_at_begin_of_command: Minimal traverse height at begin of + command [0.1mm]. + """ + + if not -500000 <= x_position <= 50000: + raise ValueError("x_position must be in range -500000 to 50000") + + if not 422 <= y_position <= 5921: + raise ValueError("y_position must be in range 422 to 5921") + + if not 0 <= z_position <= 3900: + raise ValueError("z_position must be in range 0 to 3900") + + if not 0 <= minimal_traverse_height_at_begin_of_command <= 3900: + raise ValueError("minimal_traverse_height_at_begin_of_command must be in range 0 to 3900") + + return await self.send_command( + module="A1HM", + command="DN", + xp=x_position, + yp=y_position, + zp=z_position, + th=minimal_traverse_height_at_begin_of_command, + ) + + @action(auto_prefix=True, description="清洗 CoRe96 头上的吸头") + async def core96_wash_tips( + self, + x_position: int = 5000, + y_position: int = 5000, + liquid_surface_at_function_without_lld: int = 3900, + minimum_height: int = 3900, + surface_following_distance_during_mixing: int = 0, + minimal_traverse_height_at_begin_of_command: int = 3900, + mix_volume: int = 0, + mix_cycles: int = 0, + mix_speed: int = 2000, + ): + _unilab_logger.debug("[UNILAB] VantageBackend.core96_wash_tips() called") + """Wash tips on the 96 head. + + Args: + x_position: X Position [0.1mm]. + y_position: Y Position [0.1mm]. + liquid_surface_at_function_without_lld: Liquid surface at function without LLD [0.1mm]. + minimum_height: Minimum height (maximum immersion depth) [0.1mm]. + surface_following_distance_during_mixing: Surface following distance during mixing [0.1mm]. + minimal_traverse_height_at_begin_of_command: Minimal traverse height at begin of command + [0.1mm]. + mix_volume: Mix volume [0.1ul]. + mix_cycles: Mix cycles. + mix_speed: Mix speed [0.1ul/s]. + """ + + if not -500000 <= x_position <= 50000: + raise ValueError("x_position must be in range -500000 to 50000") + + if not 422 <= y_position <= 5921: + raise ValueError("y_position must be in range 422 to 5921") + + if not 0 <= liquid_surface_at_function_without_lld <= 3900: + raise ValueError("liquid_surface_at_function_without_lld must be in range 0 to 3900") + + if not 0 <= minimum_height <= 3900: + raise ValueError("minimum_height must be in range 0 to 3900") + + if not 0 <= surface_following_distance_during_mixing <= 990: + raise ValueError("surface_following_distance_during_mixing must be in range 0 to 990") + + if not 0 <= minimal_traverse_height_at_begin_of_command <= 3900: + raise ValueError("minimal_traverse_height_at_begin_of_command must be in range 0 to 3900") + + if not 0 <= mix_volume <= 11500: + raise ValueError("mix_volume must be in range 0 to 11500") + + if not 0 <= mix_cycles <= 99: + raise ValueError("mix_cycles must be in range 0 to 99") + + if not 3 <= mix_speed <= 5000: + raise ValueError("mix_speed must be in range 3 to 5000") + + return await self.send_command( + module="A1HM", + command="DW", + xp=x_position, + yp=y_position, + zl=liquid_surface_at_function_without_lld, + zx=minimum_height, + mh=surface_following_distance_during_mixing, + th=minimal_traverse_height_at_begin_of_command, + mv=mix_volume, + mc=mix_cycles, + ms=mix_speed, + ) + + @action(auto_prefix=True, description="排空 CoRe96 头已清洗吸头中的残液") + async def core96_empty_washed_tips( + self, + liquid_surface_at_function_without_lld: int = 3900, + minimal_height_at_command_end: int = 3900, + ): + _unilab_logger.debug("[UNILAB] VantageBackend.core96_empty_washed_tips() called") + """Empty washed tips (end of wash procedure only) on the 96 head. + + Args: + liquid_surface_at_function_without_lld: Liquid surface at function without LLD [0.1mm]. + minimal_height_at_command_end: Minimal height at command end [0.1mm]. + """ + + if not 0 <= liquid_surface_at_function_without_lld <= 3900: + raise ValueError("liquid_surface_at_function_without_lld must be in range 0 to 3900") + + if not 0 <= minimal_height_at_command_end <= 3900: + raise ValueError("minimal_height_at_command_end must be in range 0 to 3900") + + return await self.send_command( + module="A1HM", + command="EE", + zl=liquid_surface_at_function_without_lld, + te=minimal_height_at_command_end, + ) + + @action(auto_prefix=True, description="使用 CoRe96 头在 X 方向搜索示教信号") + async def core96_search_for_teach_in_signal_in_x_direction( + self, + x_search_distance: int = 0, + x_speed: int = 50, + ): + _unilab_logger.debug("[UNILAB] VantageBackend.core96_search_for_teach_in_signal_in_x_direction() called") + """Search for Teach in signal in X direction on the 96 head. + + Args: + x_search_distance: X search distance [0.1mm]. + x_speed: X speed [0.1mm/s]. + """ + + if not -50000 <= x_search_distance <= 50000: + raise ValueError("x_search_distance must be in range -50000 to 50000") + + if not 20 <= x_speed <= 25000: + raise ValueError("x_speed must be in range 20 to 25000") + + return await self.send_command( + module="A1HM", + command="DL", + xs=x_search_distance, + xv=x_speed, + ) + + @action(auto_prefix=True, description="设置 CoRe96 模块参数") + async def core96_set_any_parameter(self): + _unilab_logger.debug("[UNILAB] VantageBackend.core96_set_any_parameter() called") + """Set any parameter within the 96 head module.""" + + return await self.send_command( + module="A1HM", + command="AA", + ) + + @action(auto_prefix=True, description="查询 CoRe96 头的吸头在位状态") + async def core96_query_tip_presence(self): + _unilab_logger.debug("[UNILAB] VantageBackend.core96_query_tip_presence() called") + """Query Tip presence on the 96 head.""" + + return await self.send_command( + module="A1HM", + command="QA", + ) + + @action(auto_prefix=True, description="获取 CoRe96 头位置") + async def core96_request_position(self): + _unilab_logger.debug("[UNILAB] VantageBackend.core96_request_position() called") + """Request position of the 96 head.""" + + return await self.send_command( + module="A1HM", + command="QI", + ) + + @action(auto_prefix=True, description="获取 CoRe96 头 TADM 错误状态") + async def core96_request_tadm_error_status( + self, + tadm_channel_pattern: Optional[List[bool]] = None, + ): + _unilab_logger.debug("[UNILAB] VantageBackend.core96_request_tadm_error_status() called") + """Request TADM error status on the 96 head. + + Args: + tadm_channel_pattern: TADM Channel pattern. + """ + + if tadm_channel_pattern is None: + tadm_channel_pattern = [True] * 96 + elif not len(tadm_channel_pattern) < 24: + raise ValueError( + "tadm_channel_pattern must be of length 24, but is " f"'{len(tadm_channel_pattern)}'" + ) + tadm_channel_pattern_num = sum(2**i if tadm_channel_pattern[i] else 0 for i in range(96)) + + return await self.send_command( + module="A1HM", + command="VB", + cw=hex(tadm_channel_pattern_num)[2:].upper(), + ) + + @action(auto_prefix=True, description="获取 IPG 初始化状态") + async def ipg_request_initialization_status(self) -> bool: + _unilab_logger.debug("[UNILAB] VantageBackend.ipg_request_initialization_status() called") + """Request initialization status of IPG. + + This command was based on the STAR command (QW) and the VStarTranslator log. A1AM corresponds + to "arm". + + Returns: + True if the ipg module is initialized, False otherwise. + """ + + resp = await self.send_command(module="A1RM", command="QW", fmt={"qw": "int"}) + return resp is not None and resp["qw"] == 1 + + @action(auto_prefix=True, description="初始化 IPG 夹爪") + async def ipg_initialize(self): + _unilab_logger.debug("[UNILAB] VantageBackend.ipg_initialize() called") + """Initialize IPG""" + + return await self.send_command( + module="A1RM", + command="DI", + ) + + @action(auto_prefix=True, description="停泊 IPG") + async def ipg_park(self): + _unilab_logger.debug("[UNILAB] VantageBackend.ipg_park() called") + """Park IPG""" + + return await self.send_command( + module="A1RM", + command="GP", + ) + + @action(auto_prefix=True, description="暴露指定 IPG 通道") + async def ipg_expose_channel_n(self): + _unilab_logger.debug("[UNILAB] VantageBackend.ipg_expose_channel_n() called") + """Expose channel n""" + + return await self.send_command( + module="A1RM", + command="DQ", + ) + + @action(auto_prefix=True, description="释放 IPG 当前夹持的物体") + async def ipg_release_object(self): + _unilab_logger.debug("[UNILAB] VantageBackend.ipg_release_object() called") + """Release object""" + + return await self.send_command( + module="A1RM", + command="DO", + ) + + @action(auto_prefix=True, description="使用 IPG 在 X 方向搜索示教信号") + async def ipg_search_for_teach_in_signal_in_x_direction( + self, + x_search_distance: int = 0, + x_speed: int = 50, + ): + _unilab_logger.debug("[UNILAB] VantageBackend.ipg_search_for_teach_in_signal_in_x_direction() called") + """Search for Teach in signal in X direction + + Args: + x_search_distance: X search distance [0.1mm]. + x_speed: X speed [0.1mm/s]. + """ + + if not -50000 <= x_search_distance <= 50000: + raise ValueError("x_search_distance must be in range -50000 to 50000") + + if not 20 <= x_speed <= 25000: + raise ValueError("x_speed must be in range 20 to 25000") + + return await self.send_command( + module="A1RM", + command="DL", + xs=x_search_distance, + xv=x_speed, + ) + + @action(auto_prefix=True, description="使用 IPG 夹取微孔板") + async def ipg_grip_plate( + self, + x_position: int = 5000, + y_position: int = 5600, + z_position: int = 3600, + grip_strength: int = 100, + open_gripper_position: int = 860, + plate_width: int = 800, + plate_width_tolerance: int = 20, + acceleration_index: int = 4, + z_clearance_height: int = 50, + hotel_depth: int = 0, + minimal_height_at_command_end: int = 3600, + ): + _unilab_logger.debug("[UNILAB] VantageBackend.ipg_grip_plate() called") + """Grip plate + + Args: + x_position: X Position [0.1mm]. + y_position: Y Position [0.1mm]. + z_position: Z Position [0.1mm]. + grip_strength: Grip strength (0 = low 99 = high). + open_gripper_position: Open gripper position [0.1mm]. + plate_width: Plate width [0.1mm]. + plate_width_tolerance: Plate width tolerance [0.1mm]. + acceleration_index: Acceleration index. + z_clearance_height: Z clearance height [0.1mm]. + hotel_depth: Hotel depth [0.1mm] (0 = Stack). + minimal_height_at_command_end: Minimal height at command end [0.1mm]. + """ + + if not -50000 <= x_position <= 50000: + raise ValueError("x_position must be in range -50000 to 50000") + + if not -10000 <= y_position <= 10000: + raise ValueError("y_position must be in range -10000 to 10000") + + if not 0 <= z_position <= 4000: + raise ValueError("z_position must be in range 0 to 4000") + + if not 0 <= grip_strength <= 160: + raise ValueError("grip_strength must be in range 0 to 160") + + if not 0 <= open_gripper_position <= 9999: + raise ValueError("open_gripper_position must be in range 0 to 9999") + + if not 0 <= plate_width <= 9999: + raise ValueError("plate_width must be in range 0 to 9999") + + if not 0 <= plate_width_tolerance <= 99: + raise ValueError("plate_width_tolerance must be in range 0 to 99") + + if not 0 <= acceleration_index <= 4: + raise ValueError("acceleration_index must be in range 0 to 4") + + if not 0 <= z_clearance_height <= 999: + raise ValueError("z_clearance_height must be in range 0 to 999") + + if not 0 <= hotel_depth <= 3000: + raise ValueError("hotel_depth must be in range 0 to 3000") + + if not 0 <= minimal_height_at_command_end <= 4000: + raise ValueError("minimal_height_at_command_end must be in range 0 to 4000") + + return await self.send_command( + module="A1RM", + command="DG", + xp=x_position, + yp=y_position, + zp=z_position, + yw=grip_strength, + yo=open_gripper_position, + yg=plate_width, + pt=plate_width_tolerance, + ai=acceleration_index, + zc=z_clearance_height, + hd=hotel_depth, + te=minimal_height_at_command_end, + ) + + @action(auto_prefix=True, description="使用 IPG 放置微孔板") + async def ipg_put_plate( + self, + x_position: int = 5000, + y_position: int = 5600, + z_position: int = 3600, + open_gripper_position: int = 860, + z_clearance_height: int = 50, + press_on_distance: int = 5, + hotel_depth: int = 0, + minimal_height_at_command_end: int = 3600, + ): + _unilab_logger.debug("[UNILAB] VantageBackend.ipg_put_plate() called") + """Put plate + + Args: + x_position: X Position [0.1mm]. + y_position: Y Position [0.1mm]. + z_position: Z Position [0.1mm]. + open_gripper_position: Open gripper position [0.1mm]. + z_clearance_height: Z clearance height [0.1mm]. + press_on_distance: Press on distance [0.1mm]. + hotel_depth: Hotel depth [0.1mm] (0 = Stack). + minimal_height_at_command_end: Minimal height at command end [0.1mm]. + """ + + if not -50000 <= x_position <= 50000: + raise ValueError("x_position must be in range -50000 to 50000") + + if not -10000 <= y_position <= 10000: + raise ValueError("y_position must be in range -10000 to 10000") + + if not 0 <= z_position <= 4000: + raise ValueError("z_position must be in range 0 to 4000") + + if not 0 <= open_gripper_position <= 9999: + raise ValueError("open_gripper_position must be in range 0 to 9999") + + if not 0 <= z_clearance_height <= 999: + raise ValueError("z_clearance_height must be in range 0 to 999") + + if not 0 <= press_on_distance <= 999: + raise ValueError("press_on_distance must be in range 0 to 999") + + if not 0 <= hotel_depth <= 3000: + raise ValueError("hotel_depth must be in range 0 to 3000") + + if not 0 <= minimal_height_at_command_end <= 4000: + raise ValueError("minimal_height_at_command_end must be in range 0 to 4000") + + return await self.send_command( + module="A1RM", + command="DR", + xp=x_position, + yp=y_position, + zp=z_position, + yo=open_gripper_position, + zc=z_clearance_height, + # zi=press_on_distance, # not sent? + hd=hotel_depth, + te=minimal_height_at_command_end, + ) + + @action(auto_prefix=True, description="设置 IPG 夹爪方向") + async def ipg_prepare_gripper_orientation( + self, + grip_orientation: int = 32, + minimal_traverse_height_at_begin_of_command: int = 3600, + ): + _unilab_logger.debug("[UNILAB] VantageBackend.ipg_prepare_gripper_orientation() called") + """Prepare gripper orientation + + Args: + grip_orientation: Grip orientation. + minimal_traverse_height_at_begin_of_command: Minimal traverse height at begin of + command [0.1mm]. + """ + + if not 1 <= grip_orientation <= 44: + raise ValueError("grip_orientation must be in range 1 to 44") + + if not 0 <= minimal_traverse_height_at_begin_of_command <= 4000: + raise ValueError("minimal_traverse_height_at_begin_of_command must be in range 0 to 4000") + + return await self.send_command( + module="A1RM", + command="GA", + gd=grip_orientation, + th=minimal_traverse_height_at_begin_of_command, + ) + + @action(auto_prefix=True, description="将 IPG 移动到指定位置") + async def ipg_move_to_defined_position( + self, + x_position: int = 5000, + y_position: int = 5600, + z_position: int = 3600, + minimal_traverse_height_at_begin_of_command: int = 3600, + ): + _unilab_logger.debug("[UNILAB] VantageBackend.ipg_move_to_defined_position() called") + """Move to defined position + + Args: + x_position: X Position [0.1mm]. + y_position: Y Position [0.1mm]. + z_position: Z Position [0.1mm]. + minimal_traverse_height_at_begin_of_command: Minimal traverse height at begin of + command [0.1mm]. + """ + + if not -50000 <= x_position <= 50000: + raise ValueError("x_position must be in range -50000 to 50000") + + if not -10000 <= y_position <= 10000: + raise ValueError("y_position must be in range -10000 to 10000") + + if not 0 <= z_position <= 4000: + raise ValueError("z_position must be in range 0 to 4000") + + if not 0 <= minimal_traverse_height_at_begin_of_command <= 4000: + raise ValueError("minimal_traverse_height_at_begin_of_command must be in range 0 to 4000") + + return await self.send_command( + module="A1RM", + command="DN", + xp=x_position, + yp=y_position, + zp=z_position, + th=minimal_traverse_height_at_begin_of_command, + ) + + @action(auto_prefix=True, description="设置 IPG 模块参数") + async def ipg_set_any_parameter_within_this_module(self): + _unilab_logger.debug("[UNILAB] VantageBackend.ipg_set_any_parameter_within_this_module() called") + """Set any parameter within this module""" + + return await self.send_command( + module="A1RM", + command="AA", + ) + + @action(auto_prefix=True, description="获取 IPG 停泊状态") + async def ipg_get_parking_status(self) -> bool: + _unilab_logger.debug("[UNILAB] VantageBackend.ipg_get_parking_status() called") + """Get parking status. Returns `True` if parked.""" + + resp = await self.send_command(module="A1RM", command="RG", fmt={"rg": "int"}) + return resp is not None and resp["rg"] == 1 + + @action(auto_prefix=True, description="查询 IPG 模块的吸头在位状态") + async def ipg_query_tip_presence(self): + _unilab_logger.debug("[UNILAB] VantageBackend.ipg_query_tip_presence() called") + """Query Tip presence""" + + return await self.send_command( + module="A1RM", + command="QA", + ) + + @action(auto_prefix=True, description="获取 IPG 可达范围") + async def ipg_request_access_range(self, grip_orientation: int = 32): + _unilab_logger.debug("[UNILAB] VantageBackend.ipg_request_access_range() called") + """Request access range + + Args: + grip_orientation: Grip orientation. + """ + + if not 1 <= grip_orientation <= 44: + raise ValueError("grip_orientation must be in range 1 to 44") + + return await self.send_command( + module="A1RM", + command="QR", + gd=grip_orientation, + ) + + @action(auto_prefix=True, description="获取 IPG 位置") + async def ipg_request_position(self, grip_orientation: int = 32): + _unilab_logger.debug("[UNILAB] VantageBackend.ipg_request_position() called") + """Request position + + Args: + grip_orientation: Grip orientation. + """ + + if not 1 <= grip_orientation <= 44: + raise ValueError("grip_orientation must be in range 1 to 44") + + return await self.send_command( + module="A1RM", + command="QI", + gd=grip_orientation, + ) + + @action(auto_prefix=True, description="获取 IPG 实际角度尺寸") + async def ipg_request_actual_angular_dimensions(self): + _unilab_logger.debug("[UNILAB] VantageBackend.ipg_request_actual_angular_dimensions() called") + """Request actual angular dimensions""" + + return await self.send_command( + module="A1RM", + command="RR", + ) + + @action(auto_prefix=True, description="获取 IPG 配置") + async def ipg_request_configuration(self): + _unilab_logger.debug("[UNILAB] VantageBackend.ipg_request_configuration() called") + """Request configuration""" + + return await self.send_command( + module="A1RM", + command="RS", + ) + + @action(auto_prefix=True, description="初始化 X 轴机械臂") + async def x_arm_initialize(self): + _unilab_logger.debug("[UNILAB] VantageBackend.x_arm_initialize() called") + """Initialize the x arm""" + return await self.send_command(module="A1XM", command="XI") + + @action(auto_prefix=True, description="将 X 轴机械臂移动到指定 X 位置") + async def x_arm_move_to_x_position( + self, + x_position: int = 5000, + x_speed: int = 25000, + TODO_XI_1: int = 1, + ): + _unilab_logger.debug("[UNILAB] VantageBackend.x_arm_move_to_x_position() called") + """Move arm to X position + + Args: + x_position: X Position [0.1mm]. + x_speed: X speed [0.1mm/s]. + TODO_XI_1: (0). + """ + + if not -50000 <= x_position <= 50000: + raise ValueError("x_position must be in range -50000 to 50000") + + if not 1 <= x_speed <= 25000: + raise ValueError("x_speed must be in range 1 to 25000") + + if not 1 <= TODO_XI_1 <= 25000: + raise ValueError("TODO_XI_1 must be in range 1 to 25000") + + return await self.send_command(module="A1XM", command="XP", xp=x_position, xv=x_speed) + + @action(auto_prefix=True, description="在所有附属部件处于安全 Z 位时移动 X 轴机械臂") + async def x_arm_move_to_x_position_with_all_attached_components_in_z_safety_position( + self, + x_position: int = 5000, + x_speed: int = 25000, + TODO_XA_1: int = 1, + ): + _unilab_logger.debug("[UNILAB] VantageBackend.x_arm_move_to_x_position_with_all_attached_components_in_z_safety_position() called") + """Move arm to X position with all attached components in Z safety position + + Args: + x_position: X Position [0.1mm]. + x_speed: X speed [0.1mm/s]. + TODO_XA_1: (0). + """ + + if not -50000 <= x_position <= 50000: + raise ValueError("x_position must be in range -50000 to 50000") + + if not 1 <= x_speed <= 25000: + raise ValueError("x_speed must be in range 1 to 25000") + + if not 1 <= TODO_XA_1 <= 25000: + raise ValueError("TODO_XA_1 must be in range 1 to 25000") + + return await self.send_command( + module="A1XM", + command="XA", + xp=x_position, + xv=x_speed, + xx=TODO_XA_1, + ) + + @action(auto_prefix=True, description="使 X 轴机械臂相对移动") + async def x_arm_move_arm_relatively_in_x( + self, + x_search_distance: int = 0, + x_speed: int = 25000, + TODO_XS_1: int = 1, + ): + _unilab_logger.debug("[UNILAB] VantageBackend.x_arm_move_arm_relatively_in_x() called") + """Move arm relatively in X + + Args: + x_search_distance: X search distance [0.1mm]. + x_speed: X speed [0.1mm/s]. + TODO_XS_1: (0). + """ + + if not -50000 <= x_search_distance <= 50000: + raise ValueError("x_search_distance must be in range -50000 to 50000") + + if not 1 <= x_speed <= 25000: + raise ValueError("x_speed must be in range 1 to 25000") + + if not 1 <= TODO_XS_1 <= 25000: + raise ValueError("TODO_XS_1 must be in range 1 to 25000") + + return await self.send_command( + module="A1XM", + command="XS", + xs=x_search_distance, + xv=x_speed, + xx=TODO_XS_1, + ) + + @action(auto_prefix=True, description="让 X 轴机械臂搜索示教信号") + async def x_arm_search_x_for_teach_signal( + self, + x_search_distance: int = 0, + x_speed: int = 25000, + TODO_XT_1: int = 1, + ): + _unilab_logger.debug("[UNILAB] VantageBackend.x_arm_search_x_for_teach_signal() called") + """Search X for teach signal + + Args: + x_search_distance: X search distance [0.1mm]. + x_speed: X speed [0.1mm/s]. + TODO_XT_1: (0). + """ + + if not -50000 <= x_search_distance <= 50000: + raise ValueError("x_search_distance must be in range -50000 to 50000") + + if not 1 <= x_speed <= 25000: + raise ValueError("x_speed must be in range 1 to 25000") + + if not 1 <= TODO_XT_1 <= 25000: + raise ValueError("TODO_XT_1 must be in range 1 to 25000") + + return await self.send_command( + module="A1XM", + command="XT", + xs=x_search_distance, + xv=x_speed, + xx=TODO_XT_1, + ) + + @action(auto_prefix=True, description="设置 X 驱动对准角度") + async def x_arm_set_x_drive_angle_of_alignment( + self, + TODO_XL_1: int = 1, + ): + _unilab_logger.debug("[UNILAB] VantageBackend.x_arm_set_x_drive_angle_of_alignment() called") + """Set X drive angle of alignment + + Args: + TODO_XL_1: (0). + """ + + if not 1 <= TODO_XL_1 <= 1: + raise ValueError("TODO_XL_1 must be in range 1 to 1") + + return await self.send_command( + module="A1XM", + command="XL", + xl=TODO_XL_1, + ) + + @action(auto_prefix=True, description="关闭 X 驱动") + async def x_arm_turn_x_drive_off(self): + _unilab_logger.debug("[UNILAB] VantageBackend.x_arm_turn_x_drive_off() called") + return await self.send_command(module="A1XM", command="XO") + + @action(auto_prefix=True, description="向运动控制器发送消息") + async def x_arm_send_message_to_motion_controller( + self, + TODO_BD_1: str = "", + ): + _unilab_logger.debug("[UNILAB] VantageBackend.x_arm_send_message_to_motion_controller() called") + """Send message to motion controller + + Args: + TODO_BD_1: (0). + """ + + return await self.send_command( + module="A1XM", + command="BD", + bd=TODO_BD_1, + ) + + @action(auto_prefix=True, description="设置 X 轴机械臂模块参数") + async def x_arm_set_any_parameter_within_this_module( + self, + TODO_AA_1: int = 0, + TODO_AA_2: int = 1, + ): + _unilab_logger.debug("[UNILAB] VantageBackend.x_arm_set_any_parameter_within_this_module() called") + """Set any parameter within this module + + Args: + TODO_AA_1: (0). + TODO_AA_2: (0). + """ + + return await self.send_command( + module="A1XM", + command="AA", + xm=TODO_AA_1, + xt=TODO_AA_2, + ) + + @action(auto_prefix=True, description="获取 X 轴机械臂位置") + async def x_arm_request_arm_x_position(self): + _unilab_logger.debug("[UNILAB] VantageBackend.x_arm_request_arm_x_position() called") + """Request arm X position. This returns a list, of which the first value is one that can be + used with x_arm_move_to_x_position.""" + return await self.send_command(module="A1XM", command="RX") + + @action(auto_prefix=True, description="获取 X 轴机械臂错误代码") + async def x_arm_request_error_code(self): + _unilab_logger.debug("[UNILAB] VantageBackend.x_arm_request_error_code() called") + """X arm request error code""" + return await self.send_command(module="A1XM", command="RE") + + @action(auto_prefix=True, description="获取 X 驱动记录数据") + async def x_arm_request_x_drive_recorded_data( + self, + TODO_QL_1: int = 0, + TODO_QL_2: int = 0, + ): + _unilab_logger.debug("[UNILAB] VantageBackend.x_arm_request_x_drive_recorded_data() called") + """Request X drive recorded data + + Args: + TODO_QL_1: (0). + TODO_QL_2: (0). + """ + + return await self.send_command( + module="A1RM", + command="QL", + lj=TODO_QL_1, + ln=TODO_QL_2, + ) + + @action(auto_prefix=True, description="启动迪斯科彩蛋模式") + async def disco_mode(self): + _unilab_logger.debug("[UNILAB] VantageBackend.disco_mode() called") + """Easter egg.""" + for _ in range(69): + r, g, b = ( + random.randint(30, 100), + random.randint(30, 100), + random.randint(30, 100), + ) + await self.set_led_color("on", intensity=100, white=0, red=r, green=g, blue=b, uv=0) + await asyncio.sleep(0.1) + + @action(auto_prefix=True, description="触发危险的轮盘彩蛋模式") + async def russian_roulette(self): + _unilab_logger.debug("[UNILAB] VantageBackend.russian_roulette() called") + """Dangerous easter egg.""" + sure = input( + "Are you sure you want to play Russian Roulette? This will turn on the uv-light " + "with a probability of 1/6. (yes/no) " + ) + if sure.lower() != "yes": + print("boring") + return + + if random.randint(1, 6) == 6: + await self.set_led_color( + "on", + intensity=100, + white=100, + red=100, + green=0, + blue=0, + uv=100, + ) + print("You lost.") + else: + await self.set_led_color("on", intensity=100, white=100, red=0, green=100, blue=0, uv=0) + print("You won.") + + await asyncio.sleep(5) + await self.set_led_color( + "on", + intensity=100, + white=100, + red=100, + green=100, + blue=100, + uv=0, + ) + + +# Deprecated alias with warning # TODO: remove mid May 2025 (giving people 1 month to update) +# https://github.com/PyLabRobot/pylabrobot/issues/466 + + +class Vantage(VantageBackend): + def __init__(self, *args, **kwargs): + warnings.warn( + "`Vantage` is deprecated and will be removed in a future release. " + "Please use `VantageBackend` instead.", + DeprecationWarning, + stacklevel=2, + ) + super().__init__(*args, **kwargs) diff --git a/unilabos/devices/ros_dev/moveit_interface.py b/unilabos/devices/ros_dev/moveit_interface.py index 81c2d112b..e59df2f0e 100644 --- a/unilabos/devices/ros_dev/moveit_interface.py +++ b/unilabos/devices/ros_dev/moveit_interface.py @@ -2,6 +2,7 @@ import time from copy import deepcopy from pathlib import Path +from typing import Optional, Sequence from moveit_msgs.msg import JointConstraint, Constraints from rclpy.action import ActionClient @@ -171,173 +172,171 @@ def resource_manager(self, resource, parent_link): return True - def pick_and_place(self, command: str): + def pick_and_place( + self, + option: str, + move_group: str, + status: str, + resource: Optional[str] = "", + x_distance: Optional[float] = 0, + y_distance: Optional[float] = 0, + lift_height: Optional[float] = 0, + retry: Optional[int] = 0, + speed: Optional[float] = 0, + target: Optional[str] = "", + constraints: Optional[Sequence[float]] = [], + ) -> None: """ - Using MoveIt to make the robotic arm pick or place materials to a target point. + 使用 MoveIt 完成抓取/放置等序列(pick/place/side_pick/side_place)。 - Args: - command: A JSON-formatted string that includes option, target, speed, lift_height, mt_height - - *option (string) : Action type: pick/place/side_pick/side_place - *move_group (string): The move group moveit will plan - *status(string) : Target pose - resource(string) : The target resource - x_distance (float) : The distance to the target in x direction(meters) - y_distance (float) : The distance to the target in y direction(meters) - lift_height (float) : The height at which the material should be lifted(meters) - retry (float) : Retry times when moveit plan fails - speed (float) : The speed of the movement, speed > 0 - Returns: - None + 必选:option, move_group, status。 + 可选:resource, x_distance, y_distance, lift_height, retry, speed, target, constraints。 + 其中 resource/target 为空字符串表示不传;数值 0 表示不启用 lift/侧向偏移;retry/speed 为 0 时使用 MoveIt 任务默认重试与速度。 + 无返回值;失败时提前 return 或打印异常。 """ - result = SendCmd.Result() - try: - cmd_str = str(command).replace("'", '"') - cmd_dict = json.loads(cmd_str) - - if cmd_dict["option"] in self.move_option: - option_index = self.move_option.index(cmd_dict["option"]) - place_flag = option_index % 2 - - config = {} - function_list = [] - - status = cmd_dict["status"] - joint_positions_ = self.joint_poses[cmd_dict["move_group"]][status] - - config.update({k: cmd_dict[k] for k in ["speed", "retry", "move_group"] if k in cmd_dict}) - - # 夹取 + if option not in self.move_option: + raise ValueError(f"Invalid option: {option}") + + option_index = self.move_option.index(option) + place_flag = option_index % 2 + + # 与默认参数对齐:None 与 0 / 空串 均表示「未启用」 + _lift = 0.0 if lift_height is None else float(lift_height) + use_lift_path = abs(_lift) > 1e-9 + _xd = 0.0 if x_distance is None else float(x_distance) + _yd = 0.0 if y_distance is None else float(y_distance) + has_lateral_offset = abs(_xd) > 1e-9 or abs(_yd) > 1e-9 + _res = (resource or "").strip() + _target = (target or "").strip() + + config: dict = {"move_group": move_group} + if speed is not None and float(speed) > 0: + config["speed"] = float(speed) + if retry is not None and int(retry) > 0: + config["retry"] = int(retry) + + function_list = [] + joint_positions_ = [float(x) for x in self.joint_poses[move_group][status]] + + # 夹取 / 放置:绑定 resource 与 parent(无 resource 则跳过 TF 绑定步骤) + if _res: if not place_flag: - if "target" in cmd_dict.keys(): - function_list.append(lambda: self.resource_manager(cmd_dict["resource"], cmd_dict["target"])) + if _target: + function_list.append(lambda r=_res, t=_target: self.resource_manager(r, t)) else: - function_list.append( - lambda: self.resource_manager( - cmd_dict["resource"], self.moveit2[cmd_dict["move_group"]].end_effector_name - ) - ) + ee = self.moveit2[move_group].end_effector_name + function_list.append(lambda r=_res: self.resource_manager(r, ee)) else: - function_list.append(lambda: self.resource_manager(cmd_dict["resource"], "world")) - - constraints = [] - if "constraints" in cmd_dict.keys(): - - for i in range(len(cmd_dict["constraints"])): - v = float(cmd_dict["constraints"][i]) - if v > 0: - constraints.append( - JointConstraint( - joint_name=self.moveit2[cmd_dict["move_group"]].joint_names[i], - position=joint_positions_[i], - tolerance_above=v, - tolerance_below=v, - weight=1.0, - ) + function_list.append(lambda r=_res: self.resource_manager(r, "world")) + + joint_constraint_msgs: list = [] + if constraints: + for i, c in enumerate(constraints): + v = float(c) + if v > 0: + joint_constraint_msgs.append( + JointConstraint( + joint_name=self.moveit2[move_group].joint_names[i], + position=joint_positions_[i], + tolerance_above=v, + tolerance_below=v, + weight=1.0, ) + ) + + if use_lift_path: + retval = None + attempts = config.get("retry", 10) + while retval is None and attempts > 0: + retval = self.moveit2[move_group].compute_fk(joint_positions_) + time.sleep(0.1) + attempts -= 1 + if retval is None: + raise ValueError("Failed to compute forward kinematics") + pose = [retval.pose.position.x, retval.pose.position.y, retval.pose.position.z] + quaternion = [ + retval.pose.orientation.x, + retval.pose.orientation.y, + retval.pose.orientation.z, + retval.pose.orientation.w, + ] + + function_list = [ + lambda: self.moveit_task( + position=[retval.pose.position.x, retval.pose.position.y, retval.pose.position.z], + quaternion=quaternion, + **config, + cartesian=self.cartesian_flag, + ) + ] + function_list + + pose[2] += _lift + function_list.append( + lambda p=pose.copy(), q=quaternion, cfg=config: self.moveit_task( + position=p, quaternion=q, **cfg, cartesian=self.cartesian_flag + ) + ) + end_pose = list(pose) - if "lift_height" in cmd_dict.keys(): - retval = None - retry = config.get("retry", 10) - while retval is None and retry > 0: - retval = self.moveit2[cmd_dict["move_group"]].compute_fk(joint_positions_) - time.sleep(0.1) - retry -= 1 - if retval is None: - result.success = False - return result - pose = [retval.pose.position.x, retval.pose.position.y, retval.pose.position.z] - quaternion = [ - retval.pose.orientation.x, - retval.pose.orientation.y, - retval.pose.orientation.z, - retval.pose.orientation.w, - ] + if has_lateral_offset: + if abs(_xd) > 1e-9: + deep_pose = deepcopy(pose) + deep_pose[0] += _xd + else: + deep_pose = deepcopy(pose) + deep_pose[1] += _yd function_list = [ - lambda: self.moveit_task( - position=[retval.pose.position.x, retval.pose.position.y, retval.pose.position.z], - quaternion=quaternion, - **config, - cartesian=self.cartesian_flag, + lambda p=pose.copy(), q=quaternion, cfg=config: self.moveit_task( + position=p, quaternion=q, **cfg, cartesian=self.cartesian_flag ) ] + function_list - - pose[2] += float(cmd_dict["lift_height"]) function_list.append( - lambda: self.moveit_task( - position=pose, quaternion=quaternion, **config, cartesian=self.cartesian_flag + lambda dp=deep_pose.copy(), q=quaternion, cfg=config: self.moveit_task( + position=dp, quaternion=q, **cfg, cartesian=self.cartesian_flag ) ) - end_pose = pose - - if "x_distance" in cmd_dict.keys() or "y_distance" in cmd_dict.keys(): - if "x_distance" in cmd_dict.keys(): - deep_pose = deepcopy(pose) - deep_pose[0] += float(cmd_dict["x_distance"]) - elif "y_distance" in cmd_dict.keys(): - deep_pose = deepcopy(pose) - deep_pose[1] += float(cmd_dict["y_distance"]) - - function_list = [ - lambda: self.moveit_task( - position=pose, quaternion=quaternion, **config, cartesian=self.cartesian_flag - ) - ] + function_list - function_list.append( - lambda: self.moveit_task( - position=deep_pose, quaternion=quaternion, **config, cartesian=self.cartesian_flag - ) - ) - end_pose = deep_pose + end_pose = list(deep_pose) + + retval_ik = None + attempts_ik = config.get("retry", 10) + while retval_ik is None and attempts_ik > 0: + retval_ik = self.moveit2[move_group].compute_ik( + position=end_pose, + quat_xyzw=quaternion, + constraints=Constraints(joint_constraints=joint_constraint_msgs), + ) + time.sleep(0.1) + attempts_ik -= 1 + if retval_ik is None: + raise ValueError("Failed to compute inverse kinematics") + position_ = [ + retval_ik.position[retval_ik.name.index(i)] for i in self.moveit2[move_group].joint_names + ] + jn = self.moveit2[move_group].joint_names + function_list = [ + lambda pos=position_, names=jn, cfg=config: self.moveit_joint_task( + joint_positions=pos, joint_names=names, **cfg + ) + ] + function_list + else: + function_list = [lambda cfg=config, jp=joint_positions_: self.moveit_joint_task(**cfg, joint_positions=jp)] + function_list - retval_ik = None - retry = config.get("retry", 10) - while retval_ik is None and retry > 0: - retval_ik = self.moveit2[cmd_dict["move_group"]].compute_ik( - position=end_pose, quat_xyzw=quaternion, constraints=Constraints(joint_constraints=constraints) - ) - time.sleep(0.1) - retry -= 1 - if retval_ik is None: - result.success = False - return result - position_ = [ - retval_ik.position[retval_ik.name.index(i)] - for i in self.moveit2[cmd_dict["move_group"]].joint_names - ] - function_list = [ - lambda: self.moveit_joint_task( - joint_positions=position_, - joint_names=self.moveit2[cmd_dict["move_group"]].joint_names, - **config, - ) - ] + function_list + for i in range(len(function_list)): + if i == 0: + self.cartesian_flag = False else: - function_list = [ - lambda: self.moveit_joint_task(**config, joint_positions=joint_positions_) - ] + function_list - - for i in range(len(function_list)): - if i == 0: - self.cartesian_flag = False - else: - self.cartesian_flag = True + self.cartesian_flag = True - re = function_list[i]() - if not re: - print(i, re) - result.success = False - return result - result.success = True + re = function_list[i]() + if not re: + print(i, re) + raise ValueError(f"Failed to execute moveit task: {i}") except Exception as e: - print(e) self.cartesian_flag = False - result.success = False - - return result + raise e def set_status(self, command: str): """ diff --git a/unilabos/registry/decorators.py b/unilabos/registry/decorators.py index 25a2e57f8..b4b9e7b98 100644 --- a/unilabos/registry/decorators.py +++ b/unilabos/registry/decorators.py @@ -44,6 +44,7 @@ def is_open(self): ... """ +import asyncio from enum import Enum from functools import wraps from typing import Any, Callable, Dict, List, Optional, TypeVar @@ -378,9 +379,15 @@ def AddProtocol(self): ... """ def decorator(func: F) -> F: - @wraps(func) - def wrapper(*args, **kwargs): - return func(*args, **kwargs) + # 保留原函数的协程特性,避免 asyncio.iscoroutinefunction 误判 + if asyncio.iscoroutinefunction(func): + @wraps(func) + async def wrapper(*args, **kwargs): + return await func(*args, **kwargs) + else: + @wraps(func) + def wrapper(*args, **kwargs): + return func(*args, **kwargs) # action_type 为哨兵值 => 用户没传, 视为 None (UniLabJsonCommand) resolved_type = None if action_type is _ACTION_TYPE_UNSET else action_type diff --git a/unilabos/registry/devices/hotel.yaml b/unilabos/registry/devices/hotel.yaml index fdcc89dd0..5a967ad7b 100644 --- a/unilabos/registry/devices/hotel.yaml +++ b/unilabos/registry/devices/hotel.yaml @@ -5,7 +5,7 @@ hotel.thermo_orbitor_rs2_hotel: action_value_mappings: {} module: unilabos.devices.resource_container.container:HotelContainer status_types: - rotation: '' + get_rotation: geometry_msgs.msg:Point type: python config_info: [] description: Thermo Orbitor RS2 Hotel容器设备,用于实验室样品的存储和管理。该设备通过HotelContainer类实现容器的旋转控制和状态监控,主要用于存储实验样品、试剂瓶或其他实验器具,支持旋转功能以便于样品的自动化存取。适用于需要有序存储和快速访问大量样品的实验室自动化场景。 @@ -17,6 +17,17 @@ hotel.thermo_orbitor_rs2_hotel: device_config: type: object rotation: + properties: + x: + type: number + y: + type: number + z: + type: number + required: + - x + - y + - z type: object required: - rotation @@ -25,7 +36,18 @@ hotel.thermo_orbitor_rs2_hotel: data: properties: rotation: - type: string + properties: + x: + type: number + y: + type: number + z: + type: number + required: + - x + - y + - z + type: object required: - rotation type: object diff --git a/unilabos/registry/devices/robot_arm.yaml b/unilabos/registry/devices/robot_arm.yaml index ff357ad4a..d39a5cc09 100644 --- a/unilabos/registry/devices/robot_arm.yaml +++ b/unilabos/registry/devices/robot_arm.yaml @@ -173,48 +173,64 @@ robotic_arm.SCARA_with_slider.moveit.virtual: type: object type: UniLabJsonCommand pick_and_place: - feedback: - status: status - goal: - command: command + feedback: {} + goal: {} goal_default: - command: '' + constraints: [] + lift_height: 0 + move_group: "" + option: "" + resource: "" + retry: 0 + speed: 0 + status: "" + target: "" + x_distance: 0 + y_distance: 0 handles: {} placeholder_keys: {} - result: - return_info: return_info - success: success + result: {} schema: - description: '' + description: pick_and_place 显式参数(UniLabJsonCommand) properties: - feedback: - additionalProperties: false - properties: - status: - type: string - title: SendCmd_Feedback - type: object + feedback: {} goal: - additionalProperties: false properties: - command: + constraints: + items: + type: number + type: array + lift_height: + type: number + move_group: type: string - title: SendCmd_Goal - type: object - result: - additionalProperties: false - properties: - return_info: + option: type: string - success: - type: boolean - title: SendCmd_Result + resource: + type: string + retry: + type: number + speed: + type: number + status: + type: string + target: + type: string + x_distance: + type: number + y_distance: + type: number + required: + - option + - move_group + - status type: object + result: {} required: - goal - title: SendCmd + title: pick_and_place参数 type: object - type: SendCmd + type: UniLabJsonCommand set_position: feedback: status: status diff --git a/unilabos/registry/devices/robot_linear_motion.yaml b/unilabos/registry/devices/robot_linear_motion.yaml index 74b01e806..d31e3afe8 100644 --- a/unilabos/registry/devices/robot_linear_motion.yaml +++ b/unilabos/registry/devices/robot_linear_motion.yaml @@ -684,48 +684,64 @@ linear_motion.toyo_xyz.sim: type: object type: UniLabJsonCommand pick_and_place: - feedback: - status: status - goal: - command: command + feedback: {} + goal: {} goal_default: - command: '' + constraints: [] + lift_height: 0 + move_group: "" + option: "" + resource: "" + retry: 0 + speed: 0 + status: "" + target: "" + x_distance: 0 + y_distance: 0 handles: {} placeholder_keys: {} - result: - return_info: return_info - success: success + result: {} schema: - description: '' + description: pick_and_place 显式参数(UniLabJsonCommand) properties: - feedback: - additionalProperties: false - properties: - status: - type: string - title: SendCmd_Feedback - type: object + feedback: {} goal: - additionalProperties: false properties: - command: + constraints: + items: + type: number + type: array + lift_height: + type: number + move_group: type: string - title: SendCmd_Goal - type: object - result: - additionalProperties: false - properties: - return_info: + option: type: string - success: - type: boolean - title: SendCmd_Result + resource: + type: string + retry: + type: number + speed: + type: number + status: + type: string + target: + type: string + x_distance: + type: number + y_distance: + type: number + required: + - option + - move_group + - status type: object + result: {} required: - goal - title: SendCmd + title: pick_and_place参数 type: object - type: SendCmd + type: UniLabJsonCommand set_position: feedback: status: status diff --git a/unilabos/registry/devices/seal.yaml b/unilabos/registry/devices/seal.yaml new file mode 100644 index 000000000..acfef7afc --- /dev/null +++ b/unilabos/registry/devices/seal.yaml @@ -0,0 +1,150 @@ +sealer: + category: + - sealer + class: + action_value_mappings: + auto-close: + feedback: {} + goal: {} + goal_default: {} + handles: {} + placeholder_keys: {} + result: {} + schema: + description: 关闭封膜机机构。 + properties: + feedback: {} + goal: + properties: {} + required: [] + type: object + result: {} + required: + - goal + title: close参数 + type: object + type: UniLabJsonCommandAsync + auto-get_temperature: + feedback: {} + goal: {} + goal_default: {} + handles: {} + placeholder_keys: {} + result: {} + schema: + description: 获取封膜机温度。 + properties: + feedback: {} + goal: + properties: {} + required: [] + type: object + result: + type: number + required: + - goal + title: get_temperature参数 + type: object + type: UniLabJsonCommandAsync + auto-open: + feedback: {} + goal: {} + goal_default: {} + handles: {} + placeholder_keys: {} + result: {} + schema: + description: 打开封膜机机构。 + properties: + feedback: {} + goal: + properties: {} + required: [] + type: object + result: {} + required: + - goal + title: open参数 + type: object + type: UniLabJsonCommandAsync + auto-seal: + feedback: {} + goal: {} + goal_default: + duration: 3.0 + temperature: 100 + handles: {} + placeholder_keys: {} + result: {} + schema: + description: 按设定温度和持续时间执行微孔板封膜。 + properties: + feedback: {} + goal: + properties: + duration: + default: 3.0 + type: number + temperature: + default: 100 + type: integer + required: + - temperature + - duration + type: object + result: {} + required: + - goal + title: seal参数 + type: object + type: UniLabJsonCommandAsync + auto-set_temperature: + feedback: {} + goal: {} + goal_default: + temperature: 100.0 + handles: {} + placeholder_keys: {} + result: {} + schema: + description: 设置封膜机温度。 + properties: + feedback: {} + goal: + properties: + temperature: + default: 100.0 + type: number + required: + - temperature + type: object + result: {} + required: + - goal + title: set_temperature参数 + type: object + type: UniLabJsonCommandAsync + module: unilabos.devices._phage_display.sealer:Sealer + status_types: {} + type: python + config_info: [] + description: 用于实验室自动化中对微孔板进行封膜的设备,可执行封膜、开合机构控制,以及温度设置与读取。 + handles: [] + icon: '' + init_param_schema: + config: + properties: + backend: + type: object + required: + - backend + type: object + data: + properties: {} + required: [] + type: object + model: + mesh: sealer + path: https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/devices/sealer/macro_device.xacro + type: device + version: 1.0.0 diff --git a/unilabos/resources/graphio.py b/unilabos/resources/graphio.py index c8f1cc2cd..603c4c1df 100644 --- a/unilabos/resources/graphio.py +++ b/unilabos/resources/graphio.py @@ -42,7 +42,7 @@ def canonicalize_nodes_data( Returns: ResourceTreeSet: 标准化后的资源树集合 """ - print_status(f"{len(nodes)} Resources loaded", "info") + print_status(f"{len(nodes)} Resources loaded:", "info") # 第一步:基本预处理(处理graphml的label字段) outer_host_node_id = None @@ -593,7 +593,7 @@ def resource_ulab_to_plr_inner(resource: dict): "size_y": resource["config"].get("size_y", 0), "size_z": resource["config"].get("size_z", 0), "location": {**resource["position"], "type": "Coordinate"}, - "rotation": {"x": 0, "y": 0, "z": 0, "type": "Rotation"}, # Resource如果没有rotation,是plr版本太低 + "rotation": {resource["config"].get("rotation", {"x": 0, "y": 0, "z": 0, "type": "Rotation"})}, # Resource如果没有rotation,是plr版本太低 "category": resource["type"], "model": resource["config"].get("model", None), # resource中deck没有model "children": ( diff --git a/unilabos/resources/resource_tracker.py b/unilabos/resources/resource_tracker.py index 3fb945b64..8b305bce1 100644 --- a/unilabos/resources/resource_tracker.py +++ b/unilabos/resources/resource_tracker.py @@ -244,6 +244,12 @@ def get_resource_instance_from_dict(cls, content: ResourceDictType) -> "Resource height= content["config"].get("size_y", 0), depth= content["config"].get("size_z", 0), ) + if "rotation" not in pose: + pose["rotation"] = ResourceDictPositionObjectType( + x=content["config"].get("rotation", {}).get("x", 0), + y=content["config"].get("rotation", {}).get("y", 0), + z=content["config"].get("rotation", {}).get("z", 0), + ) content["pose"] = pose try: res_dict = ResourceDict.model_validate(content) diff --git a/unilabos/ros/main_slave_run.py b/unilabos/ros/main_slave_run.py index 7dca43e81..85e9f0823 100644 --- a/unilabos/ros/main_slave_run.py +++ b/unilabos/ros/main_slave_run.py @@ -91,7 +91,7 @@ def main( device_id="resource_mesh_manager", device_uuid=str(uuid.uuid4()), ) - joint_republisher = JointRepublisher("joint_republisher", host_node.resource_tracker) + joint_republisher = JointRepublisher("joint_republisher","", host_node.resource_tracker) # lh_joint_pub = LiquidHandlerJointPublisher( # resources_config=resources_list, resource_tracker=host_node.resource_tracker # ) diff --git a/unilabos/ros/nodes/presets/host_node.py b/unilabos/ros/nodes/presets/host_node.py index e5e212b1b..bb6e5f647 100644 --- a/unilabos/ros/nodes/presets/host_node.py +++ b/unilabos/ros/nodes/presets/host_node.py @@ -9,6 +9,7 @@ from action_msgs.msg import GoalStatus from geometry_msgs.msg import Point +from sensor_msgs.msg import JointState as JointStateMsg from rclpy.action import ActionClient, get_action_server_names_and_types_by_node from rclpy.service import Service from typing_extensions import TypedDict @@ -350,6 +351,10 @@ def __init__( else: self.lab_logger().warning(f"[Host Node] Device {device_id} already existed, skipping.") self.update_device_status_subscriptions() + + # 订阅 joint_state_repub topic,桥接关节数据到云端 + self._init_joint_state_bridge() + # TODO: 需要验证 初始化所有控制器节点 if controllers_config: update_rate = controllers_config["controller_manager"]["ros__parameters"]["update_rate"] @@ -784,6 +789,179 @@ def property_callback(self, msg, device_id: str, property_name: str) -> None: else: self.lab_logger().trace(f"Status updated: {device_id}.{property_name} = {msg.data}") + """关节数据 & 资源跟随桥接""" + + # 吞吐优化参数 + _JOINT_DEAD_BAND: float = 1e-4 # 关节角度变化小于此值视为无变化 + _JOINT_MIN_INTERVAL: float = 0.05 # 最小发送间隔 (秒),限制到 ~20Hz + + def _init_joint_state_bridge(self): + """ + 订阅 /joint_states (sensor_msgs/JointState) 和 resource_pose (String), + 构建 device_id → uuid 映射,并维护 resource_poses 状态。 + + 吞吐优化: + - 死区过滤 (dead band): 关节角度变化 < 阈值时不发送 + - 抑频 (throttle): 限制最大发送频率,避免 ROS2 1kHz 打满 WS + - 增量 resource_poses: 仅在 resource_pose 实际变化时才附带发送 + """ + # 构建 device_id → cloud_uuid 映射(从 devices_config 中获取) + self._device_uuid_map: Dict[str, str] = {} + for tree in self.devices_config.trees: + node = tree.root_node + if node.res_content.type == "device" and node.res_content.uuid: + self._device_uuid_map[node.res_content.id] = node.res_content.uuid + + # 按 device_id 长度降序排列,最长前缀优先匹配(避免 arm 抢先匹配 arm_left_j1) + self._device_ids_sorted = sorted(self._device_uuid_map.keys(), key=len, reverse=True) + + # 资源挂载状态:{resource_id: parent_link_name} + self._resource_poses: Dict[str, str] = {} + # resource_pose 变化标志,仅在真正变化时随关节数据发送 + self._resource_poses_dirty: bool = False + + # 吞吐优化状态 + self._last_joint_values: Dict[str, float] = {} # 上次发送的关节值(全局) + self._last_send_time: float = -float("inf") # 上次发送时间戳(初始为-inf确保首条通过) + self._last_sent_resource_poses: Dict[str, str] = {} # 上次发送的 resource_poses 快照 + + if not self._device_uuid_map: + self.lab_logger().debug("[Host Node] 无设备 UUID 映射,跳过关节桥接") + return + + # 直接订阅 /joint_states(sensor_msgs/JointState),无需经过 JointRepublisher + self.create_subscription( + JointStateMsg, + "/joint_states", + self._joint_state_callback, + 10, + callback_group=self.callback_group, + ) + + # 订阅 resource_pose(资源挂载变化,由 ResourceMeshManager 发布) + from std_msgs.msg import String as StdString + self.create_subscription( + StdString, + "resource_pose", + self._resource_pose_callback, + 10, + callback_group=self.callback_group, + ) + + self.lab_logger().info( + f"[Host Node] 已订阅 /joint_states 和 resource_pose,设备映射: {list(self._device_uuid_map.keys())}" + ) + + def _resource_pose_callback(self, msg): + """ + 接收 ResourceMeshManager 发布的资源挂载变更。 + + msg.data 格式: JSON dict,如 {"tip_rack_A1": "gripper_link", "plate_1": "deck_link"} + 空 dict {} 表示无变化(心跳包)。 + """ + try: + data = json.loads(msg.data) + except (json.JSONDecodeError, ValueError): + return + if not isinstance(data, dict) or not data: + return + # 检测实际变化 + has_change = False + for k, v in data.items(): + if self._resource_poses.get(k) != v: + has_change = True + break + if has_change: + self._resource_poses.update(data) + self._resource_poses_dirty = True + + def _joint_state_callback(self, msg: JointStateMsg): + """ + 直接接收 /joint_states (sensor_msgs/JointState),按设备分组后通过 bridge 发送到云端。 + + 吞吐优化: + 1. 抑频: 距上次发送 < _JOINT_MIN_INTERVAL 则跳过(除非有 resource_pose 变化) + 2. 死区: 所有关节角度变化 < _JOINT_DEAD_BAND 则跳过(除非有 resource_pose 变化) + 3. 增量 resource_poses: 仅在 dirty 时附带,否则发空 dict + """ + names = list(msg.name) + positions = list(msg.position) + if not names or len(names) != len(positions): + return + + now = time.time() + resource_dirty = self._resource_poses_dirty + + # 抑频检查:resource_pose 变化时强制发送 + if not resource_dirty and (now - self._last_send_time) < self._JOINT_MIN_INTERVAL: + return + + # 死区过滤:检测是否有关节值实质变化 + has_significant_change = False + for name, pos in zip(names, positions): + last_val = self._last_joint_values.get(name) + if last_val is None or abs(float(pos) - last_val) >= self._JOINT_DEAD_BAND: + has_significant_change = True + break + + # 无关节变化且无资源变化 → 跳过 + if not has_significant_change and not resource_dirty: + return + + # 更新上次发送的关节值 + for name, pos in zip(names, positions): + self._last_joint_values[name] = float(pos) + self._last_send_time = now + + # 按设备 ID 分组关节数据(最长前缀优先匹配) + device_joints: Dict[str, Dict[str, float]] = {} + for name, pos in zip(names, positions): + matched_device = None + for device_id in self._device_ids_sorted: + if name.startswith(device_id + "_"): + matched_device = device_id + break + + if matched_device: + if matched_device not in device_joints: + device_joints[matched_device] = {} + device_joints[matched_device][name] = float(pos) + elif len(self._device_uuid_map) == 1: + fallback_id = self._device_ids_sorted[0] + if fallback_id not in device_joints: + device_joints[fallback_id] = {} + device_joints[fallback_id][name] = float(pos) + + # 构建设备级 resource_poses(仅在 dirty 时附带实际数据) + device_resource_poses: Dict[str, Dict[str, str]] = {} + if resource_dirty: + for resource_id, link_name in self._resource_poses.items(): + matched_device = None + for device_id in self._device_ids_sorted: + if link_name.startswith(device_id + "_"): + matched_device = device_id + break + if matched_device: + if matched_device not in device_resource_poses: + device_resource_poses[matched_device] = {} + device_resource_poses[matched_device][resource_id] = link_name + elif len(self._device_uuid_map) == 1: + fallback_id = self._device_ids_sorted[0] + if fallback_id not in device_resource_poses: + device_resource_poses[fallback_id] = {} + device_resource_poses[fallback_id][resource_id] = link_name + self._resource_poses_dirty = False + + # 通过 bridge 发送 push_joint_state(含 resource_poses) + for device_id, joint_states in device_joints.items(): + node_uuid = self._device_uuid_map.get(device_id) + if not node_uuid: + continue + resource_poses = device_resource_poses.get(device_id, {}) + for bridge in self.bridges: + if hasattr(bridge, "publish_joint_state"): + bridge.publish_joint_state(node_uuid, joint_states, resource_poses) + def send_goal( self, item: "QueueItem", diff --git a/unilabos/ros/nodes/presets/joint_republisher.py b/unilabos/ros/nodes/presets/joint_republisher.py index b82903779..2fbbd6411 100644 --- a/unilabos/ros/nodes/presets/joint_republisher.py +++ b/unilabos/ros/nodes/presets/joint_republisher.py @@ -41,7 +41,7 @@ def listener_callback(self, msg:JointState): json_dict["velocity"] = list(msg.velocity) json_dict["effort"] = list(msg.effort) - self.msg.data = str(json_dict) + self.msg.data = json.dumps(json_dict) self.joint_repub.publish(self.msg) # print('-'*20) # print(self.msg.data) diff --git a/unilabos/test/experiments/demo_3D_1longarm.json b/unilabos/test/experiments/demo_3D_1longarm.json new file mode 100644 index 000000000..3c41cc61e --- /dev/null +++ b/unilabos/test/experiments/demo_3D_1longarm.json @@ -0,0 +1,560 @@ +{ + "nodes": [ + { + "uuid": "f976256b-1c24-4bbb-a8a3-e5dcf16572ab", + "parent_uuid": "", + "id": "agilent_biotek_406_fx", + "name": "agilent_biotek_406_fx", + "type": "device", + "class": "agilent_biotek_406_fx", + "parent": "", + "pose": { + "layout": "x-y", + "position": { + "x": 1830, + "y": -150, + "z": 0 + }, + "position_3d": { + "x": 0, + "y": 0, + "z": 0 + }, + "size": { + "width": 0, + "height": 0, + "depth": 0 + }, + "scale": { + "x": 1, + "y": 1, + "z": 1 + }, + "rotation": { + "x": 0, + "y": 0, + "z": 4.7124 + }, + "extra": null, + "cross_section_type": "rectangle" + }, + "config": {}, + "data": {}, + "schema": {}, + "description": "", + "model": { + "mesh": "agilent_biotek_406_fx", + "path": "https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/devices/agilent_biotek_406_fx/macro_device.xacro", + "type": "device", + "format": "xacro" + }, + "position": { + "x": 1830, + "y": -150, + "z": 0 + } + }, + { + "uuid": "2a2bb86f-7b14-407c-87a1-675d4f25bf69", + "parent_uuid": "", + "id": "cytomat_backend", + "name": "cytomat_backend", + "type": "device", + "class": "cytomat_backend", + "parent": "", + "pose": { + "layout": "x-y", + "position": { + "x": 1200, + "y": -150, + "z": 0 + }, + "position_3d": { + "x": 0, + "y": 0, + "z": 0 + }, + "size": { + "width": 0, + "height": 0, + "depth": 0 + }, + "scale": { + "x": 1, + "y": 1, + "z": 1 + }, + "rotation": { + "x": 0, + "y": 0, + "z": 3.1416 + }, + "extra": null, + "cross_section_type": "rectangle" + }, + "config": {}, + "data": {}, + "schema": {}, + "description": "", + "model": { + "mesh": "cytomat_backend", + "path": "https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/devices/cytomat_backend/macro_device.xacro", + "type": "device", + "format": "xacro" + }, + "position": { + "x": 1200, + "y": -150, + "z": 0 + } + }, + { + "uuid": "1c04f3e7-5f13-4102-9b29-0250fea4fd4b", + "parent_uuid": "", + "id": "robotic_arm_SCARA_with_slider_moveit_virtual", + "name": "robotic_arm_SCARA_with_slider", + "type": "device", + "class": "robotic_arm.SCARA_with_slider.moveit.virtual", + "parent": "", + "pose": { + "layout": "x-y", + "position": { + "x": -700, + "y": -800, + "z": 0 + }, + "position_3d": { + "x": 0, + "y": 0, + "z": 0 + }, + "size": { + "width": 0, + "height": 0, + "depth": 0 + }, + "scale": { + "x": 1, + "y": 1, + "z": 1 + }, + "rotation": { + "x": 0, + "y": 0, + "z": 0 + }, + "extra": null, + "cross_section_type": "rectangle" + }, + "config": { + "rotation": { + "x": 0, + "y": 0, + "z": -1.5708, + "type": "Rotation" + }, + "joint_poses": { + "arm": { + "home": [ + 0, + 0, + 0, + 0, + 0 + ], + "home_1": [ + 1, + 0, + 0, + 0, + 0 + ], + "home_2": [ + 2, + 0, + 0, + 0, + 0 + ], + "home_3": [ + 3, + 0, + 0, + 0, + 0 + ], + "home_4": [ + 4, + 0, + 0, + 0, + 0 + ], + "bd_facsmelody": [4.156, 0.1, -1.0472, -1.0472, 0.5236], + "qiagen_qiacube_connect": [3.52, 0.1, -0.5236, -1.5708, 0.5236], + "applied_biosystems_seqstudio_genetic_analyzer": [2.86, 0.1, -1.0472, -1.0472, 0.5236], + "cytiva_biacore_8k_plus": [2.12, 0.445, -1.0472, -1.0472, 0.5236], + "cytiva_akta_pure": [1.28, 0.15, -1.0472, -1.0472, 0.5236], + "molecular_devices_qpix_420": [3.499, 0.291, 1.0472, 1.0472, -0.5236], + "tecan_resolvex_a200": [2.639, 0.247, 1.0472, 1.0472, -0.5236], + "agilent_biotek_406_fx": [2.219, 0.148, 1.2741, 1.2741, -0.9599], + "cytomat_backend": [1.236, 0.364, -0.4189, 1.7104, 0.2618], + "vantage_backend": [0.61, 0.211, -0.4189, 1.7104, 0.2618], + "hettich_rotanta_460_robotic": [0.108, 0.148, -1.6581, -1.3788, 1.4835], + "telesis_bio_bioxp_3250": [0.636, 0.224, -1.0472, -1.0472, 0.576], + "hotel.thermo_orbitor_rs2_hotel": [3.051, 0.176, 0.2967, 1.1519, 0.1396], + "peeler": [3.651, 0.008, 0.6981, 1.4486, -0.5934], + "sealer": [4.063, 0.039, -0.3316, 1.8326, 0.0349] + } + }, + "moveit_type": "arm_slider", + "device_config": {} + }, + "data": {}, + "schema": {}, + "description": "", + "model": { + "mesh": "arm_slider", + "path": "https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/devices/arm_slider/macro_device.xacro", + "type": "device", + "format": "xacro" + }, + "position": { + "x": -700, + "y": -800, + "z": 0 + } + }, + { + "uuid": "e4c42e82-2b88-4722-912b-6e59c13be802", + "parent_uuid": "", + "id": "vantage_backend", + "name": "vantage_backend", + "type": "device", + "class": "vantage_backend", + "parent": "", + "pose": { + "layout": "x-y", + "position": { + "x": 130.29459, + "y": -200, + "z": 0 + }, + "position_3d": { + "x": 0, + "y": 0, + "z": 0 + }, + "size": { + "width": 0, + "height": 0, + "depth": 0 + }, + "scale": { + "x": 1, + "y": 1, + "z": 1 + }, + "rotation": { + "x": 0, + "y": 0, + "z": 0 + }, + "extra": null, + "cross_section_type": "rectangle" + }, + "config": {}, + "data": { + "num_channels": 8, + "module_id_length": 4 + }, + "schema": {}, + "description": "", + "model": { + "mesh": "hamilton_vantage", + "path": "https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/devices/hamilton_vantage/macro_device.xacro", + "type": "device", + "format": "xacro" + }, + "position": { + "x": 130.29459, + "y": -200, + "z": 0 + } + }, + { + "uuid": "0ab03208-9853-4c73-8e87-137104b757f4", + "parent_uuid": "", + "id": "host_node", + "name": "host_node", + "type": "device", + "class": "host_node", + "parent": "", + "pose": { + "layout": "x-y", + "position": { + "x": 0, + "y": 0, + "z": 0 + }, + "position_3d": { + "x": 0, + "y": 0, + "z": 0 + }, + "size": { + "width": 0, + "height": 0, + "depth": 0 + }, + "scale": { + "x": 1, + "y": 1, + "z": 1 + }, + "rotation": { + "x": 0, + "y": 0, + "z": 0 + }, + "extra": null, + "cross_section_type": "rectangle" + }, + "config": {}, + "data": {}, + "schema": {}, + "description": "", + "model": null, + "position": { + "x": 0, + "y": 0, + "z": 0 + } + }, + { + "uuid": "6328814d-0f66-445d-938e-e41a24456fd7", + "parent_uuid": "", + "id": "tecan_resolvex_a200", + "name": "tecan_resolvex_a200", + "type": "device", + "class": "tecan_resolvex_a200", + "parent": "", + "pose": { + "layout": "x-y", + "position": { + "x": 2350, + "y": -150, + "z": 0 + }, + "position_3d": { + "x": 0, + "y": 0, + "z": 0 + }, + "size": { + "width": 0, + "height": 0, + "depth": 0 + }, + "scale": { + "x": 1, + "y": 1, + "z": 1 + }, + "rotation": { + "x": 0, + "y": 0, + "z": 0 + }, + "extra": null, + "cross_section_type": "rectangle" + }, + "config": {}, + "data": {}, + "schema": {}, + "description": "", + "model": { + "mesh": "tecan_resolvex_a200", + "path": "https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/devices/tecan_resolvex_a200/macro_device.xacro", + "type": "device", + "format": "xacro" + }, + "position": { + "x": 2350, + "y": -150, + "z": 0 + } + }, + { + "uuid": "0e544ee2-eda4-48e6-a150-9dd4b23ce594", + "parent_uuid": "", + "id": "peeler", + "name": "peeler", + "type": "device", + "class": "peeler", + "parent": "", + "pose": { + "layout": "x-y", + "position": { + "x": 3450, + "y": -280, + "z": 0 + }, + "position_3d": { + "x": 0, + "y": 0, + "z": 0 + }, + "size": { + "width": 0, + "height": 0, + "depth": 0 + }, + "scale": { + "x": 1, + "y": 1, + "z": 1 + }, + "rotation": { + "x": 0, + "y": 0, + "z": 3.1415927 + }, + "extra": null, + "cross_section_type": "rectangle" + }, + "config": { + "backend":{} + }, + "data": null, + "schema": null, + "model": { + "mesh": "peeler", + "path": "https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/devices/peeler/macro_device.xacro", + "type": "device", + "format": "xacro" + }, + "position": { + "x": 3450, + "y": -280, + "z": 0 + } + }, + { + "uuid": "b3f672d4-4ccb-458c-82fa-18d8b1495037", + "parent_uuid": "", + "id": "sealer", + "name": "sealer", + "type": "device", + "class": "sealer", + "parent": "", + "pose": { + "layout": "x-y", + "position": { + "x": 4000, + "y": -200, + "z": 0 + }, + "position_3d": { + "x": 0, + "y": 0, + "z": 0 + }, + "size": { + "width": 0, + "height": 0, + "depth": 0 + }, + "scale": { + "x": 1, + "y": 1, + "z": 1 + }, + "rotation": { + "x": 0, + "y": 0, + "z": 3.1415927 + }, + "extra": null, + "cross_section_type": "rectangle" + }, + "config": { + "backend":{} + }, + "data": null, + "schema": null, + "model": { + "mesh": "sealer", + "path": "https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/devices/sealer/macro_device.xacro", + "type": "device", + "format": "xacro" + }, + "position": { + "x": 4000, + "y": -200, + "z": 0 + } + }, + { + "uuid": "e892d6d6-0982-4253-a664-95ada1628e25", + "parent_uuid": "", + "id": "hotel_thermo_orbitor_rs2_hotel", + "name": "hotel.thermo_orbitor_rs2_hotel", + "type": "device", + "class": "hotel.thermo_orbitor_rs2_hotel", + "parent": "", + "pose": { + "layout": "x-y", + "position": { + "x": 3000, + "y": -350, + "z": 0 + }, + "position_3d": { + "x": 0, + "y": 0, + "z": 0 + }, + "size": { + "width": 0, + "height": 0, + "depth": 0 + }, + "scale": { + "x": 1, + "y": 1, + "z": 1 + }, + "rotation": { + "x": 0, + "y": 0, + "z": 3.1415927 + }, + "extra": null, + "cross_section_type": "rectangle" + }, + "config": { + "rotation":{ + "x": 0, + "y": 0, + "z": 0 + }, + "device_config":{} + }, + "data": null, + "schema": null, + "model": { + "mesh": "thermo_orbitor_rs2_hotel", + "path": "https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/devices/thermo_orbitor_rs2_hotel/macro_device.xacro", + "type": "device", + "format": "xacro" + }, + "position": { + "x": 3000, + "y": -350, + "z": 0 + } + } + ], + "edges": [] +} \ No newline at end of file diff --git a/unilabos/test/experiments/phage_display_devices_dedup_2arm_v7.json b/unilabos/test/experiments/phage_display_devices_dedup_2arm_v7.json new file mode 100644 index 000000000..af3a256f6 --- /dev/null +++ b/unilabos/test/experiments/phage_display_devices_dedup_2arm_v7.json @@ -0,0 +1,809 @@ + { + "nodes": [ + { + "uuid": "c6695028-5ead-4a0d-9679-3fd55bde041e", + "parent_uuid": "", + "id": "cytomat_backend", + "name": "cytomat_backend", + "type": "device", + "class": "cytomat_backend", + "parent": "", + "pose": { + "layout": "x-y", + "position": { + "x": 1200, + "y": -150, + "z": 0 + }, + "position_3d": { + "x": 0, + "y": 0, + "z": 0 + }, + "size": { + "width": 0, + "height": 0, + "depth": 0 + }, + "scale": { + "x": 1, + "y": 1, + "z": 1 + }, + "rotation": { + "x": 0, + "y": 0, + "z": 3.1416 + }, + "extra": null, + "cross_section_type": "rectangle" + }, + "config": {}, + "data": {}, + "schema": {}, + "description": "", + "model": { + "mesh": "cytomat_backend", + "path": "https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/devices/cytomat_backend/macro_device.xacro", + "type": "device" + }, + "position": { + "x": 1200, + "y": -150, + "z": 0 + } + }, + { + "uuid": "1b4f9f79-ce50-4273-aa1e-89fb0bc32473", + "parent_uuid": "", + "id": "molecular_devices_qpix_420", + "name": "molecular_devices_qpix_420", + "type": "device", + "class": "molecular_devices_qpix_420", + "parent": "", + "pose": { + "layout": "x-y", + "position": { + "x": 3400, + "y": -50, + "z": 0 + }, + "position_3d": { + "x": 0, + "y": 0, + "z": 0 + }, + "size": { + "width": 0, + "height": 0, + "depth": 0 + }, + "scale": { + "x": 1, + "y": 1, + "z": 1 + }, + "rotation": { + "x": 0, + "y": 0, + "z": 0 + }, + "extra": null, + "cross_section_type": "rectangle" + }, + "config": {}, + "data": {}, + "schema": {}, + "description": "", + "model": { + "mesh": "molecular_devices_qpix_420", + "path": "https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/devices/molecular_devices_qpix_420/macro_device.xacro", + "type": "device" + }, + "position": { + "x": 3400, + "y": -50, + "z": 0 + } + }, + { + "uuid": "12de9fe9-f15d-47d3-9e93-a79d958967ed", + "parent_uuid": "", + "id": "tecan_resolvex_a200", + "name": "tecan_resolvex_a200", + "type": "device", + "class": "tecan_resolvex_a200", + "parent": "", + "pose": { + "layout": "x-y", + "position": { + "x": 2350, + "y": -150, + "z": 0 + }, + "position_3d": { + "x": 0, + "y": 0, + "z": 0 + }, + "size": { + "width": 0, + "height": 0, + "depth": 0 + }, + "scale": { + "x": 1, + "y": 1, + "z": 1 + }, + "rotation": { + "x": 0, + "y": 0, + "z": 0 + }, + "extra": null, + "cross_section_type": "rectangle" + }, + "config": {}, + "data": {}, + "schema": {}, + "description": "", + "model": { + "mesh": "tecan_resolvex_a200", + "path": "https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/devices/tecan_resolvex_a200/macro_device.xacro", + "type": "device" + }, + "position": { + "x": 2350, + "y": -150, + "z": 0 + } + }, + { + "uuid": "c9084fe2-0ae4-4adf-885a-705525241343", + "parent_uuid": "", + "id": "host_node", + "name": "host_node", + "type": "device", + "class": "host_node", + "parent": "", + "pose": { + "layout": "x-y", + "position": { + "x": 0, + "y": 0, + "z": 0 + }, + "position_3d": { + "x": 0, + "y": 0, + "z": 0 + }, + "size": { + "width": 0, + "height": 0, + "depth": 0 + }, + "scale": { + "x": 1, + "y": 1, + "z": 1 + }, + "rotation": { + "x": 0, + "y": 0, + "z": 0 + }, + "extra": null, + "cross_section_type": "rectangle" + }, + "config": {}, + "data": {}, + "schema": {}, + "description": "", + "model": null, + "position": { + "x": 0, + "y": 0, + "z": 0 + } + }, + { + "uuid": "a7f68f3f-87ad-4ee2-80cd-6078e8ed4f5a", + "parent_uuid": "", + "id": "cytiva_biacore_8k_plus", + "name": "cytiva_biacore_8k_plus", + "type": "device", + "class": "cytiva_biacore_8k_plus", + "parent": "", + "pose": { + "layout": "x-y", + "position": { + "x": 1850, + "y": -1450, + "z": 0 + }, + "position_3d": { + "x": 0, + "y": 0, + "z": 0 + }, + "size": { + "width": 0, + "height": 0, + "depth": 0 + }, + "scale": { + "x": 1, + "y": 1, + "z": 1 + }, + "rotation": { + "x": 0, + "y": 0, + "z": 0 + }, + "extra": null, + "cross_section_type": "rectangle" + }, + "config": {}, + "data": {}, + "schema": {}, + "description": "", + "model": { + "mesh": "cytiva_biacore_8k_plus", + "path": "https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/devices/cytiva_biacore_8k_plus/macro_device.xacro", + "type": "device" + }, + "position": { + "x": 1850, + "y": -1450, + "z": 0 + } + }, + { + "uuid": "c5161468-e991-4824-82a9-f6ef277a4a8a", + "parent_uuid": "", + "id": "telesis_bio_bioxp_3250", + "name": "telesis_bio_bioxp_3250", + "type": "device", + "class": "telesis_bio_bioxp_3250", + "parent": "", + "pose": { + "layout": "x-y", + "position": { + "x": 360, + "y": -1580, + "z": 0 + }, + "position_3d": { + "x": 0, + "y": 0, + "z": 0 + }, + "size": { + "width": 0, + "height": 0, + "depth": 0 + }, + "scale": { + "x": 1, + "y": 1, + "z": 1 + }, + "rotation": { + "x": 0, + "y": 0, + "z": 0 + }, + "extra": null, + "cross_section_type": "rectangle" + }, + "config": {}, + "data": {}, + "schema": {}, + "description": "", + "model": { + "mesh": "telesis_bio_bioxp_3250", + "path": "https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/devices/telesis_bio_bioxp_3250/macro_device.xacro", + "type": "device" + }, + "position": { + "x": 360, + "y": -1580, + "z": 0 + } + }, + { + "uuid": "adecc6e6-a53a-48c2-9390-36de662cd864", + "parent_uuid": "", + "id": "robotic_arm_SCARA_with_slider_moveit_virtual", + "name": "robotic_arm_SCARA_with_slider", + "type": "device", + "class": "robotic_arm.SCARA_with_slider.moveit.virtual", + "parent": "", + "pose": { + "layout": "x-y", + "position": { + "x": -700, + "y": -800, + "z": 0 + }, + "position_3d": { + "x": 0, + "y": 0, + "z": 0 + }, + "size": { + "width": 0, + "height": 0, + "depth": 0 + }, + "scale": { + "x": 1, + "y": 1, + "z": 1 + }, + "rotation": { + "x": 0, + "y": 0, + "z": 0 + }, + "extra": null, + "cross_section_type": "rectangle" + }, + "config": { + "rotation": { + "x": 0, + "y": 0, + "z": -1.5708, + "type": "Rotation" + }, + "joint_poses": { + "arm": { + "home": [ + 0, + 0, + 0, + 0, + 0 + ], + "home_1": [ + 1, + 0, + 0, + 0, + 0 + ], + "home_2": [ + 2, + 0, + 0, + 0, + 0 + ], + "home_3": [ + 3, + 0, + 0, + 0, + 0 + ], + "home_4": [ + 4, + 0, + 0, + 0, + 0 + ], + "bd_facsmelody": [4.156, 0.1, -1.0472, -1.0472, 0.5236], + "qiagen_qiacube_connect": [3.52, 0.1, -0.5236, -1.5708, 0.5236], + "applied_biosystems_seqstudio_genetic_analyzer": [2.86, 0.1, -1.0472, -1.0472, 0.5236], + "cytiva_biacore_8k_plus": [2.12, 0.445, -1.0472, -1.0472, 0.5236], + "cytiva_akta_pure": [1.28, 0.15, -1.0472, -1.0472, 0.5236], + "molecular_devices_qpix_420": [3.499, 0.291, 1.0472, 1.0472, -0.5236], + "tecan_resolvex_a200": [2.639, 0.247, 1.0472, 1.0472, -0.5236], + "agilent_biotek_406_fx": [2.219, 0.148, 1.2741, 1.2741, -0.9599], + "cytomat_backend": [1.236, 0.364, -0.4189, 1.7104, 0.2618], + "vantage_backend": [0.61, 0.211, -0.4189, 1.7104, 0.2618], + "hettich_rotanta_460_robotic": [0.108, 0.148, -1.6581, -1.3788, 1.4835], + "telesis_bio_bioxp_3250": [0.636, 0.224, -1.0472, -1.0472, 0.576], + "hotel": [3.051, 0.176, 0.2967, 1.1519, 0.1396], + "plate_peeler": [3.651, 0.008, 0.6981, 1.4486, -0.5934], + "plate_sealer": [4.063, 0.039, -0.3316, 1.8326, 0.0349] + } + }, + "moveit_type": "arm_slider", + "device_config": {} + }, + "data": {}, + "schema": {}, + "description": "", + "model": { + "mesh": "arm_slider", + "path": "https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/devices/arm_slider/macro_device.xacro", + "type": "device" + }, + "position": { + "x": -700, + "y": -800, + "z": 0 + } + }, + { + "uuid": "cdee2d7c-a09f-46ef-84e5-a81275c1a0e4", + "parent_uuid": "", + "id": "cytiva_akta_pure", + "name": "cytiva_akta_pure", + "type": "device", + "class": "cytiva_akta_pure", + "parent": "", + "pose": { + "layout": "x-y", + "position": { + "x": 1050, + "y": -1450, + "z": 0 + }, + "position_3d": { + "x": 0, + "y": 0, + "z": 0 + }, + "size": { + "width": 0, + "height": 0, + "depth": 0 + }, + "scale": { + "x": 1, + "y": 1, + "z": 1 + }, + "rotation": { + "x": 0, + "y": 0, + "z": 0 + }, + "extra": null, + "cross_section_type": "rectangle" + }, + "config": {}, + "data": {}, + "schema": {}, + "description": "", + "model": { + "mesh": "cytiva_akta_pure", + "path": "https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/devices/cytiva_akta_pure/macro_device.xacro", + "type": "device" + }, + "position": { + "x": 1050, + "y": -1450, + "z": 0 + } + }, + { + "uuid": "ee00901a-1e0b-4fd7-9a8e-78432d8b8a4c", + "parent_uuid": "", + "id": "qiagen_qiacube_connect", + "name": "qiagen_qiacube_connect", + "type": "device", + "class": "qiagen_qiacube_connect", + "parent": "", + "pose": { + "layout": "x-y", + "position": { + "x": 3230, + "y": -1450, + "z": 0 + }, + "position_3d": { + "x": 0, + "y": 0, + "z": 0 + }, + "size": { + "width": 0, + "height": 0, + "depth": 0 + }, + "scale": { + "x": 1, + "y": 1, + "z": 1 + }, + "rotation": { + "x": 0, + "y": 0, + "z": 0 + }, + "extra": null, + "cross_section_type": "rectangle" + }, + "config": {}, + "data": {}, + "schema": {}, + "description": "", + "model": { + "mesh": "qiagen_qiacube_connect", + "path": "https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/devices/qiagen_qiacube_connect/macro_device.xacro", + "type": "device" + }, + "position": { + "x": 3230, + "y": -1450, + "z": 0 + } + }, + { + "uuid": "90887021-ad3a-45e8-9729-6ae98f859800", + "parent_uuid": "", + "id": "hettich_rotanta_460_robotic", + "name": "hettich_rotanta_460_robotic", + "type": "device", + "class": "hettich_rotanta_460_robotic", + "parent": "", + "pose": { + "layout": "x-y", + "position": { + "x": -350, + "y": -1550, + "z": 0 + }, + "position_3d": { + "x": 0, + "y": 0, + "z": 0 + }, + "size": { + "width": 0, + "height": 0, + "depth": 0 + }, + "scale": { + "x": 1, + "y": 1, + "z": 1 + }, + "rotation": { + "x": 0, + "y": 0, + "z": 3.1415927 + }, + "extra": null, + "cross_section_type": "rectangle" + }, + "config": {}, + "data": {}, + "schema": {}, + "description": "", + "model": { + "mesh": "hettich_rotanta_460_robotic", + "path": "https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/devices/hettich_rotanta_460_robotic/macro_device.xacro", + "type": "device" + }, + "position": { + "x": -350, + "y": -1550, + "z": 0 + } + }, + { + "uuid": "42f1b39d-a219-4898-a458-f906a6114009", + "parent_uuid": "", + "id": "applied_biosystems_seqstudio_genetic_analyzer", + "name": "applied_biosystems_seqstudio_genetic_analyzer", + "type": "device", + "class": "applied_biosystems_seqstudio_genetic_analyzer", + "parent": "", + "pose": { + "layout": "x-y", + "position": { + "x": 2600, + "y": -1450, + "z": 0 + }, + "position_3d": { + "x": 0, + "y": 0, + "z": 0 + }, + "size": { + "width": 0, + "height": 0, + "depth": 0 + }, + "scale": { + "x": 1, + "y": 1, + "z": 1 + }, + "rotation": { + "x": 0, + "y": 0, + "z": 3.1416 + }, + "extra": null, + "cross_section_type": "rectangle" + }, + "config": {}, + "data": {}, + "schema": {}, + "description": "", + "model": { + "mesh": "applied_biosystems_seqstudio_genetic_analyzer", + "path": "https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/devices/applied_biosystems_seqstudio_genetic_analyzer/macro_device.xacro", + "type": "device" + }, + "position": { + "x": 2600, + "y": -1450, + "z": 0 + } + }, + { + "uuid": "a613ef85-24a0-441f-ba3e-8b250d11f5f9", + "parent_uuid": "", + "id": "vantage_backend", + "name": "vantage_backend", + "type": "device", + "class": "vantage_backend", + "parent": "", + "pose": { + "layout": "x-y", + "position": { + "x": 130.29459, + "y": -200, + "z": 0 + }, + "position_3d": { + "x": 0, + "y": 0, + "z": 0 + }, + "size": { + "width": 0, + "height": 0, + "depth": 0 + }, + "scale": { + "x": 1, + "y": 1, + "z": 1 + }, + "rotation": { + "x": 0, + "y": 0, + "z": 0 + }, + "extra": null, + "cross_section_type": "rectangle" + }, + "config": {}, + "data": { + "num_channels": 8, + "module_id_length": 4 + }, + "schema": {}, + "description": "", + "model": { + "mesh": "hamilton_vantage", + "path": "https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/devices/hamilton_vantage/macro_device.xacro", + "type": "device" + }, + "position": { + "x": 130.29459, + "y": -200, + "z": 0 + } + }, + { + "uuid": "7f00d52a-637d-4184-953b-2824072ac764", + "parent_uuid": "", + "id": "agilent_biotek_406_fx", + "name": "agilent_biotek_406_fx", + "type": "device", + "class": "agilent_biotek_406_fx", + "parent": "", + "pose": { + "layout": "x-y", + "position": { + "x": 1830, + "y": -150, + "z": 0 + }, + "position_3d": { + "x": 0, + "y": 0, + "z": 0 + }, + "size": { + "width": 0, + "height": 0, + "depth": 0 + }, + "scale": { + "x": 1, + "y": 1, + "z": 1 + }, + "rotation": { + "x": 0, + "y": 0, + "z": 4.7124 + }, + "extra": null, + "cross_section_type": "rectangle" + }, + "config": {}, + "data": {}, + "schema": {}, + "description": "", + "model": { + "mesh": "agilent_biotek_406_fx", + "path": "https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/devices/agilent_biotek_406_fx/macro_device.xacro", + "type": "device" + }, + "position": { + "x": 1830, + "y": -150, + "z": 0 + } + }, + { + "uuid": "6ff9327b-8440-47d8-b638-3cc21691ac9c", + "parent_uuid": "", + "id": "bd_facsmelody", + "name": "bd_facsmelody", + "type": "device", + "class": "bd_facsmelody", + "parent": "", + "pose": { + "layout": "x-y", + "position": { + "x": 3900, + "y": -1450, + "z": 0 + }, + "position_3d": { + "x": 0, + "y": 0, + "z": 0 + }, + "size": { + "width": 0, + "height": 0, + "depth": 0 + }, + "scale": { + "x": 1, + "y": 1, + "z": 1 + }, + "rotation": { + "x": 0, + "y": 0, + "z": 3.1415927 + }, + "extra": null, + "cross_section_type": "rectangle" + }, + "config": {}, + "data": {}, + "schema": {}, + "description": "", + "model": { + "mesh": "bd_facsmelody", + "path": "https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/devices/bd_facsmelody/macro_device.xacro", + "type": "device" + }, + "position": { + "x": 3900, + "y": -1450, + "z": 0 + } + } + ], + "edges": [] + } \ No newline at end of file