mirror of
https://github.com/dptech-corp/Uni-Lab-OS.git
synced 2026-02-15 12:15:11 +00:00
bump version to 0.10.3
update registry do not modify axis globally Prcix9320 (#207) * 0.10.7 Update (#101) * Cleanup registry to be easy-understanding (#76) * delete deprecated mock devices * rename categories * combine chromatographic devices * rename rviz simulation nodes * organic virtual devices * parse vessel_id * run registry completion before merge --------- Co-authored-by: Xuwznln <18435084+Xuwznln@users.noreply.github.com> * fix: workstation handlers and vessel_id parsing * fix: working dir error when input config path feat: report publish topic when error * modify default discovery_interval to 15s * feat: add trace log level * feat: 添加ChinWe设备控制类,支持串口通信和电机控制功能 (#79) * fix: drop_tips not using auto resource select * fix: discard_tips error * fix: discard_tips * fix: prcxi_res * add: prcxi res fix: startup slow * feat: workstation example * fix pumps and liquid_handler handle * feat: 优化protocol node节点运行日志 * fix all protocol_compilers and remove deprecated devices * feat: 新增use_remote_resource参数 * fix and remove redundant info * bugfixes on organic protocols * fix filter protocol * fix protocol node * 临时兼容错误的driver写法 * fix: prcxi import error * use call_async in all service to avoid deadlock * fix: figure_resource * Update recipe.yaml * add workstation template and battery example * feat: add sk & ak * update workstation base * Create workstation_architecture.md * refactor: workstation_base 重构为仅含业务逻辑,通信和子设备管理交给 ProtocolNode * refactor: ProtocolNode→WorkstationNode * Add:msgs.action (#83) * update: Workstation dev 将版本号从 0.10.3 更新为 0.10.4 (#84) * Add:msgs.action * update: 将版本号从 0.10.3 更新为 0.10.4 * simplify resource system * uncompleted refactor * example for use WorkstationBase * feat: websocket * feat: websocket test * feat: workstation example * feat: action status * fix: station自己的方法注册错误 * fix: 还原protocol node处理方法 * fix: build * fix: missing job_id key * ws test version 1 * ws test version 2 * ws protocol * 增加物料关系上传日志 * 增加物料关系上传日志 * 修正物料关系上传 * 修复工站的tracker实例追踪失效问题 * 增加handle检测,增加material edge关系上传 * 修复event loop错误 * 修复edge上报错误 * 修复async错误 * 更新schema的title字段 * 主机节点信息等支持自动刷新 * 注册表编辑器 * 修复status密集发送时,消息出错 * 增加addr参数 * fix: addr param * fix: addr param * 取消labid 和 强制config输入 * Add action definitions for LiquidHandlerSetGroup and LiquidHandlerTransferGroup - Created LiquidHandlerSetGroup.action with fields for group name, wells, and volumes. - Created LiquidHandlerTransferGroup.action with fields for source and target group names and unit volume. - Both actions include response fields for return information and success status. * Add LiquidHandlerSetGroup and LiquidHandlerTransferGroup actions to CMakeLists * Add set_group and transfer_group methods to PRCXI9300Handler and update liquid_handler.yaml * result_info改为字典类型 * 新增uat的地址替换 * runze multiple pump support (cherry picked from commit49354fcf39) * remove runze multiple software obtainer (cherry picked from commit8bcc92a394) * support multiple backbone (cherry picked from commit4771ff2347) * Update runze pump format * Correct runze multiple backbone * Update runze_multiple_backbone * Correct runze pump multiple receive method. * Correct runze pump multiple receive method. * 对于PRCXI9320的transfer_group,一对多和多对多 * 移除MQTT,更新launch文档,提供注册表示例文件,更新到0.10.5 * fix import error * fix dupe upload registry * refactor ws client * add server timeout * Fix: run-column with correct vessel id (#86) * fix run_column * Update run_column_protocol.py (cherry picked from commite5aa4d940a) * resource_update use resource_add * 新增版位推荐功能 * 重新规定了版位推荐的入参 * update registry with nested obj * fix protocol node log_message, added create_resource return value * fix protocol node log_message, added create_resource return value * try fix add protocol * fix resource_add * 修复移液站错误的aspirate注册表 * Feature/xprbalance-zhida (#80) * feat(devices): add Zhida GC/MS pretreatment automation workstation * feat(devices): add mettler_toledo xpr balance * balance * 重新补全zhida注册表 * PRCXI9320 json * PRCXI9320 json * PRCXI9320 json * fix resource download * remove class for resource * bump version to 0.10.6 * 更新所有注册表 * 修复protocolnode的兼容性 * 修复protocolnode的兼容性 * Update install md * Add Defaultlayout * 更新物料接口 * fix dict to tree/nested-dict converter * coin_cell_station draft * refactor: rename "station_resource" to "deck" * add standardized BIOYOND resources: bottle_carrier, bottle * refactor and add BIOYOND resources tests * add BIOYOND deck assignment and pass all tests * fix: update resource with correct structure; remove deprecated liquid_handler set_group action * feat: 将新威电池测试系统驱动与配置文件并入 workstation_dev_YB2 (#92) * feat: 新威电池测试系统驱动与注册文件 * feat: bring neware driver & battery.json into workstation_dev_YB2 * add bioyond studio draft * bioyond station with communication init and resource sync * fix bioyond station and registry * fix: update resource with correct structure; remove deprecated liquid_handler set_group action * frontend_docs * create/update resources with POST/PUT for big amount/ small amount data * create/update resources with POST/PUT for big amount/ small amount data * refactor: add itemized_carrier instead of carrier consists of ResourceHolder * create warehouse by factory func * update bioyond launch json * add child_size for itemized_carrier * fix bioyond resource io * Workstation templates: Resources and its CRUD, and workstation tasks (#95) * coin_cell_station draft * refactor: rename "station_resource" to "deck" * add standardized BIOYOND resources: bottle_carrier, bottle * refactor and add BIOYOND resources tests * add BIOYOND deck assignment and pass all tests * fix: update resource with correct structure; remove deprecated liquid_handler set_group action * feat: 将新威电池测试系统驱动与配置文件并入 workstation_dev_YB2 (#92) * feat: 新威电池测试系统驱动与注册文件 * feat: bring neware driver & battery.json into workstation_dev_YB2 * add bioyond studio draft * bioyond station with communication init and resource sync * fix bioyond station and registry * create/update resources with POST/PUT for big amount/ small amount data * refactor: add itemized_carrier instead of carrier consists of ResourceHolder * create warehouse by factory func * update bioyond launch json * add child_size for itemized_carrier * fix bioyond resource io --------- Co-authored-by: h840473807 <47357934+h840473807@users.noreply.github.com> Co-authored-by: Xie Qiming <97236197+Andy6M@users.noreply.github.com> * 更新物料接口 * Workstation dev yb2 (#100) * Refactor and extend reaction station action messages * Refactor dispensing station tasks to enhance parameter clarity and add batch processing capabilities - Updated `create_90_10_vial_feeding_task` to include detailed parameters for 90%/10% vial feeding, improving clarity and usability. - Introduced `create_batch_90_10_vial_feeding_task` for batch processing of 90%/10% vial feeding tasks with JSON formatted input. - Added `create_batch_diamine_solution_task` for batch preparation of diamine solution, also utilizing JSON formatted input. - Refined `create_diamine_solution_task` to include additional parameters for better task configuration. - Enhanced schema descriptions and default values for improved user guidance. * 修复to_plr_resources * add update remove * 支持选择器注册表自动生成 支持转运物料 * 修复资源添加 * 修复transfer_resource_to_another生成 * 更新transfer_resource_to_another参数,支持spot入参 * 新增test_resource动作 * fix host_node error * fix host_node test_resource error * fix host_node test_resource error * 过滤本地动作 * 移动内部action以兼容host node * 修复同步任务报错不显示的bug * feat: 允许返回非本节点物料,后面可以通过decoration进行区分,就不进行warning了 * update todo * modify bioyond/plr converter, bioyond resource registry, and tests * pass the tests * update todo * add conda-pack-build.yml * add auto install script for conda-pack-build.yml (cherry picked from commit172599adcf) * update conda-pack-build.yml * update conda-pack-build.yml * update conda-pack-build.yml * update conda-pack-build.yml * update conda-pack-build.yml * Add version in __init__.py Update conda-pack-build.yml Add create_zip_archive.py * Update conda-pack-build.yml * Update conda-pack-build.yml (with mamba) * Update conda-pack-build.yml * Fix FileNotFoundError * Try fix 'charmap' codec can't encode characters in position 16-23: character maps to <undefined> * Fix unilabos msgs search error * Fix environment_check.py * Update recipe.yaml * Update registry. Update uuid loop figure method. Update install docs. * Fix nested conda pack * Fix one-key installation path error * Bump version to 0.10.7 * Workshop bj (#99) * Add LaiYu Liquid device integration and tests Introduce LaiYu Liquid device implementation, including backend, controllers, drivers, configuration, and resource files. Add hardware connection, tip pickup, and simplified test scripts, as well as experiment and registry configuration for LaiYu Liquid. Documentation and .gitignore for the device are also included. * feat(LaiYu_Liquid): 重构设备模块结构并添加硬件文档 refactor: 重新组织LaiYu_Liquid模块目录结构 docs: 添加SOPA移液器和步进电机控制指令文档 fix: 修正设备配置中的最大体积默认值 test: 新增工作台配置测试用例 chore: 删除过时的测试脚本和配置文件 * add * 重构: 将 LaiYu_Liquid.py 重命名为 laiyu_liquid_main.py 并更新所有导入引用 - 使用 git mv 将 LaiYu_Liquid.py 重命名为 laiyu_liquid_main.py - 更新所有相关文件中的导入引用 - 保持代码功能不变,仅改善命名一致性 - 测试确认所有导入正常工作 * 修复: 在 core/__init__.py 中添加 LaiYuLiquidBackend 导出 - 添加 LaiYuLiquidBackend 到导入列表 - 添加 LaiYuLiquidBackend 到 __all__ 导出列表 - 确保所有主要类都可以正确导入 * 修复大小写文件夹名字 * 电池装配工站二次开发教程(带目录)上传至dev (#94) * 电池装配工站二次开发教程 * Update intro.md * 物料教程 * 更新物料教程,json格式注释 * Update prcxi driver & fix transfer_liquid mix_times (#90) * Update prcxi driver & fix transfer_liquid mix_times * fix: correct mix_times type * Update liquid_handler registry * test: prcxi.py * Update registry from pr * fix ony-key script not exist * clean files --------- Co-authored-by: Junhan Chang <changjh@dp.tech> Co-authored-by: ZiWei <131428629+ZiWei09@users.noreply.github.com> Co-authored-by: Guangxin Zhang <guangxin.zhang.bio@gmail.com> Co-authored-by: Xie Qiming <97236197+Andy6M@users.noreply.github.com> Co-authored-by: h840473807 <47357934+h840473807@users.noreply.github.com> Co-authored-by: LccLink <1951855008@qq.com> Co-authored-by: lixinyu1011 <61094742+lixinyu1011@users.noreply.github.com> Co-authored-by: shiyubo0410 <shiyubo@dp.tech> * fix startup env check. add auto install during one-key installation * Try fix one-key build on linux * Complete all one key installation * fix: rename schema field to resource_schema with serialization and validation aliases (#104) Co-authored-by: ZiWei <131428629+ZiWei09@users.noreply.github.com> * Fix one-key installation build Install conda-pack before pack command Add conda-pack to base when building one-key installer Fix param error when using mamba run Try fix one-key build on linux * Fix conda pack on windows * add plr_to_bioyond, and refactor bioyond stations * modify default config * Fix one-key installation build for windows * Fix workstation startup Update registry * Fix/resource UUID and doc fix (#109) * Fix ResourceTreeSet load error * Raise error when using unsupported type to create ResourceTreeSet * Fix children key error * Fix children key error * Fix workstation resource not tracking * Fix workstation deck & children resource dupe * Fix workstation deck & children resource dupe * Fix multiple resource error * Fix resource tree update * Fix resource tree update * Force confirm uuid * Tip more error log * Refactor Bioyond workstation and experiment workflow (#105) Refactored the Bioyond workstation classes to improve parameter handling and workflow management. Updated experiment.py to use BioyondReactionStation with deck and material mappings, and enhanced workflow step parameter mapping and execution logic. Adjusted JSON experiment configs, improved workflow sequence handling, and added UUID assignment to PLR materials. Removed unused station_config and material cache logic, and added detailed docstrings and debug output for workflow methods. * Fix resource get. Fix resource parent not found. Mapping uuid for all resources. * mount parent uuid * Add logging configuration based on BasicConfig in main function * fix workstation node error * fix workstation node error * Update boot example * temp fix for resource get * temp fix for resource get * provide error info when cant find plr type * pack repo info * fix to plr type error * fix to plr type error * Update regular container method * support no size init * fix comprehensive_station.json * fix comprehensive_station.json * fix type conversion * fix state loading for regular container * Update deploy-docs.yml * Update deploy-docs.yml --------- Co-authored-by: ZiWei <131428629+ZiWei09@users.noreply.github.com> * Close #107 Update doc url. * Fix/update resource (#112) * cancel upload_registry * Refactor Bioyond workstation and experiment workflow -fix (#111) * refactor(bioyond_studio): 优化材料缓存加载和参数验证逻辑 改进材料缓存加载逻辑以支持多种材料类型和详细材料处理 更新工作流参数验证中的字段名从key/value改为Key/DisplayValue 移除未使用的merge_workflow_with_parameters方法 添加get_station_info方法获取工作站基础信息 清理实验文件中的注释代码和更新导入路径 * fix: 修复资源移除时的父资源检查问题 在BaseROS2DeviceNode中,移除资源前添加对父资源是否为None的检查,避免空指针异常 同时更新Bottle和BottleCarrier类以支持**kwargs参数 修正测试文件中Liquid_feeding_beaker的大小写拼写错误 * correct return message --------- Co-authored-by: ZiWei <131428629+ZiWei09@users.noreply.github.com> * fix resource_get in action * fix(reaction_station): 清空工作流序列和参数避免重复执行 (#113) 在创建任务后清空工作流序列和参数,防止下次执行时累积重复 * Update create_resource device_id * Update ResourceTracker add more enumeration in POSE fix converter in resource_tracker * Update graphio together with workstation design. fix(reaction_station): 为步骤参数添加Value字段传个BY后端 fix(bioyond/warehouses): 修正仓库尺寸和物品排列参数 调整仓库的x轴和z轴物品数量以及物品尺寸参数,使其符合4x1x4的规格要求 fix warehouse serialize/deserialize fix bioyond converter fix itemized_carrier.unassign_child_resource allow not-loaded MSG in registry add layout serializer & converter warehouseuse A1-D4; add warehouse layout fix(graphio): 修正bioyond到plr资源转换中的坐标计算错误 Fix resource assignment and type mapping issues Corrects resource assignment in ItemizedCarrier by using the correct spot key from _ordering. Updates graphio to use 'typeName' instead of 'name' for type mapping in resource_bioyond_to_plr. Renames DummyWorkstation to BioyondWorkstation in workstation_http_service for clarity. * Update workstation & bioyond example Refine descriptions in Bioyond reaction station YAML Updated and clarified field and operation descriptions in the reaction_station_bioyond.yaml file for improved accuracy and consistency. Changes include more precise terminology, clearer parameter explanations, and standardized formatting for operation schemas. refactor(workstation): 更新反应站参数描述并添加分液站配置文件 修正反应站方法参数描述,使其更准确清晰 添加bioyond_dispensing_station.yaml配置文件 add create_workflow script and test add invisible_slots to carriers fix(warehouses): 修正bioyond_warehouse_1x4x4仓库的尺寸参数 调整仓库的num_items_x和num_items_z值以匹配实际布局,并更新物品尺寸参数 save resource get data. allow empty value for layout and cross_section_type More decks&plates support for bioyond (#115) refactor(registry): 重构反应站设备配置,简化并更新操作命令 移除旧的自动操作命令,新增针对具体化学操作的命令配置 更新模块路径和配置结构,优化参数定义和描述 fix(dispensing_station): 修正物料信息查询方法调用 将直接调用material_id_query改为通过hardware_interface调用,以符合接口设计规范 * PRCXI Update 修改prcxi连线 prcxi样例图 Create example_prcxi.json * Update resource extra & uuid. use ordering to convert identifier to idx convert identifier to site idx correct extra key update extra before transfer fix multiple instance error add resource_tree_transfer func fox itemrized carrier assign child resource support internal device material transfer remove extra key use same callback group support material extra support material extra support update_resource_site in extra * Update workstation. modify workstation_architecture docs bioyond_HR (#133) * feat: Enhance Bioyond synchronization and resource management - Implemented synchronization for all material types (consumables, samples, reagents) from Bioyond, logging detailed information for each type. - Improved error handling and logging during synchronization processes. - Added functionality to save Bioyond material IDs in UniLab resources for future updates. - Enhanced the `sync_to_external` method to handle material movements correctly, including querying and creating materials in Bioyond. - Updated warehouse configurations to support new storage types and improved layout for better resource management. - Introduced new resource types such as reactors and tip boxes, with detailed specifications. - Modified warehouse factory to support column offsets for naming conventions (e.g., A05-D08). - Improved resource tracking by merging extra attributes instead of overwriting them. - Added a new method for updating resources in Bioyond, ensuring better synchronization of resource changes. * feat: 添加TipBox和Reactor的配置到bottles.yaml * fix: 修复液体投料方法中的volume参数处理逻辑 修复solid_feeding_vials方法中的volume参数处理逻辑,优化solvents参数的使用条件 更新液体投料方法,支持通过溶剂信息自动计算体积,添加solvents参数并更新文档描述 Add batch creation methods for vial and solution tasks 添加批量创建90%10%小瓶投料任务和二胺溶液配置任务的功能,更新相关参数和默认值 * 封膜仪、撕膜仪、耗材站接口 * 添加Raman和xrd相关代码 * Resource update & asyncio fix correct bioyond config prcxi example fix append_resource fix regularcontainer fix cancel error fix resource_get param fix json dumps support name change during materials change enable slave mode change uuid logger to trace level correct remove_resource stats disable slave connect websocket adjust with_children param modify devices to use correct executor (sleep, create_task) support sleep and create_task in node fix run async execution error * bump version to 0.10.9 update registry * PRCXI Reset Error Correction (#166) * change 9320 desk row number to 4 * Updated 9320 host address * Updated 9320 host address * Add **kwargs in classes: PRCXI9300Deck and PRCXI9300Container * Removed all sample_id in prcxi_9320.json to avoid KeyError * 9320 machine testing settings * Typo * Rewrite setup logic to clear error code * 初始化 step_mode 属性 * 1114物料手册定义教程byxinyu (#165) * 宜宾奔耀工站deck前端by_Xinyu * 构建物料教程byxinyu * 1114物料手册定义教程 * 3d sim (#97) * 修改lh的json启动 * 修改lh的json启动 * 修改backend,做成sim的通用backend * 修改yaml的地址,3D模型适配网页生产环境 * 添加laiyu硬件连接 * 修改移液枪的状态判断方法, 修改移液枪的状态判断方法, 添加三轴的表定点与零点之间的转换 添加三轴真实移动的backend * 修改laiyu移液站 简化移动方法, 取消软件限制位置, 修改当值使用Z轴时也需要重新复位Z轴的问题 * 更新lh以及laiyu workshop 1,现在可以直接通过修改backend,适配其他的移液站,主类依旧使用LiquidHandler,不用重新编写 2,修改枪头判断标准,使用枪头自身判断而不是类的判断, 3,将归零参数用毫米计算,方便手动调整, 4,修改归零方式,上电使用机械归零,确定机械零点,手动归零设置工作区域零点方便计算,二者互不干涉 * 修改枪头动作 * 修改虚拟仿真方法 --------- Co-authored-by: zhangshixiang <@zhangshixiang> Co-authored-by: Junhan Chang <changjh@dp.tech> * 标准化opcua设备接入unilab (#78) * 初始提交,只保留工作区当前状态 * remove redundant arm_slider meshes --------- Co-authored-by: Junhan Chang <changjh@dp.tech> * add new laiyu liquid driver, yaml and json files (#164) * HR物料同步,前端展示位置修复 (#135) * 更新Bioyond工作站配置,添加新的物料类型映射和载架定义,优化物料查询逻辑 * 添加Bioyond实验配置文件,定义物料类型映射和设备配置 * 更新bioyond_warehouse_reagent_stack方法,修正试剂堆栈尺寸和布局描述 * 更新Bioyond实验配置,修正物料类型映射,优化设备配置 * 更新Bioyond资源同步逻辑,优化物料入库流程,增强错误处理和日志记录 * 更新Bioyond资源,添加配液站和反应站专用载架,优化仓库工厂函数的排序方式 * 更新Bioyond资源,添加配液站和反应站相关载架,优化试剂瓶和样品瓶配置 * 更新Bioyond实验配置,修正试剂瓶载架ID,确保与设备匹配 * 更新Bioyond资源,移除反应站单烧杯载架,添加反应站单烧瓶载架分类 * Refactor Bioyond resource synchronization and update bottle carrier definitions - Removed traceback printing in error handling for Bioyond synchronization. - Enhanced logging for existing Bioyond material ID usage during synchronization. - Added new bottle carrier definitions for single flask and updated existing ones. - Refactored dispensing station and reaction station bottle definitions for clarity and consistency. - Improved resource mapping and error handling in graphio for Bioyond resource conversion. - Introduced layout parameter in warehouse factory for better warehouse configuration. * 更新Bioyond仓库工厂,添加排序方式支持,优化坐标计算逻辑 * 更新Bioyond载架和甲板配置,调整样品板尺寸和仓库坐标 * 更新Bioyond资源同步,增强占用位置日志信息,修正坐标转换逻辑 * 更新Bioyond反应站和分配站配置,调整材料类型映射和ID,移除不必要的项 * support name change during materials change * fix json dumps * correct tip * 优化调度器API路径,更新相关方法描述 * 更新 BIOYOND 载架相关文档,调整 API 以支持自带试剂瓶的载架类型,修复资源获取时的子物料处理逻辑 * 实现资源删除时的同步处理,优化出库操作逻辑 * 修复 ItemizedCarrier 中的可见性逻辑 * 保存 Bioyond 原始信息到 unilabos_extra,以便出库时查询 * 根据 resource.capacity 判断是试剂瓶(载架)还是多瓶载架,走不同的奔曜转换 * Fix bioyond bottle_carriers ordering * 优化 Bioyond 物料同步逻辑,增强坐标解析和位置更新处理 * disable slave connect websocket * correct remove_resource stats * change uuid logger to trace level * enable slave mode * refactor(bioyond): 统一资源命名并优化物料同步逻辑 - 将DispensingStation和ReactionStation资源统一为PolymerStation命名 - 优化物料同步逻辑,支持耗材类型(typeMode=0)的查询 - 添加物料默认参数配置功能 - 调整仓库坐标布局 - 清理废弃资源定义 * feat(warehouses): 为仓库函数添加col_offset和layout参数 * refactor: 更新实验配置中的物料类型映射命名 将DispensingStation和ReactionStation的物料类型映射统一更名为PolymerStation,保持命名一致性 * fix: 更新实验配置中的载体名称从6VialCarrier到6StockCarrier * feat(bioyond): 实现物料创建与入库分离逻辑 将物料同步流程拆分为两个独立阶段:transfer阶段只创建物料,add阶段执行入库 简化状态检查接口,仅返回连接状态 * fix(reaction_station): 修正液体进料烧杯体积单位并增强返回结果 将液体进料烧杯的体积单位从μL改为g以匹配实际使用场景 在返回结果中添加merged_workflow和order_params字段,提供更完整的工作流信息 * feat(dispensing_station): 在任务创建返回结果中添加order_params信息 在create_order方法返回结果中增加order_params字段,以便调用方获取完整的任务参数 * fix(dispensing_station): 修改90%物料分配逻辑从分成3份改为直接使用 原逻辑将主称固体平均分成3份作为90%物料,现改为直接使用main_portion * feat(bioyond): 添加任务编码和任务ID的输出,支持批量任务创建后的状态监控 * refactor(registry): 简化设备配置中的任务结果处理逻辑 将多个单独的任务编码和ID字段合并为统一的return_info字段 更新相关描述以反映新的数据结构 * feat(工作站): 添加HTTP报送服务和任务完成状态跟踪 - 在graphio.py中添加API必需字段 - 实现工作站HTTP服务启动和停止逻辑 - 添加任务完成状态跟踪字典和等待方法 - 重写任务完成报送处理方法记录状态 - 支持批量任务完成等待和报告获取 * refactor(dispensing_station): 移除wait_for_order_completion_and_get_report功能 该功能已被wait_for_multiple_orders_and_get_reports替代,简化代码结构 * fix: 更新任务报告API错误 * fix(workstation_http_service): 修复状态查询中device_id获取逻辑 处理状态查询时安全获取device_id,避免因属性不存在导致的异常 * fix(bioyond_studio): 改进物料入库失败时的错误处理和日志记录 在物料入库API调用失败时,添加更详细的错误信息打印 同时修正station.py中对空响应和失败情况的判断逻辑 * refactor(bioyond): 优化瓶架载体的分配逻辑和注释说明 重构瓶架载体的分配逻辑,使用嵌套循环替代硬编码索引分配 添加更详细的坐标映射说明,明确PLR与Bioyond坐标的对应关系 * fix(bioyond_rpc): 修复物料入库成功时无data字段返回空的问题 当API返回成功但无data字段时,返回包含success标识的字典而非空字典 --------- Co-authored-by: Xuwznln <18435084+Xuwznln@users.noreply.github.com> Co-authored-by: Junhan Chang <changjh@dp.tech> * nmr * Update devices * bump version to 0.10.10 * Update repo files. * Add get_resource_with_dir & get_resource method * fix camera & workstation & warehouse & reaction station driver * update docs, test examples fix liquid_handler init bug * bump version to 0.10.11 * Add startup_json_path, disable_browser, port config * Update oss config * feat(bioyond_studio): 添加项目API接口支持及优化物料管理功能 添加通用项目API接口方法(_post_project_api, _delete_project_api)用于与LIMS系统交互 实现compute_experiment_design方法用于实验设计计算 新增brief_step_parameters等订单相关接口方法 优化物料转移逻辑,增加异步任务处理 扩展BioyondV1RPC类,添加批量物料操作、订单状态管理等功能 * feat(bioyond): 添加测量小瓶仓库和更新仓库工厂函数参数 * Support unilabos_samples key * add session_id and normal_exit * Add result schema and add TypedDict conversion. * Fix port error * Add backend api and update doc * Add get_regular_container func * Add get_regular_container func * Transfer_liquid (#176) * change 9320 desk row number to 4 * Updated 9320 host address * Updated 9320 host address * Add **kwargs in classes: PRCXI9300Deck and PRCXI9300Container * Removed all sample_id in prcxi_9320.json to avoid KeyError * 9320 machine testing settings * Typo * Typo in base_device_node.py * Enhance liquid handling functionality by adding support for multiple transfer modes (one-to-many, one-to-one, many-to-one) and improving parameter validation. Default channel usage is set when not specified. Adjusted mixing logic to ensure it only occurs when valid conditions are met. Updated documentation for clarity. * Auto dump logs, fix workstation input schema * Fix startup with remote resource error Resource dict fully change to "pose" key Update oss link Reduce pylabrobot conversion warning & force enable log dump. 更新 logo 图片 * signal when host node is ready * fix ros2 future print all logs to file fix resource dict dump error * update version to 0.10.12 * 修改sample_uuid的返回值 * 修改pose标签设定机制 * 添加 aspiate函数返回值 * 返回dispense后的sample_uuid * 添加self.pending_liquids_dict的重置方法 * 修改prcxi的json文件,解决trach错误问题 * 修改prcxijson,防止PlateT4的硬件错误 * 对laiyu移液站进行部分修改,取消多次初始化的问题 * 修改根据新的物料格式,修改可视化 * 添加切换枪头方法,添加mock振荡与加热方法 * 夹爪添加 * 删除多余的laiyu部分 * 云端可启动夹爪 * Delete __init__.py * Enhance PRCXI9300 classes with new Container and TipRack implementations, improving state management and initialization logic. Update JSON configuration to reflect type changes for containers and plates. * 修改上传数据 --------- Co-authored-by: Junhan Chang <changjh@dp.tech> Co-authored-by: ZiWei <131428629+ZiWei09@users.noreply.github.com> Co-authored-by: Guangxin Zhang <guangxin.zhang.bio@gmail.com> Co-authored-by: Xie Qiming <97236197+Andy6M@users.noreply.github.com> Co-authored-by: h840473807 <47357934+h840473807@users.noreply.github.com> Co-authored-by: LccLink <1951855008@qq.com> Co-authored-by: lixinyu1011 <61094742+lixinyu1011@users.noreply.github.com> Co-authored-by: shiyubo0410 <shiyubo@dp.tech> Co-authored-by: hh.(SII) <103566763+Mile-Away@users.noreply.github.com> Co-authored-by: Xianwei Qi <qxw@stu.pku.edu.cn> Co-authored-by: WenzheG <wenzheguo32@gmail.com> Co-authored-by: Harry Liu <113173203+ALITTLELZ@users.noreply.github.com> Co-authored-by: q434343 <73513873+q434343@users.noreply.github.com> Co-authored-by: tt <166512503+tt11142023@users.noreply.github.com> Co-authored-by: xyc <49015816+xiaoyu10031@users.noreply.github.com> Co-authored-by: zhangshixiang <@zhangshixiang> Co-authored-by: zhangshixiang <554662886@qq.com> Co-authored-by: ALITTLELZ <l_LZlz@163.com> Add topic config add camera driver (#191) * add camera driver * add init.py file to cameraSII driver 增强新威电池测试系统 OSS 上传功能 / Enhanced Neware Battery Test System OSS Upload (#196) * feat: neware-oss-upload-enhancement * feat(neware): enhance OSS upload with metadata and workflow handles Add post process station and related resources (#195) * Add post process station and related resources - Created JSON configuration for post_process_station and its child post_process_deck. - Added YAML definitions for post_process_station, bottle carriers, bottles, and deck resources. - Implemented Python classes for bottle carriers, bottles, decks, and warehouses to manage resources in the post process. - Established a factory method for creating warehouses with customizable dimensions and layouts. - Defined the structure and behavior of the post_process_deck and its associated warehouses. * feat(post_process): add post_process_station and related warehouse functionality - Introduced post_process_station.json to define the post-processing station structure. - Implemented post_process_warehouse.py to create warehouse configurations with customizable layouts. - Added warehouses.py for specific warehouse configurations (4x3x1). - Updated post_process_station.yaml to reflect new module paths for OpcUaClient. - Refactored bottle carriers and bottles YAML files to point to the new module paths. - Adjusted deck.yaml to align with the new organizational structure for post_process_deck. prcxi resource (#202) * prcxi resource * prcxi_resource * Fix upload error not showing. Support str type category. --------- Co-authored-by: Xuwznln <18435084+Xuwznln@users.noreply.github.com> Fix upload error not showing. Support str type category. feat: introduce `wait_time` command and configurable device communication timeout. feat: Add `SyringePump` (SY-03B) driver with unified serial/TCP transport for `chinwe` device, including registry and test configurations.
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
package:
|
||||
name: unilabos
|
||||
version: 0.10.12
|
||||
version: 0.10.13
|
||||
|
||||
source:
|
||||
path: ../unilabos
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
package:
|
||||
name: ros-humble-unilabos-msgs
|
||||
version: 0.10.12
|
||||
version: 0.10.13
|
||||
source:
|
||||
path: ../../unilabos_msgs
|
||||
target_directory: src
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
package:
|
||||
name: unilabos
|
||||
version: "0.10.12"
|
||||
version: "0.10.13"
|
||||
|
||||
source:
|
||||
path: ../..
|
||||
|
||||
2
setup.py
2
setup.py
@@ -4,7 +4,7 @@ package_name = 'unilabos'
|
||||
|
||||
setup(
|
||||
name=package_name,
|
||||
version='0.10.12',
|
||||
version='0.10.13',
|
||||
packages=find_packages(),
|
||||
include_package_data=True,
|
||||
install_requires=['setuptools'],
|
||||
|
||||
0
tests/ros/__init__.py
Normal file
0
tests/ros/__init__.py
Normal file
0
tests/workflow/__init__.py
Normal file
0
tests/workflow/__init__.py
Normal file
|
Before Width: | Height: | Size: 148 KiB After Width: | Height: | Size: 148 KiB |
|
Before Width: | Height: | Size: 140 KiB After Width: | Height: | Size: 140 KiB |
|
Before Width: | Height: | Size: 117 KiB After Width: | Height: | Size: 117 KiB |
35
tests/workflow/merge_workflow.py
Normal file
35
tests/workflow/merge_workflow.py
Normal file
@@ -0,0 +1,35 @@
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
ROOT_DIR = Path(__file__).resolve().parents[2]
|
||||
if str(ROOT_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT_DIR))
|
||||
|
||||
import pytest
|
||||
|
||||
from unilabos.workflow.convert_from_json import (
|
||||
convert_from_json,
|
||||
normalize_steps as _normalize_steps,
|
||||
normalize_labware as _normalize_labware,
|
||||
)
|
||||
from unilabos.workflow.common import draw_protocol_graph_with_ports
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"protocol_name",
|
||||
[
|
||||
"example_bio",
|
||||
# "bioyond_materials_liquidhandling_1",
|
||||
"example_prcxi",
|
||||
],
|
||||
)
|
||||
def test_build_protocol_graph(protocol_name):
|
||||
data_path = Path(__file__).with_name(f"{protocol_name}.json")
|
||||
|
||||
graph = convert_from_json(data_path, workstation_name="PRCXi")
|
||||
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M")
|
||||
output_path = data_path.with_name(f"{protocol_name}_graph_{timestamp}.png")
|
||||
draw_protocol_graph_with_ports(graph, str(output_path))
|
||||
print(graph)
|
||||
@@ -1 +1 @@
|
||||
__version__ = "0.10.12"
|
||||
__version__ = "0.10.13"
|
||||
|
||||
@@ -299,6 +299,10 @@ class HTTPClient:
|
||||
)
|
||||
if response.status_code not in [200, 201]:
|
||||
logger.error(f"注册资源失败: {response.status_code}, {response.text}")
|
||||
if response.status_code == 200:
|
||||
res = response.json()
|
||||
if "code" in res and res["code"] != 0:
|
||||
logger.error(f"注册资源失败: {response.text}")
|
||||
return response
|
||||
|
||||
def request_startup_json(self) -> Optional[Dict[str, Any]]:
|
||||
|
||||
@@ -5,7 +5,6 @@ from typing import Any, Union, List, Dict, Callable, Optional, Tuple
|
||||
from pydantic import BaseModel
|
||||
|
||||
from opcua import Client, ua
|
||||
from opcua.ua import NodeClass
|
||||
import pandas as pd
|
||||
import os
|
||||
|
||||
@@ -13,7 +12,7 @@ from unilabos.device_comms.opcua_client.node.uniopcua import Base as OpcUaNodeBa
|
||||
from unilabos.device_comms.opcua_client.node.uniopcua import Variable, Method, NodeType, DataType
|
||||
from unilabos.device_comms.universal_driver import UniversalDriver
|
||||
from unilabos.utils.log import logger
|
||||
|
||||
from unilabos.devices.workstation.post_process.decks import post_process_deck
|
||||
|
||||
class OpcUaNode(BaseModel):
|
||||
name: str
|
||||
@@ -117,6 +116,8 @@ class BaseClient(UniversalDriver):
|
||||
_variables_to_find: Dict[str, Dict[str, Any]] = {}
|
||||
_name_mapping: Dict[str, str] = {} # 英文名到中文名的映射
|
||||
_reverse_mapping: Dict[str, str] = {} # 中文名到英文名的映射
|
||||
# 直接缓存已找到的 ua.Node 对象,避免因字符串 NodeId 格式导致订阅失败
|
||||
_found_node_objects: Dict[str, Any] = {}
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
@@ -125,6 +126,9 @@ class BaseClient(UniversalDriver):
|
||||
# 初始化名称映射字典
|
||||
self._name_mapping = {}
|
||||
self._reverse_mapping = {}
|
||||
# 初始化线程锁(在子类中会被重新创建,这里提供默认实现)
|
||||
import threading
|
||||
self._client_lock = threading.RLock()
|
||||
|
||||
def _set_client(self, client: Optional[Client]) -> None:
|
||||
if client is None:
|
||||
@@ -152,15 +156,24 @@ class BaseClient(UniversalDriver):
|
||||
if not self.client:
|
||||
raise ValueError('client is not connected')
|
||||
|
||||
logger.info('开始查找节点...')
|
||||
logger.info(f'开始查找 {len(self._variables_to_find)} 个节点...')
|
||||
try:
|
||||
# 获取根节点
|
||||
root = self.client.get_root_node()
|
||||
objects = root.get_child(["0:Objects"])
|
||||
|
||||
# 记录查找前的状态
|
||||
before_count = len(self._node_registry)
|
||||
|
||||
# 查找节点
|
||||
self._find_nodes_recursive(objects)
|
||||
|
||||
# 记录查找后的状态
|
||||
after_count = len(self._node_registry)
|
||||
newly_found = after_count - before_count
|
||||
|
||||
logger.info(f"本次查找新增 {newly_found} 个节点,当前共 {after_count} 个")
|
||||
|
||||
# 检查是否所有节点都已找到
|
||||
not_found = []
|
||||
for var_name, var_info in self._variables_to_find.items():
|
||||
@@ -168,9 +181,13 @@ class BaseClient(UniversalDriver):
|
||||
not_found.append(var_name)
|
||||
|
||||
if not_found:
|
||||
logger.warning(f"以下节点未找到: {', '.join(not_found)}")
|
||||
logger.warning(f"⚠ 以下 {len(not_found)} 个节点未找到: {', '.join(not_found[:10])}{'...' if len(not_found) > 10 else ''}")
|
||||
logger.warning(f"提示:请检查这些节点名称是否与服务器的 BrowseName 完全匹配(包括大小写、空格等)")
|
||||
# 提供一个示例来帮助调试
|
||||
if not_found:
|
||||
logger.info(f"尝试在服务器中查找第一个未找到的节点 '{not_found[0]}' 的相似节点...")
|
||||
else:
|
||||
logger.info("所有节点均已找到")
|
||||
logger.info(f"✓ 所有 {len(self._variables_to_find)} 个节点均已找到并注册")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"查找节点失败: {e}")
|
||||
@@ -188,17 +205,20 @@ class BaseClient(UniversalDriver):
|
||||
var_info = self._variables_to_find[node_name]
|
||||
node_type = var_info.get("node_type")
|
||||
data_type = var_info.get("data_type")
|
||||
node_id_str = str(node.nodeid)
|
||||
|
||||
# 根据节点类型创建相应的对象
|
||||
if node_type == NodeType.VARIABLE:
|
||||
self._node_registry[node_name] = Variable(self.client, node_name, str(node.nodeid), data_type)
|
||||
logger.info(f"找到变量节点: {node_name}")
|
||||
self._node_registry[node_name] = Variable(self.client, node_name, node_id_str, data_type)
|
||||
logger.info(f"✓ 找到变量节点: '{node_name}', NodeId: {node_id_str}, DataType: {data_type}")
|
||||
# 缓存真实的 ua.Node 对象用于订阅
|
||||
self._found_node_objects[node_name] = node
|
||||
elif node_type == NodeType.METHOD:
|
||||
# 对于方法节点,需要获取父节点ID
|
||||
parent_node = node.get_parent()
|
||||
parent_node_id = str(parent_node.nodeid)
|
||||
self._node_registry[node_name] = Method(self.client, node_name, str(node.nodeid), parent_node_id, data_type)
|
||||
logger.info(f"找到方法节点: {node_name}")
|
||||
self._node_registry[node_name] = Method(self.client, node_name, node_id_str, parent_node_id, data_type)
|
||||
logger.info(f"✓ 找到方法节点: '{node_name}', NodeId: {node_id_str}, ParentId: {parent_node_id}")
|
||||
|
||||
# 递归处理子节点
|
||||
for child in node.get_children():
|
||||
@@ -296,13 +316,17 @@ class BaseClient(UniversalDriver):
|
||||
if name in self._name_mapping:
|
||||
chinese_name = self._name_mapping[name]
|
||||
if chinese_name in self._node_registry:
|
||||
return self._node_registry[chinese_name]
|
||||
node = self._node_registry[chinese_name]
|
||||
logger.debug(f"使用节点: '{name}' -> '{chinese_name}', NodeId: {node.node_id}")
|
||||
return node
|
||||
elif chinese_name in self._variables_to_find:
|
||||
logger.warning(f"节点 {chinese_name} (英文名: {name}) 尚未找到,尝试重新查找")
|
||||
if self.client:
|
||||
self._find_nodes()
|
||||
if chinese_name in self._node_registry:
|
||||
return self._node_registry[chinese_name]
|
||||
node = self._node_registry[chinese_name]
|
||||
logger.info(f"重新查找成功: '{chinese_name}', NodeId: {node.node_id}")
|
||||
return node
|
||||
raise ValueError(f'节点 {chinese_name} (英文名: {name}) 未注册或未找到')
|
||||
|
||||
# 直接使用原始名称查找
|
||||
@@ -312,9 +336,14 @@ class BaseClient(UniversalDriver):
|
||||
if self.client:
|
||||
self._find_nodes()
|
||||
if name in self._node_registry:
|
||||
return self._node_registry[name]
|
||||
node = self._node_registry[name]
|
||||
logger.info(f"重新查找成功: '{name}', NodeId: {node.node_id}")
|
||||
return node
|
||||
logger.error(f"❌ 节点 '{name}' 未注册或未找到。已注册节点: {list(self._node_registry.keys())[:5]}...")
|
||||
raise ValueError(f'节点 {name} 未注册或未找到')
|
||||
return self._node_registry[name]
|
||||
node = self._node_registry[name]
|
||||
logger.debug(f"使用节点: '{name}', NodeId: {node.node_id}")
|
||||
return node
|
||||
|
||||
def get_node_registry(self) -> Dict[str, OpcUaNodeBase]:
|
||||
return self._node_registry
|
||||
@@ -335,12 +364,13 @@ class BaseClient(UniversalDriver):
|
||||
return self
|
||||
|
||||
logger.info(f'开始注册 {len(node_list)} 个节点...')
|
||||
new_nodes_count = 0
|
||||
for node in node_list:
|
||||
if node is None:
|
||||
continue
|
||||
|
||||
if node.name in self._node_registry:
|
||||
logger.info(f'节点 {node.name} 已存在')
|
||||
logger.debug(f'节点 "{node.name}" 已存在于注册表')
|
||||
exist = self._node_registry[node.name]
|
||||
if exist.type != node.node_type:
|
||||
raise ValueError(f'节点 {node.name} 类型 {node.node_type} 与已存在的类型 {exist.type} 不一致')
|
||||
@@ -351,9 +381,10 @@ class BaseClient(UniversalDriver):
|
||||
"node_type": node.node_type,
|
||||
"data_type": node.data_type
|
||||
}
|
||||
logger.info(f'添加节点 {node.name} 到待查找列表')
|
||||
new_nodes_count += 1
|
||||
logger.debug(f'添加节点 "{node.name}" ({node.node_type}) 到待查找列表')
|
||||
|
||||
logger.info('节点注册完成')
|
||||
logger.info(f'节点注册完成:新增 {new_nodes_count} 个待查找节点,总计 {len(self._variables_to_find)} 个')
|
||||
|
||||
# 如果客户端已连接,立即开始查找
|
||||
if self.client:
|
||||
@@ -470,7 +501,7 @@ class BaseClient(UniversalDriver):
|
||||
val = result_dict.get("value")
|
||||
err = result_dict.get("error")
|
||||
|
||||
print(f"读取 {node_name} 返回值 = {val} (类型: {type(val).__name__}), 错误 = {err}")
|
||||
print(f"读取 {node_name} 返回值 = {val} (类型: {type(val).__name__}, 错误 = {err}")
|
||||
return val, err
|
||||
except Exception as e:
|
||||
print(f"解析读取结果失败: {e}, 原始结果: {result_str}")
|
||||
@@ -518,15 +549,25 @@ class BaseClient(UniversalDriver):
|
||||
raise ValueError("必须提供write_nodes参数")
|
||||
|
||||
def execute_init_function(use_node: Callable[[str], OpcUaNodeBase]) -> bool:
|
||||
if isinstance(write_nodes, list):
|
||||
# 处理节点列表
|
||||
for node_name in write_nodes:
|
||||
# 尝试从参数中获取同名参数的值
|
||||
current_value = True # 默认值
|
||||
if hasattr(self, '_workflow_params') and node_name in self._workflow_params:
|
||||
current_value = self._workflow_params[node_name]
|
||||
print(f"初始化函数: 从参数获取值 {node_name} = {current_value}")
|
||||
"""根据 _workflow_params 为各节点写入真实数值。
|
||||
|
||||
约定:
|
||||
- write_nodes 为 list 时: 节点名 == 参数名,从 _workflow_params[node_name] 取值;
|
||||
- write_nodes 为 dict 时:
|
||||
* value 为字符串且在 _workflow_params 中: 当作参数名去取值;
|
||||
* 否则 value 视为常量直接写入。
|
||||
"""
|
||||
|
||||
params = getattr(self, "_workflow_params", {}) or {}
|
||||
|
||||
if isinstance(write_nodes, list):
|
||||
# 节点列表形式: 节点名与参数名一致
|
||||
for node_name in write_nodes:
|
||||
if node_name not in params:
|
||||
print(f"初始化函数: 参数中未找到 {node_name}, 跳过写入")
|
||||
continue
|
||||
|
||||
current_value = params[node_name]
|
||||
print(f"初始化函数: 写入节点 {node_name} = {current_value}")
|
||||
input_json = json.dumps({"node_name": node_name, "value": current_value})
|
||||
result_str = self.write_node(input_json)
|
||||
@@ -538,13 +579,14 @@ class BaseClient(UniversalDriver):
|
||||
except Exception as e:
|
||||
print(f"初始化函数: 解析写入结果失败: {e}, 原始结果: {result_str}")
|
||||
elif isinstance(write_nodes, dict):
|
||||
# 处理节点字典,使用指定的值
|
||||
# 映射形式: 节点名 -> 参数名或常量
|
||||
for node_name, node_value in write_nodes.items():
|
||||
# 检查值是否是字符串类型的参数名
|
||||
current_value = node_value
|
||||
if isinstance(node_value, str) and hasattr(self, '_workflow_params') and node_value in self._workflow_params:
|
||||
current_value = self._workflow_params[node_value]
|
||||
if isinstance(node_value, str) and node_value in params:
|
||||
current_value = params[node_value]
|
||||
print(f"初始化函数: 从参数获取值 {node_value} = {current_value}")
|
||||
else:
|
||||
current_value = node_value
|
||||
print(f"初始化函数: 使用常量值 写入 {node_name} = {current_value}")
|
||||
|
||||
print(f"初始化函数: 写入节点 {node_name} = {current_value}")
|
||||
input_json = json.dumps({"node_name": node_name, "value": current_value})
|
||||
@@ -672,20 +714,20 @@ class BaseClient(UniversalDriver):
|
||||
condition_nodes: 条件节点列表 [节点名1, 节点名2]
|
||||
"""
|
||||
def execute_start_function(use_node: Callable[[str], OpcUaNodeBase]) -> bool:
|
||||
# 直接处理写入节点
|
||||
"""开始函数: 写入触发节点, 然后轮询条件节点直到满足停止条件。"""
|
||||
|
||||
params = getattr(self, "_workflow_params", {}) or {}
|
||||
|
||||
# 先处理写入节点(触发位等)
|
||||
if write_nodes:
|
||||
if isinstance(write_nodes, list):
|
||||
# 处理节点列表,默认值都是True
|
||||
for i, node_name in enumerate(write_nodes):
|
||||
# 尝试获取与节点对应的参数值
|
||||
param_name = f"write_{i}"
|
||||
# 列表形式: 节点名与参数名一致, 若无参数则直接写 True
|
||||
for node_name in write_nodes:
|
||||
if node_name in params:
|
||||
current_value = params[node_name]
|
||||
else:
|
||||
current_value = True
|
||||
|
||||
# 获取参数值(如果有)
|
||||
current_value = True # 默认值
|
||||
if hasattr(self, '_workflow_params') and param_name in self._workflow_params:
|
||||
current_value = self._workflow_params[param_name]
|
||||
|
||||
# 直接写入节点
|
||||
print(f"直接写入节点 {node_name} = {current_value}")
|
||||
input_json = json.dumps({"node_name": node_name, "value": current_value})
|
||||
result_str = self.write_node(input_json)
|
||||
@@ -697,14 +739,13 @@ class BaseClient(UniversalDriver):
|
||||
except Exception as e:
|
||||
print(f"解析直接写入结果失败: {e}, 原始结果: {result_str}")
|
||||
elif isinstance(write_nodes, dict):
|
||||
# 处理节点字典,值是指定的
|
||||
# 字典形式: 节点名 -> 常量值(如 True/False)
|
||||
for node_name, node_value in write_nodes.items():
|
||||
# 尝试获取参数值(如果节点名与参数名匹配)
|
||||
current_value = node_value # 使用指定的默认值
|
||||
if hasattr(self, '_workflow_params') and node_name in self._workflow_params:
|
||||
current_value = self._workflow_params[node_name]
|
||||
if node_name in params:
|
||||
current_value = params[node_name]
|
||||
else:
|
||||
current_value = node_value
|
||||
|
||||
# 直接写入节点
|
||||
print(f"直接写入节点 {node_name} = {current_value}")
|
||||
input_json = json.dumps({"node_name": node_name, "value": current_value})
|
||||
result_str = self.write_node(input_json)
|
||||
@@ -732,6 +773,7 @@ class BaseClient(UniversalDriver):
|
||||
# 直接读取节点
|
||||
result_str = self.read_node(node_name)
|
||||
try:
|
||||
time.sleep(1)
|
||||
result_str = result_str.replace("'", '"')
|
||||
result_dict = json.loads(result_str)
|
||||
read_res = result_dict.get("value")
|
||||
@@ -1035,6 +1077,8 @@ class BaseClient(UniversalDriver):
|
||||
读取节点值的便捷方法
|
||||
返回包含result字段的字典
|
||||
"""
|
||||
# 使用锁保护客户端访问
|
||||
with self._client_lock:
|
||||
try:
|
||||
node = self.use_node(node_name)
|
||||
value, error = node.read()
|
||||
@@ -1068,6 +1112,8 @@ class BaseClient(UniversalDriver):
|
||||
eg:'{\"node_name\":\"反应罐号码\",\"value\":\"2\"}'
|
||||
返回JSON格式的字符串,包含操作结果
|
||||
"""
|
||||
# 使用锁保护客户端访问
|
||||
with self._client_lock:
|
||||
try:
|
||||
# 解析JSON格式的输入
|
||||
if not isinstance(json_input, str):
|
||||
@@ -1128,13 +1174,50 @@ class BaseClient(UniversalDriver):
|
||||
|
||||
|
||||
class OpcUaClient(BaseClient):
|
||||
def __init__(self, url: str, config_path: str = None, username: str = None, password: str = None, refresh_interval: float = 1.0):
|
||||
def __init__(
|
||||
self,
|
||||
url: str,
|
||||
deck: Optional[Union[post_process_deck, Dict[str, Any]]] = None,
|
||||
config_path: str = None,
|
||||
username: str = None,
|
||||
password: str = None,
|
||||
use_subscription: bool = True,
|
||||
cache_timeout: float = 5.0,
|
||||
subscription_interval: int = 500,
|
||||
*args,
|
||||
**kwargs,
|
||||
):
|
||||
# 降低OPCUA库的日志级别
|
||||
import logging
|
||||
logging.getLogger("opcua").setLevel(logging.WARNING)
|
||||
|
||||
super().__init__()
|
||||
|
||||
# ===== 关键修改:参照 BioyondWorkstation 处理 deck =====
|
||||
|
||||
super().__init__()
|
||||
|
||||
# 处理 deck 参数
|
||||
if deck is None:
|
||||
self.deck = post_process_deck(setup=True)
|
||||
elif isinstance(deck, dict):
|
||||
self.deck = post_process_deck(setup=True)
|
||||
elif hasattr(deck, 'children'):
|
||||
self.deck = deck
|
||||
else:
|
||||
raise ValueError(f"deck 参数类型不支持: {type(deck)}")
|
||||
|
||||
if self.deck is None:
|
||||
raise ValueError("Deck 配置不能为空")
|
||||
|
||||
# 统计仓库信息
|
||||
warehouse_count = 0
|
||||
if hasattr(self.deck, 'children'):
|
||||
warehouse_count = len(self.deck.children)
|
||||
logger.info(f"Deck 初始化完成,加载 {warehouse_count} 个资源")
|
||||
|
||||
|
||||
# OPC UA 客户端初始化
|
||||
client = Client(url)
|
||||
|
||||
if username and password:
|
||||
@@ -1142,144 +1225,391 @@ class OpcUaClient(BaseClient):
|
||||
client.set_password(password)
|
||||
|
||||
self._set_client(client)
|
||||
self._connect()
|
||||
|
||||
# 节点值缓存和刷新相关属性
|
||||
self._node_values = {} # 缓存节点值
|
||||
self._refresh_interval = refresh_interval # 刷新间隔(秒)
|
||||
self._refresh_running = False
|
||||
self._refresh_thread = None
|
||||
# 订阅相关属性
|
||||
self._use_subscription = use_subscription
|
||||
self._subscription = None
|
||||
self._subscription_handles = {}
|
||||
self._subscription_interval = subscription_interval
|
||||
|
||||
# 缓存相关属性
|
||||
self._node_values = {} # 修改为支持时间戳的缓存结构
|
||||
self._cache_timeout = cache_timeout
|
||||
|
||||
# 连接状态监控
|
||||
self._connection_check_interval = 30.0 # 连接检查间隔(秒)
|
||||
self._connection_monitor_running = False
|
||||
self._connection_monitor_thread = None
|
||||
|
||||
# 添加线程锁,保护OPC UA客户端的并发访问
|
||||
import threading
|
||||
self._client_lock = threading.RLock()
|
||||
|
||||
# 连接到服务器
|
||||
self._connect()
|
||||
|
||||
# 如果提供了配置文件路径,则加载配置并注册工作流
|
||||
if config_path:
|
||||
self.load_config(config_path)
|
||||
|
||||
# 启动节点值刷新线程
|
||||
self.start_node_refresh()
|
||||
# 启动连接监控
|
||||
self._start_connection_monitor()
|
||||
|
||||
def _register_nodes_as_attributes(self):
|
||||
"""将所有节点注册为实例属性,可以通过self.node_name访问"""
|
||||
for node_name, node in self._node_registry.items():
|
||||
# 检查是否有对应的英文名称
|
||||
eng_name = self._reverse_mapping.get(node_name)
|
||||
if eng_name:
|
||||
# 如果有对应的英文名称,使用英文名称作为属性名
|
||||
attr_name = eng_name
|
||||
|
||||
def _connect(self) -> None:
|
||||
"""连接到OPC UA服务器"""
|
||||
logger.info('尝试连接到 OPC UA 服务器...')
|
||||
if self.client:
|
||||
try:
|
||||
self.client.connect()
|
||||
logger.info('✓ 客户端已连接!')
|
||||
|
||||
# 连接后开始查找节点
|
||||
if self._variables_to_find:
|
||||
self._find_nodes()
|
||||
|
||||
# 如果启用订阅模式,设置订阅
|
||||
if self._use_subscription:
|
||||
self._setup_subscriptions()
|
||||
else:
|
||||
# 如果没有对应的英文名称,使用原始名称,但替换空格和特殊字符
|
||||
attr_name = node_name.replace(' ', '_').replace('-', '_')
|
||||
logger.info("订阅模式已禁用,将使用按需读取模式")
|
||||
|
||||
# 创建获取节点值的属性方法,使用中文名称获取节点值
|
||||
def create_property_getter(node_key):
|
||||
def getter(self):
|
||||
# 优先从缓存获取值
|
||||
if node_key in self._node_values:
|
||||
return self._node_values[node_key]
|
||||
# 缓存中没有则直接读取
|
||||
value, _ = self.use_node(node_key).read()
|
||||
return value
|
||||
return getter
|
||||
|
||||
# 使用property装饰器将方法注册为类属性
|
||||
setattr(OpcUaClient, attr_name, property(create_property_getter(node_name)))
|
||||
logger.info(f"已注册节点 '{node_name}' 为属性 '{attr_name}'")
|
||||
|
||||
def refresh_node_values(self):
|
||||
"""刷新所有节点的值到缓存"""
|
||||
if not self.client:
|
||||
logger.warning("客户端未初始化,无法刷新节点值")
|
||||
return
|
||||
|
||||
try:
|
||||
# 简单检查连接状态,如果不连接会抛出异常
|
||||
self.client.get_namespace_array()
|
||||
except Exception as e:
|
||||
logger.warning(f"客户端连接异常,无法刷新节点值: {e}")
|
||||
logger.error(f'客户端连接失败: {e}')
|
||||
raise
|
||||
else:
|
||||
raise ValueError('客户端未初始化')
|
||||
|
||||
class SubscriptionHandler:
|
||||
"""freeopcua订阅处理器:必须实现 datachange_notification 方法"""
|
||||
def __init__(self, outer):
|
||||
self.outer = outer
|
||||
|
||||
def datachange_notification(self, node, val, data):
|
||||
# 委托给外层类的处理函数
|
||||
try:
|
||||
self.outer._on_subscription_datachange(node, val, data)
|
||||
except Exception as e:
|
||||
logger.error(f"订阅数据回调处理失败: {e}")
|
||||
|
||||
# 可选:事件通知占位,避免库调用时报缺失
|
||||
def event_notification(self, event):
|
||||
pass
|
||||
|
||||
def _setup_subscriptions(self):
|
||||
"""设置 OPC UA 订阅"""
|
||||
if not self.client or not self._use_subscription:
|
||||
return
|
||||
|
||||
with self._client_lock:
|
||||
try:
|
||||
logger.info(f"开始设置订阅 (发布间隔: {self._subscription_interval}ms)...")
|
||||
|
||||
# 创建订阅
|
||||
handler = OpcUaClient.SubscriptionHandler(self)
|
||||
self._subscription = self.client.create_subscription(
|
||||
self._subscription_interval,
|
||||
handler
|
||||
)
|
||||
|
||||
# 为所有变量节点创建监控项
|
||||
subscribed_count = 0
|
||||
skipped_count = 0
|
||||
|
||||
for node_name, node in self._node_registry.items():
|
||||
# 只为变量节点创建订阅
|
||||
if node.type == NodeType.VARIABLE and node.node_id:
|
||||
try:
|
||||
if hasattr(node, 'read'):
|
||||
value, error = node.read()
|
||||
if not error:
|
||||
self._node_values[node_name] = value
|
||||
#logger.debug(f"已刷新节点 '{node_name}' 的值: {value}")
|
||||
# 优先使用在查找阶段缓存的真实 ua.Node 对象
|
||||
ua_node = self._found_node_objects.get(node_name)
|
||||
if ua_node is None:
|
||||
ua_node = self.client.get_node(node.node_id)
|
||||
handle = self._subscription.subscribe_data_change(ua_node)
|
||||
self._subscription_handles[node_name] = handle
|
||||
subscribed_count += 1
|
||||
logger.debug(f"✓ 已订阅节点: {node_name}")
|
||||
except Exception as e:
|
||||
logger.error(f"刷新节点 '{node_name}' 失败: {e}")
|
||||
skipped_count += 1
|
||||
logger.warning(f"✗ 订阅节点 {node_name} 失败: {e}")
|
||||
else:
|
||||
skipped_count += 1
|
||||
|
||||
def get_node_value(self, name):
|
||||
"""获取节点值,支持中文名和英文名"""
|
||||
# 如果提供的是英文名,转换为中文名
|
||||
logger.info(f"订阅设置完成: 成功 {subscribed_count} 个, 跳过 {skipped_count} 个")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"设置订阅失败: {e}")
|
||||
traceback.print_exc()
|
||||
# 订阅失败时回退到按需读取模式
|
||||
self._use_subscription = False
|
||||
logger.warning("订阅模式设置失败,已自动切换到按需读取模式")
|
||||
|
||||
def _on_subscription_datachange(self, node, val, data):
|
||||
"""订阅数据变化处理器(供内部 SubscriptionHandler 调用)"""
|
||||
try:
|
||||
node_id = str(node.nodeid)
|
||||
current_time = time.time()
|
||||
# 查找对应的节点名称
|
||||
for node_name, node_obj in self._node_registry.items():
|
||||
if node_obj.node_id == node_id:
|
||||
self._node_values[node_name] = {
|
||||
'value': val,
|
||||
'timestamp': current_time,
|
||||
'source': 'subscription'
|
||||
}
|
||||
logger.debug(f"订阅更新: {node_name} = {val}")
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error(f"处理订阅数据失败: {e}")
|
||||
|
||||
def get_node_value(self, name, use_cache=True, force_read=False):
|
||||
"""
|
||||
获取节点值(智能缓存版本)
|
||||
|
||||
参数:
|
||||
name: 节点名称(支持中文名或英文名)
|
||||
use_cache: 是否使用缓存
|
||||
force_read: 是否强制从服务器读取(忽略缓存)
|
||||
"""
|
||||
# 处理名称映射
|
||||
if name in self._name_mapping:
|
||||
chinese_name = self._name_mapping[name]
|
||||
# 优先从缓存获取值
|
||||
if chinese_name in self._node_values:
|
||||
return self._node_values[chinese_name]
|
||||
# 缓存中没有则直接读取
|
||||
elif name in self._node_registry:
|
||||
chinese_name = name
|
||||
else:
|
||||
raise ValueError(f"未找到名称为 '{name}' 的节点")
|
||||
|
||||
# 如果强制读取,直接从服务器读取
|
||||
if force_read:
|
||||
with self._client_lock:
|
||||
value, _ = self.use_node(chinese_name).read()
|
||||
# 更新缓存
|
||||
self._node_values[chinese_name] = {
|
||||
'value': value,
|
||||
'timestamp': time.time(),
|
||||
'source': 'forced_read'
|
||||
}
|
||||
return value
|
||||
# 如果提供的是中文名,直接使用
|
||||
elif name in self._node_registry:
|
||||
# 优先从缓存获取值
|
||||
if name in self._node_values:
|
||||
return self._node_values[name]
|
||||
# 缓存中没有则直接读取
|
||||
value, _ = self.use_node(name).read()
|
||||
return value
|
||||
else:
|
||||
raise ValueError(f"未找到名称为 '{name}' 的节点")
|
||||
|
||||
def set_node_value(self, name, value):
|
||||
"""设置节点值,支持中文名和英文名"""
|
||||
# 如果提供的是英文名,转换为中文名
|
||||
if name in self._name_mapping:
|
||||
chinese_name = self._name_mapping[name]
|
||||
node = self.use_node(chinese_name)
|
||||
# 如果提供的是中文名,直接使用
|
||||
elif name in self._node_registry:
|
||||
node = self.use_node(name)
|
||||
else:
|
||||
raise ValueError(f"未找到名称为 '{name}' 的节点")
|
||||
# 检查缓存
|
||||
if use_cache and chinese_name in self._node_values:
|
||||
cache_entry = self._node_values[chinese_name]
|
||||
cache_age = time.time() - cache_entry['timestamp']
|
||||
|
||||
# 写入值
|
||||
error = node.write(value)
|
||||
# 如果是订阅模式,缓存永久有效(由订阅更新)
|
||||
# 如果是按需读取模式,检查缓存超时
|
||||
if cache_entry.get('source') == 'subscription' or cache_age < self._cache_timeout:
|
||||
logger.debug(f"从缓存读取: {chinese_name} = {cache_entry['value']} (age: {cache_age:.2f}s, source: {cache_entry.get('source', 'unknown')})")
|
||||
return cache_entry['value']
|
||||
|
||||
# 缓存过期或不存在,从服务器读取
|
||||
with self._client_lock:
|
||||
try:
|
||||
value, error = self.use_node(chinese_name).read()
|
||||
if not error:
|
||||
# 更新缓存
|
||||
if hasattr(node, 'name'):
|
||||
self._node_values[node.name] = value
|
||||
self._node_values[chinese_name] = {
|
||||
'value': value,
|
||||
'timestamp': time.time(),
|
||||
'source': 'on_demand_read'
|
||||
}
|
||||
return value
|
||||
else:
|
||||
logger.warning(f"读取节点 {chinese_name} 失败")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"读取节点 {chinese_name} 出错: {e}")
|
||||
return None
|
||||
|
||||
def set_node_value(self, name, value):
|
||||
"""
|
||||
设置节点值
|
||||
写入成功后会立即更新本地缓存
|
||||
"""
|
||||
# 处理名称映射
|
||||
if name in self._name_mapping:
|
||||
chinese_name = self._name_mapping[name]
|
||||
elif name in self._node_registry:
|
||||
chinese_name = name
|
||||
else:
|
||||
raise ValueError(f"未找到名称为 '{name}' 的节点")
|
||||
|
||||
with self._client_lock:
|
||||
try:
|
||||
node = self.use_node(chinese_name)
|
||||
error = node.write(value)
|
||||
|
||||
if not error:
|
||||
# 写入成功,立即更新缓存
|
||||
self._node_values[chinese_name] = {
|
||||
'value': value,
|
||||
'timestamp': time.time(),
|
||||
'source': 'write'
|
||||
}
|
||||
logger.debug(f"写入成功: {chinese_name} = {value}")
|
||||
return True
|
||||
else:
|
||||
logger.warning(f"写入节点 {chinese_name} 失败")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"写入节点 {chinese_name} 出错: {e}")
|
||||
return False
|
||||
|
||||
def _refresh_worker(self):
|
||||
"""节点值刷新线程的工作函数"""
|
||||
self._refresh_running = True
|
||||
logger.info(f"节点值刷新线程已启动,刷新间隔: {self._refresh_interval}秒")
|
||||
|
||||
while self._refresh_running:
|
||||
def _check_connection(self) -> bool:
|
||||
"""检查连接状态"""
|
||||
try:
|
||||
self.refresh_node_values()
|
||||
with self._client_lock:
|
||||
if self.client:
|
||||
# 尝试获取命名空间数组来验证连接
|
||||
self.client.get_namespace_array()
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"节点值刷新过程出错: {e}")
|
||||
logger.warning(f"连接检查失败: {e}")
|
||||
return False
|
||||
return False
|
||||
|
||||
# 等待下一次刷新
|
||||
time.sleep(self._refresh_interval)
|
||||
def _connection_monitor_worker(self):
|
||||
"""连接监控线程工作函数"""
|
||||
self._connection_monitor_running = True
|
||||
logger.info(f"连接监控线程已启动 (检查间隔: {self._connection_check_interval}秒)")
|
||||
|
||||
def start_node_refresh(self):
|
||||
"""启动节点值刷新线程"""
|
||||
if self._refresh_thread is not None and self._refresh_thread.is_alive():
|
||||
logger.warning("节点值刷新线程已在运行")
|
||||
reconnect_attempts = 0
|
||||
max_reconnect_attempts = 5
|
||||
|
||||
while self._connection_monitor_running:
|
||||
try:
|
||||
# 检查连接状态
|
||||
if not self._check_connection():
|
||||
logger.warning("检测到连接断开,尝试重新连接...")
|
||||
reconnect_attempts += 1
|
||||
|
||||
if reconnect_attempts <= max_reconnect_attempts:
|
||||
try:
|
||||
# 尝试重新连接
|
||||
with self._client_lock:
|
||||
if self.client:
|
||||
try:
|
||||
self.client.disconnect()
|
||||
except:
|
||||
pass
|
||||
|
||||
self.client.connect()
|
||||
logger.info("✓ 重新连接成功")
|
||||
|
||||
# 重新设置订阅
|
||||
if self._use_subscription:
|
||||
self._setup_subscriptions()
|
||||
|
||||
reconnect_attempts = 0
|
||||
except Exception as e:
|
||||
logger.error(f"重新连接失败 (尝试 {reconnect_attempts}/{max_reconnect_attempts}): {e}")
|
||||
time.sleep(5) # 重连失败后等待5秒
|
||||
else:
|
||||
logger.error(f"达到最大重连次数 ({max_reconnect_attempts}),停止重连")
|
||||
self._connection_monitor_running = False
|
||||
else:
|
||||
# 连接正常,重置重连计数
|
||||
reconnect_attempts = 0
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"连接监控出错: {e}")
|
||||
|
||||
# 等待下次检查
|
||||
time.sleep(self._connection_check_interval)
|
||||
|
||||
def _start_connection_monitor(self):
|
||||
"""启动连接监控线程"""
|
||||
if self._connection_monitor_thread is not None and self._connection_monitor_thread.is_alive():
|
||||
logger.warning("连接监控线程已在运行")
|
||||
return
|
||||
|
||||
import threading
|
||||
self._refresh_thread = threading.Thread(target=self._refresh_worker, daemon=True)
|
||||
self._refresh_thread.start()
|
||||
self._connection_monitor_thread = threading.Thread(
|
||||
target=self._connection_monitor_worker,
|
||||
daemon=True,
|
||||
name="OpcUaConnectionMonitor"
|
||||
)
|
||||
self._connection_monitor_thread.start()
|
||||
|
||||
def stop_node_refresh(self):
|
||||
"""停止节点值刷新线程"""
|
||||
self._refresh_running = False
|
||||
if self._refresh_thread and self._refresh_thread.is_alive():
|
||||
self._refresh_thread.join(timeout=2.0)
|
||||
logger.info("节点值刷新线程已停止")
|
||||
def _stop_connection_monitor(self):
|
||||
"""停止连接监控线程"""
|
||||
self._connection_monitor_running = False
|
||||
if self._connection_monitor_thread and self._connection_monitor_thread.is_alive():
|
||||
self._connection_monitor_thread.join(timeout=2.0)
|
||||
logger.info("连接监控线程已停止")
|
||||
|
||||
def read_node(self, node_name: str) -> str:
|
||||
"""
|
||||
读取节点值的便捷方法(使用缓存)
|
||||
返回JSON格式字符串
|
||||
"""
|
||||
try:
|
||||
# 使用get_node_value方法,自动处理缓存
|
||||
value = self.get_node_value(node_name, use_cache=True)
|
||||
|
||||
# 获取缓存信息
|
||||
chinese_name = self._name_mapping.get(node_name, node_name)
|
||||
cache_info = self._node_values.get(chinese_name, {})
|
||||
|
||||
result = {
|
||||
"value": value,
|
||||
"error": False,
|
||||
"node_name": node_name,
|
||||
"timestamp": time.time(),
|
||||
"cache_age": time.time() - cache_info.get('timestamp', time.time()),
|
||||
"source": cache_info.get('source', 'unknown')
|
||||
}
|
||||
|
||||
return json.dumps(result)
|
||||
except Exception as e:
|
||||
logger.error(f"读取节点 {node_name} 失败: {e}")
|
||||
result = {
|
||||
"value": None,
|
||||
"error": True,
|
||||
"node_name": node_name,
|
||||
"error_message": str(e),
|
||||
"timestamp": time.time()
|
||||
}
|
||||
return json.dumps(result)
|
||||
|
||||
def get_cache_stats(self) -> Dict[str, Any]:
|
||||
"""获取缓存统计信息"""
|
||||
current_time = time.time()
|
||||
stats = {
|
||||
'total_cached_nodes': len(self._node_values),
|
||||
'subscription_nodes': 0,
|
||||
'on_demand_nodes': 0,
|
||||
'expired_nodes': 0,
|
||||
'cache_timeout': self._cache_timeout,
|
||||
'using_subscription': self._use_subscription
|
||||
}
|
||||
|
||||
for node_name, cache_entry in self._node_values.items():
|
||||
source = cache_entry.get('source', 'unknown')
|
||||
cache_age = current_time - cache_entry['timestamp']
|
||||
|
||||
if source == 'subscription':
|
||||
stats['subscription_nodes'] += 1
|
||||
elif source in ['on_demand_read', 'forced_read', 'write']:
|
||||
stats['on_demand_nodes'] += 1
|
||||
|
||||
if cache_age > self._cache_timeout:
|
||||
stats['expired_nodes'] += 1
|
||||
|
||||
return stats
|
||||
|
||||
def print_cache_stats(self):
|
||||
"""打印缓存统计信息"""
|
||||
stats = self.get_cache_stats()
|
||||
print("\n" + "="*80)
|
||||
print("缓存统计信息")
|
||||
print("="*80)
|
||||
print(f"总缓存节点数: {stats['total_cached_nodes']}")
|
||||
print(f"订阅模式: {'启用' if stats['using_subscription'] else '禁用'}")
|
||||
print(f" - 订阅更新节点: {stats['subscription_nodes']}")
|
||||
print(f" - 按需读取节点: {stats['on_demand_nodes']}")
|
||||
print(f" - 已过期节点: {stats['expired_nodes']}")
|
||||
print(f"缓存超时时间: {stats['cache_timeout']}秒")
|
||||
print("="*80 + "\n")
|
||||
|
||||
def load_config(self, config_path: str) -> None:
|
||||
"""从JSON配置文件加载并注册工作流"""
|
||||
@@ -1289,42 +1619,111 @@ class OpcUaClient(BaseClient):
|
||||
|
||||
# 处理节点注册
|
||||
if "register_node_list_from_csv_path" in config_data:
|
||||
# 获取配置文件所在目录
|
||||
config_dir = os.path.dirname(os.path.abspath(config_path))
|
||||
|
||||
# 处理CSV路径,如果是相对路径,则相对于配置文件所在目录
|
||||
if "path" in config_data["register_node_list_from_csv_path"]:
|
||||
csv_path = config_data["register_node_list_from_csv_path"]["path"]
|
||||
if not os.path.isabs(csv_path):
|
||||
# 转换为绝对路径
|
||||
csv_path = os.path.join(config_dir, csv_path)
|
||||
config_data["register_node_list_from_csv_path"]["path"] = csv_path
|
||||
|
||||
# 直接使用字典
|
||||
self.register_node_list_from_csv_path(**config_data["register_node_list_from_csv_path"])
|
||||
|
||||
if self.client and self._variables_to_find:
|
||||
logger.info("CSV加载完成,开始查找服务器节点...")
|
||||
self._find_nodes()
|
||||
|
||||
# 处理工作流创建
|
||||
if "create_flow" in config_data:
|
||||
# 直接传递字典列表
|
||||
self.create_workflow_from_json(config_data["create_flow"])
|
||||
# 将工作流注册为实例方法
|
||||
self.register_workflows_as_methods()
|
||||
|
||||
# 将所有节点注册为属性
|
||||
self._register_nodes_as_attributes()
|
||||
|
||||
# 打印统计信息
|
||||
found_count = len(self._node_registry)
|
||||
total_count = len(self._variables_to_find)
|
||||
if found_count < total_count:
|
||||
logger.warning(f"节点查找完成:找到 {found_count}/{total_count} 个节点")
|
||||
else:
|
||||
logger.info(f"✓ 节点查找完成:所有 {found_count} 个节点均已找到")
|
||||
|
||||
# 如果使用订阅模式,重新设置订阅(确保新节点被订阅)
|
||||
if self._use_subscription and found_count > 0:
|
||||
self._setup_subscriptions()
|
||||
|
||||
logger.info(f"成功从 {config_path} 加载配置")
|
||||
except Exception as e:
|
||||
logger.error(f"加载配置文件 {config_path} 失败: {e}")
|
||||
traceback.print_exc()
|
||||
|
||||
def disconnect(self):
|
||||
# 停止刷新线程
|
||||
self.stop_node_refresh()
|
||||
"""断开连接并清理资源"""
|
||||
logger.info("正在断开连接...")
|
||||
|
||||
# 停止连接监控
|
||||
self._stop_connection_monitor()
|
||||
|
||||
# 删除订阅
|
||||
if self._subscription:
|
||||
try:
|
||||
with self._client_lock:
|
||||
self._subscription.delete()
|
||||
logger.info("订阅已删除")
|
||||
except Exception as e:
|
||||
logger.warning(f"删除订阅失败: {e}")
|
||||
|
||||
# 断开客户端连接
|
||||
if self.client:
|
||||
try:
|
||||
with self._client_lock:
|
||||
self.client.disconnect()
|
||||
logger.info("OPC UA client disconnected")
|
||||
logger.info("✓ OPC UA 客户端已断开连接")
|
||||
except Exception as e:
|
||||
logger.error(f"断开连接失败: {e}")
|
||||
|
||||
def _register_nodes_as_attributes(self):
|
||||
"""将所有节点注册为实例属性"""
|
||||
for node_name, node in self._node_registry.items():
|
||||
if not node.node_id or node.node_id == "":
|
||||
logger.warning(f"⚠ 节点 '{node_name}' 的 node_id 为空,跳过注册为属性")
|
||||
continue
|
||||
|
||||
eng_name = self._reverse_mapping.get(node_name)
|
||||
attr_name = eng_name if eng_name else node_name.replace(' ', '_').replace('-', '_')
|
||||
|
||||
def create_property_getter(node_key):
|
||||
def getter(self):
|
||||
return self.get_node_value(node_key, use_cache=True)
|
||||
return getter
|
||||
|
||||
setattr(OpcUaClient, attr_name, property(create_property_getter(node_name)))
|
||||
logger.debug(f"已注册节点 '{node_name}' 为属性 '{attr_name}'")
|
||||
|
||||
def post_init(self, ros_node):
|
||||
"""ROS2 节点就绪后的初始化"""
|
||||
if not (hasattr(self, 'deck') and self.deck):
|
||||
return
|
||||
|
||||
if not (hasattr(ros_node, 'resource_tracker') and ros_node.resource_tracker):
|
||||
logger.warning("resource_tracker 不存在,无法注册 deck")
|
||||
return
|
||||
|
||||
# 1. 本地注册(必需)
|
||||
ros_node.resource_tracker.add_resource(self.deck)
|
||||
|
||||
# 2. 上传云端
|
||||
try:
|
||||
from unilabos.ros.nodes.base_device_node import ROS2DeviceNode
|
||||
ROS2DeviceNode.run_async_func(
|
||||
ros_node.update_resource,
|
||||
True,
|
||||
resources=[self.deck]
|
||||
)
|
||||
logger.info("Deck 已上传到云端")
|
||||
except Exception as e:
|
||||
logger.error(f"上传失败: {e}")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
@@ -1338,8 +1737,8 @@ if __name__ == '__main__':
|
||||
# 创建OPC UA客户端并加载配置
|
||||
try:
|
||||
client = OpcUaClient(
|
||||
url="opc.tcp://localhost:4840/freeopcua/server/", # 替换为实际的OPC UA服务器地址
|
||||
config_path=config_path # 传入配置文件路径
|
||||
url="opc.tcp://192.168.1.88:4840/freeopcua/server/", # 替换为实际的OPC UA服务器地址
|
||||
config_path="D:\\Uni-Lab-OS\\unilabos\\device_comms\\opcua_client\\opcua_huairou.json" # 传入配置文件路径
|
||||
)
|
||||
|
||||
# 列出所有已注册的工作流
|
||||
@@ -1349,7 +1748,9 @@ if __name__ == '__main__':
|
||||
|
||||
# 测试trigger_grab_action工作流 - 使用英文参数名
|
||||
print("\n测试trigger_grab_action工作流 - 使用英文参数名:")
|
||||
client.trigger_grab_action(reaction_tank_number=2, raw_tank_number=3)
|
||||
client.trigger_grab_action(reaction_tank_number=2, raw_tank_number=2)
|
||||
# client.set_node_value("reaction_tank_number", 2)
|
||||
|
||||
|
||||
# 读取节点值 - 使用英文节点名
|
||||
grab_complete = client.get_node_value("grab_complete")
|
||||
|
||||
@@ -3,7 +3,7 @@ from enum import Enum
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Tuple, Union, Optional, Any, List
|
||||
|
||||
from opcua import Client, Node
|
||||
from opcua import Client, Node, ua
|
||||
from opcua.ua import NodeId, NodeClass, VariantType
|
||||
|
||||
|
||||
@@ -47,23 +47,68 @@ class Base(ABC):
|
||||
def _get_node(self) -> Node:
|
||||
if self._node is None:
|
||||
try:
|
||||
# 检查是否是NumericNodeId(ns=X;i=Y)格式
|
||||
if "NumericNodeId" in self._node_id:
|
||||
# 从字符串中提取命名空间和标识符
|
||||
# 尝试多种 NodeId 字符串格式解析,兼容不同服务器/库的输出
|
||||
# 可能的格式示例: 'ns=2;i=1234', 'ns=2;s=SomeString',
|
||||
# 'StringNodeId(ns=4;s=OPC|变量名)', 'NumericNodeId(ns=2;i=1234)' 等
|
||||
import re
|
||||
match = re.search(r'ns=(\d+);i=(\d+)', self._node_id)
|
||||
if match:
|
||||
ns = int(match.group(1))
|
||||
identifier = int(match.group(2))
|
||||
|
||||
nid = self._node_id
|
||||
# 如果已经是 NodeId/Node 对象(库用户可能传入),直接使用
|
||||
try:
|
||||
from opcua.ua import NodeId as UaNodeId
|
||||
if isinstance(nid, UaNodeId):
|
||||
self._node = self._client.get_node(nid)
|
||||
return self._node
|
||||
except Exception:
|
||||
# 若导入或类型判断失败,则继续下一步
|
||||
pass
|
||||
|
||||
# 直接以字符串形式处理
|
||||
if isinstance(nid, str):
|
||||
nid = nid.strip()
|
||||
|
||||
# 处理包含类名的格式,如 'StringNodeId(ns=4;s=...)' 或 'NumericNodeId(ns=2;i=...)'
|
||||
# 提取括号内的内容
|
||||
match_wrapped = re.match(r'(String|Numeric|Byte|Guid|TwoByteNode|FourByteNode)NodeId\((.*)\)', nid)
|
||||
if match_wrapped:
|
||||
# 提取括号内的实际 node_id 字符串
|
||||
nid = match_wrapped.group(2).strip()
|
||||
|
||||
# 常见短格式 'ns=2;i=1234' 或 'ns=2;s=SomeString'
|
||||
if re.match(r'^ns=\d+;[is]=', nid):
|
||||
self._node = self._client.get_node(nid)
|
||||
else:
|
||||
# 尝试提取 ns 和 i 或 s
|
||||
# 对于字符串标识符,可能包含特殊字符,使用非贪婪匹配
|
||||
m_num = re.search(r'ns=(\d+);i=(\d+)', nid)
|
||||
m_str = re.search(r'ns=(\d+);s=(.+?)(?:\)|$)', nid)
|
||||
if m_num:
|
||||
ns = int(m_num.group(1))
|
||||
identifier = int(m_num.group(2))
|
||||
node_id = NodeId(identifier, ns)
|
||||
self._node = self._client.get_node(node_id)
|
||||
elif m_str:
|
||||
ns = int(m_str.group(1))
|
||||
identifier = m_str.group(2).strip()
|
||||
# 对于字符串标识符,直接使用字符串格式
|
||||
node_id_str = f"ns={ns};s={identifier}"
|
||||
self._node = self._client.get_node(node_id_str)
|
||||
else:
|
||||
raise ValueError(f"无法解析节点ID: {self._node_id}")
|
||||
# 回退:尝试直接传入字符串(有些实现接受其它格式)
|
||||
try:
|
||||
self._node = self._client.get_node(self._node_id)
|
||||
except Exception as e:
|
||||
# 输出更详细的错误信息供调试
|
||||
print(f"获取节点失败(尝试直接字符串): {self._node_id}, 错误: {e}")
|
||||
raise
|
||||
else:
|
||||
# 直接使用节点ID字符串
|
||||
# 非字符串,尝试直接使用
|
||||
self._node = self._client.get_node(self._node_id)
|
||||
except Exception as e:
|
||||
print(f"获取节点失败: {self._node_id}, 错误: {e}")
|
||||
# 添加额外提示,帮助定位 BadNodeIdUnknown 问题
|
||||
print("提示: 请确认该 node_id 是否来自当前连接的服务器地址空间," \
|
||||
"以及 CSV/配置中名称与服务器 BrowseName 是否匹配。")
|
||||
raise
|
||||
return self._node
|
||||
|
||||
@@ -104,7 +149,56 @@ class Variable(Base):
|
||||
|
||||
def write(self, value: Any) -> bool:
|
||||
try:
|
||||
# 如果声明了数据类型,则尝试转换并使用对应的 Variant 写入
|
||||
coerced = value
|
||||
try:
|
||||
if self._data_type is not None:
|
||||
# 基于声明的数据类型做简单类型转换
|
||||
dt = self._data_type
|
||||
if dt in (DataType.SBYTE, DataType.BYTE, DataType.INT16, DataType.UINT16,
|
||||
DataType.INT32, DataType.UINT32, DataType.INT64, DataType.UINT64):
|
||||
# 数值类型 -> int
|
||||
if isinstance(value, str):
|
||||
coerced = int(value)
|
||||
else:
|
||||
coerced = int(value)
|
||||
elif dt in (DataType.FLOAT, DataType.DOUBLE):
|
||||
if isinstance(value, str):
|
||||
coerced = float(value)
|
||||
else:
|
||||
coerced = float(value)
|
||||
elif dt == DataType.BOOLEAN:
|
||||
if isinstance(value, str):
|
||||
v = value.strip().lower()
|
||||
if v in ("true", "1", "yes", "on"):
|
||||
coerced = True
|
||||
elif v in ("false", "0", "no", "off"):
|
||||
coerced = False
|
||||
else:
|
||||
coerced = bool(value)
|
||||
else:
|
||||
coerced = bool(value)
|
||||
elif dt == DataType.STRING or dt == DataType.BYTESTRING or dt == DataType.DATETIME:
|
||||
coerced = str(value)
|
||||
|
||||
# 使用 ua.Variant 明确指定 VariantType
|
||||
try:
|
||||
variant = ua.Variant(coerced, dt.value)
|
||||
self._get_node().set_value(variant)
|
||||
except Exception:
|
||||
# 回退:有些 set_value 实现接受 (value, variant_type)
|
||||
try:
|
||||
self._get_node().set_value(coerced, dt.value)
|
||||
except Exception:
|
||||
# 最后回退到直接写入(保持兼容性)
|
||||
self._get_node().set_value(coerced)
|
||||
else:
|
||||
# 未声明数据类型,直接写入
|
||||
self._get_node().set_value(value)
|
||||
except Exception:
|
||||
# 若在转换或按数据类型写入失败,尝试直接写入原始值并让上层捕获错误
|
||||
self._get_node().set_value(value)
|
||||
|
||||
return False
|
||||
except Exception as e:
|
||||
print(f"写入变量 {self._name} 失败: {e}")
|
||||
@@ -120,20 +214,50 @@ class Method(Base):
|
||||
def _get_parent_node(self) -> Node:
|
||||
if self._parent_node is None:
|
||||
try:
|
||||
# 检查是否是NumericNodeId(ns=X;i=Y)格式
|
||||
if "NumericNodeId" in self._parent_node_id:
|
||||
# 从字符串中提取命名空间和标识符
|
||||
# 处理父节点ID,使用与_get_node相同的解析逻辑
|
||||
import re
|
||||
match = re.search(r'ns=(\d+);i=(\d+)', self._parent_node_id)
|
||||
if match:
|
||||
ns = int(match.group(1))
|
||||
identifier = int(match.group(2))
|
||||
|
||||
nid = self._parent_node_id
|
||||
|
||||
# 如果已经是 NodeId 对象,直接使用
|
||||
try:
|
||||
from opcua.ua import NodeId as UaNodeId
|
||||
if isinstance(nid, UaNodeId):
|
||||
self._parent_node = self._client.get_node(nid)
|
||||
return self._parent_node
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 字符串处理
|
||||
if isinstance(nid, str):
|
||||
nid = nid.strip()
|
||||
|
||||
# 处理包含类名的格式
|
||||
match_wrapped = re.match(r'(String|Numeric|Byte|Guid|TwoByteNode|FourByteNode)NodeId\((.*)\)', nid)
|
||||
if match_wrapped:
|
||||
nid = match_wrapped.group(2).strip()
|
||||
|
||||
# 常见短格式
|
||||
if re.match(r'^ns=\d+;[is]=', nid):
|
||||
self._parent_node = self._client.get_node(nid)
|
||||
else:
|
||||
# 提取 ns 和 i 或 s
|
||||
m_num = re.search(r'ns=(\d+);i=(\d+)', nid)
|
||||
m_str = re.search(r'ns=(\d+);s=(.+?)(?:\)|$)', nid)
|
||||
if m_num:
|
||||
ns = int(m_num.group(1))
|
||||
identifier = int(m_num.group(2))
|
||||
node_id = NodeId(identifier, ns)
|
||||
self._parent_node = self._client.get_node(node_id)
|
||||
elif m_str:
|
||||
ns = int(m_str.group(1))
|
||||
identifier = m_str.group(2).strip()
|
||||
node_id_str = f"ns={ns};s={identifier}"
|
||||
self._parent_node = self._client.get_node(node_id_str)
|
||||
else:
|
||||
raise ValueError(f"无法解析父节点ID: {self._parent_node_id}")
|
||||
# 回退
|
||||
self._parent_node = self._client.get_node(self._parent_node_id)
|
||||
else:
|
||||
# 直接使用节点ID字符串
|
||||
self._parent_node = self._client.get_node(self._parent_node_id)
|
||||
except Exception as e:
|
||||
print(f"获取父节点失败: {self._parent_node_id}, 错误: {e}")
|
||||
|
||||
@@ -128,7 +128,7 @@ class ResourceVisualization:
|
||||
new_dev.set("device_name", node["id"]+"_")
|
||||
# if node["parent"] is not None:
|
||||
# new_dev.set("station_name", node["parent"]+'_')
|
||||
|
||||
if "position" in node:
|
||||
new_dev.set("x",str(float(node["position"]["position"]["x"])/1000))
|
||||
new_dev.set("y",str(float(node["position"]["position"]["y"])/1000))
|
||||
new_dev.set("z",str(float(node["position"]["position"]["z"])/1000))
|
||||
@@ -136,6 +136,13 @@ class ResourceVisualization:
|
||||
new_dev.set("rx",str(float(node["config"]["rotation"]["x"])))
|
||||
new_dev.set("ry",str(float(node["config"]["rotation"]["y"])))
|
||||
new_dev.set("r",str(float(node["config"]["rotation"]["z"])))
|
||||
if "pose" in node:
|
||||
new_dev.set("x",str(float(node["pose"]["position"]["x"])/1000))
|
||||
new_dev.set("y",str(float(node["pose"]["position"]["y"])/1000))
|
||||
new_dev.set("z",str(float(node["pose"]["position"]["z"])/1000))
|
||||
new_dev.set("rx",str(float(node["pose"]["rotation"]["x"])))
|
||||
new_dev.set("ry",str(float(node["pose"]["rotation"]["y"])))
|
||||
new_dev.set("r",str(float(node["pose"]["rotation"]["z"])))
|
||||
if "device_config" in node["config"]:
|
||||
for key, value in node["config"]["device_config"].items():
|
||||
new_dev.set(key, str(value))
|
||||
|
||||
0
unilabos/devices/cameraSII/__init__.py
Normal file
0
unilabos/devices/cameraSII/__init__.py
Normal file
712
unilabos/devices/cameraSII/cameraDriver.py
Normal file
712
unilabos/devices/cameraSII/cameraDriver.py
Normal file
@@ -0,0 +1,712 @@
|
||||
#!/usr/bin/env python3
|
||||
import asyncio
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
from typing import Optional, Dict, Any
|
||||
import logging
|
||||
|
||||
import requests
|
||||
import websockets
|
||||
|
||||
logging.getLogger("zeep").setLevel(logging.WARNING)
|
||||
logging.getLogger("zeep.xsd.schema").setLevel(logging.WARNING)
|
||||
logging.getLogger("zeep.xsd.schema.schema").setLevel(logging.WARNING)
|
||||
from onvif import ONVIFCamera # 新增:ONVIF PTZ 控制
|
||||
|
||||
|
||||
# ======================= 独立的 PTZController =======================
|
||||
class PTZController:
|
||||
def __init__(self, host: str, port: int, user: str, password: str):
|
||||
"""
|
||||
:param host: 摄像机 IP 或域名(和 RTSP 的一样即可)
|
||||
:param port: ONVIF 端口(多数为 80,看你的设备)
|
||||
:param user: 摄像机用户名
|
||||
:param password: 摄像机密码
|
||||
"""
|
||||
self.host = host
|
||||
self.port = port
|
||||
self.user = user
|
||||
self.password = password
|
||||
|
||||
self.cam: Optional[ONVIFCamera] = None
|
||||
self.media_service = None
|
||||
self.ptz_service = None
|
||||
self.profile = None
|
||||
|
||||
def connect(self) -> bool:
|
||||
"""
|
||||
建立 ONVIF 连接并初始化 PTZ 能力,失败返回 False(不抛异常)
|
||||
Note: 首先 pip install onvif-zeep
|
||||
"""
|
||||
try:
|
||||
self.cam = ONVIFCamera(self.host, self.port, self.user, self.password)
|
||||
self.media_service = self.cam.create_media_service()
|
||||
self.ptz_service = self.cam.create_ptz_service()
|
||||
profiles = self.media_service.GetProfiles()
|
||||
if not profiles:
|
||||
print("[PTZ] No media profiles found on camera.", file=sys.stderr)
|
||||
return False
|
||||
self.profile = profiles[0]
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"[PTZ] Failed to init ONVIF PTZ: {e}", file=sys.stderr)
|
||||
return False
|
||||
|
||||
def _continuous_move(self, pan: float, tilt: float, zoom: float, duration: float) -> bool:
|
||||
"""
|
||||
连续移动一段时间(秒),之后自动停止。
|
||||
此函数为阻塞模式:只有在 Stop 调用结束后,才返回 True/False。
|
||||
"""
|
||||
if not self.ptz_service or not self.profile:
|
||||
print("[PTZ] _continuous_move: ptz_service or profile not ready", file=sys.stderr)
|
||||
return False
|
||||
|
||||
# 进入前先强行停一下,避免前一次残留动作
|
||||
self._force_stop()
|
||||
|
||||
req = self.ptz_service.create_type("ContinuousMove")
|
||||
req.ProfileToken = self.profile.token
|
||||
|
||||
req.Velocity = {
|
||||
"PanTilt": {"x": pan, "y": tilt},
|
||||
"Zoom": {"x": zoom},
|
||||
}
|
||||
|
||||
try:
|
||||
print(f"[PTZ] ContinuousMove start: pan={pan}, tilt={tilt}, zoom={zoom}, duration={duration}", file=sys.stderr)
|
||||
self.ptz_service.ContinuousMove(req)
|
||||
except Exception as e:
|
||||
print(f"[PTZ] ContinuousMove failed: {e}", file=sys.stderr)
|
||||
return False
|
||||
|
||||
# 阻塞等待:这里决定“运动时间”
|
||||
import time
|
||||
wait_seconds = max(2 * duration, 0.0)
|
||||
time.sleep(wait_seconds)
|
||||
|
||||
# 运动完成后强制停止
|
||||
return self._force_stop()
|
||||
|
||||
def stop(self) -> bool:
|
||||
"""
|
||||
阻塞调用 Stop(带重试),成功 True,失败 False。
|
||||
"""
|
||||
return self._force_stop()
|
||||
|
||||
# ------- 对外动作接口(给 CameraController 调用) -------
|
||||
# 所有接口都为“阻塞模式”:只有在运动 + Stop 完成后才返回 True/False
|
||||
|
||||
def move_up(self, speed: float = 0.5, duration: float = 1.0) -> bool:
|
||||
print(f"[PTZ] move_up called, speed={speed}, duration={duration}", file=sys.stderr)
|
||||
return self._continuous_move(pan=0.0, tilt=+speed, zoom=0.0, duration=duration)
|
||||
|
||||
def move_down(self, speed: float = 0.5, duration: float = 1.0) -> bool:
|
||||
print(f"[PTZ] move_down called, speed={speed}, duration={duration}", file=sys.stderr)
|
||||
return self._continuous_move(pan=0.0, tilt=-speed, zoom=0.0, duration=duration)
|
||||
|
||||
def move_left(self, speed: float = 0.2, duration: float = 1.0) -> bool:
|
||||
print(f"[PTZ] move_left called, speed={speed}, duration={duration}", file=sys.stderr)
|
||||
return self._continuous_move(pan=-speed, tilt=0.0, zoom=0.0, duration=duration)
|
||||
|
||||
def move_right(self, speed: float = 0.2, duration: float = 1.0) -> bool:
|
||||
print(f"[PTZ] move_right called, speed={speed}, duration={duration}", file=sys.stderr)
|
||||
return self._continuous_move(pan=+speed, tilt=0.0, zoom=0.0, duration=duration)
|
||||
|
||||
# ------- 占位的变倍接口(当前设备不支持) -------
|
||||
def zoom_in(self, speed: float = 0.2, duration: float = 1.0) -> bool:
|
||||
"""
|
||||
当前设备不支持变倍;保留方法只是避免上层调用时报错。
|
||||
"""
|
||||
print("[PTZ] zoom_in is disabled for this device.", file=sys.stderr)
|
||||
return False
|
||||
|
||||
def zoom_out(self, speed: float = 0.2, duration: float = 1.0) -> bool:
|
||||
"""
|
||||
当前设备不支持变倍;保留方法只是避免上层调用时报错。
|
||||
"""
|
||||
print("[PTZ] zoom_out is disabled for this device.", file=sys.stderr)
|
||||
return False
|
||||
|
||||
def _force_stop(self, retries: int = 3, delay: float = 0.1) -> bool:
|
||||
"""
|
||||
尝试多次调用 Stop,作为“强制停止”手段。
|
||||
:param retries: 重试次数
|
||||
:param delay: 每次重试间隔(秒)
|
||||
"""
|
||||
if not self.ptz_service or not self.profile:
|
||||
print("[PTZ] _force_stop: ptz_service or profile not ready", file=sys.stderr)
|
||||
return False
|
||||
|
||||
import time
|
||||
last_error = None
|
||||
for i in range(retries):
|
||||
try:
|
||||
print(f"[PTZ] _force_stop: calling Stop(), attempt={i+1}", file=sys.stderr)
|
||||
self.ptz_service.Stop({"ProfileToken": self.profile.token})
|
||||
print("[PTZ] _force_stop: Stop() returned OK", file=sys.stderr)
|
||||
return True
|
||||
except Exception as e:
|
||||
last_error = e
|
||||
print(f"[PTZ] _force_stop: Stop() failed at attempt {i+1}: {e}", file=sys.stderr)
|
||||
time.sleep(delay)
|
||||
|
||||
print(f"[PTZ] _force_stop: all {retries} attempts failed, last error: {last_error}", file=sys.stderr)
|
||||
return False
|
||||
|
||||
# ======================= CameraController(加入 PTZ) =======================
|
||||
|
||||
class CameraController:
|
||||
"""
|
||||
Uni-Lab-OS 摄像头驱动(driver 形式)
|
||||
启动 Uni-Lab-OS 后,立即开始推流
|
||||
|
||||
- WebSocket 信令:通过 signal_backend_url 连接到后端
|
||||
例如: wss://sciol.ac.cn/api/realtime/signal/host/<host_id>
|
||||
- 媒体服务器:通过 rtmp_url / webrtc_api / webrtc_stream_url
|
||||
当前配置为 SRS,与独立 HostSimulator 独立运行脚本保持一致。
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
host_id: str = "demo-host",
|
||||
|
||||
# (1)信令后端(WebSocket)
|
||||
signal_backend_url: str = "wss://sciol.ac.cn/api/realtime/signal/host",
|
||||
|
||||
# (2)媒体后端(RTMP + WebRTC API)
|
||||
rtmp_url: str = "rtmp://srs.sciol.ac.cn:4499/live/camera-01",
|
||||
webrtc_api: str = "https://srs.sciol.ac.cn/rtc/v1/play/",
|
||||
webrtc_stream_url: str = "webrtc://srs.sciol.ac.cn:4500/live/camera-01",
|
||||
camera_rtsp_url: str = "",
|
||||
|
||||
# (3)PTZ 控制相关(ONVIF)
|
||||
ptz_host: str = "", # 一般就是摄像头 IP,比如 "192.168.31.164"
|
||||
ptz_port: int = 80, # ONVIF 端口,不一定是 80,按实际情况改
|
||||
ptz_user: str = "", # admin
|
||||
ptz_password: str = "", # admin123
|
||||
):
|
||||
self.host_id = host_id
|
||||
self.camera_rtsp_url = camera_rtsp_url
|
||||
|
||||
# 拼接最终的 WebSocket URL:.../host/<host_id>
|
||||
signal_backend_url = signal_backend_url.rstrip("/")
|
||||
if not signal_backend_url.endswith("/host"):
|
||||
signal_backend_url = signal_backend_url + "/host"
|
||||
self.signal_backend_url = f"{signal_backend_url}/{host_id}"
|
||||
|
||||
# 媒体服务器配置
|
||||
self.rtmp_url = rtmp_url
|
||||
self.webrtc_api = webrtc_api
|
||||
self.webrtc_stream_url = webrtc_stream_url
|
||||
|
||||
# PTZ 控制
|
||||
self.ptz_host = ptz_host
|
||||
self.ptz_port = ptz_port
|
||||
self.ptz_user = ptz_user
|
||||
self.ptz_password = ptz_password
|
||||
self._ptz: Optional[PTZController] = None
|
||||
self._init_ptz_if_possible()
|
||||
|
||||
# 运行时状态
|
||||
self._ws: Optional[object] = None
|
||||
self._ffmpeg_process: Optional[subprocess.Popen] = None
|
||||
self._running = False
|
||||
self._loop_task: Optional[asyncio.Future] = None
|
||||
|
||||
# 事件循环 & 线程
|
||||
self._loop: Optional[asyncio.AbstractEventLoop] = None
|
||||
self._loop_thread: Optional[threading.Thread] = None
|
||||
|
||||
try:
|
||||
self.start()
|
||||
except Exception as e:
|
||||
print(f"[CameraController] __init__ auto start failed: {e}", file=sys.stderr)
|
||||
|
||||
# ------------------------ PTZ 初始化 ------------------------
|
||||
|
||||
# ------------------------ PTZ 公开动作方法(一个动作一个函数) ------------------------
|
||||
|
||||
def ptz_move_up(self, speed: float = 0.5, duration: float = 1.0) -> bool:
|
||||
print(f"[CameraController] ptz_move_up called, speed={speed}, duration={duration}")
|
||||
return self._ptz.move_up(speed=speed, duration=duration)
|
||||
|
||||
def ptz_move_down(self, speed: float = 0.5, duration: float = 1.0) -> bool:
|
||||
print(f"[CameraController] ptz_move_down called, speed={speed}, duration={duration}")
|
||||
return self._ptz.move_down(speed=speed, duration=duration)
|
||||
|
||||
def ptz_move_left(self, speed: float = 0.2, duration: float = 1.0) -> bool:
|
||||
print(f"[CameraController] ptz_move_left called, speed={speed}, duration={duration}")
|
||||
return self._ptz.move_left(speed=speed, duration=duration)
|
||||
|
||||
def ptz_move_right(self, speed: float = 0.2, duration: float = 1.0) -> bool:
|
||||
print(f"[CameraController] ptz_move_right called, speed={speed}, duration={duration}")
|
||||
return self._ptz.move_right(speed=speed, duration=duration)
|
||||
|
||||
def zoom_in(self, speed: float = 0.2, duration: float = 1.0) -> bool:
|
||||
"""
|
||||
当前设备不支持变倍;保留方法只是避免上层调用时报错。
|
||||
"""
|
||||
print("[PTZ] zoom_in is disabled for this device.", file=sys.stderr)
|
||||
return False
|
||||
|
||||
def zoom_out(self, speed: float = 0.2, duration: float = 1.0) -> bool:
|
||||
"""
|
||||
当前设备不支持变倍;保留方法只是避免上层调用时报错。
|
||||
"""
|
||||
print("[PTZ] zoom_out is disabled for this device.", file=sys.stderr)
|
||||
return False
|
||||
|
||||
def ptz_stop(self):
|
||||
if self._ptz is None:
|
||||
print("[CameraController] PTZ not initialized.", file=sys.stderr)
|
||||
return
|
||||
self._ptz.stop()
|
||||
|
||||
def _init_ptz_if_possible(self):
|
||||
"""
|
||||
根据 ptz_host / user / password 初始化 PTZ;
|
||||
如果配置信息不全则不启用 PTZ(静默)。
|
||||
"""
|
||||
if not (self.ptz_host and self.ptz_user and self.ptz_password):
|
||||
return
|
||||
ctrl = PTZController(
|
||||
host=self.ptz_host,
|
||||
port=self.ptz_port,
|
||||
user=self.ptz_user,
|
||||
password=self.ptz_password,
|
||||
)
|
||||
if ctrl.connect():
|
||||
self._ptz = ctrl
|
||||
else:
|
||||
self._ptz = None
|
||||
|
||||
# ---------------------------------------------------------------------
|
||||
# 对外暴露的方法:供 Uni-Lab-OS 调用
|
||||
# ---------------------------------------------------------------------
|
||||
|
||||
def start(self, config: Optional[Dict[str, Any]] = None):
|
||||
"""
|
||||
启动 Camera 连接 & 消息循环,并在启动时就开启 FFmpeg 推流,
|
||||
"""
|
||||
|
||||
if self._running:
|
||||
return {"status": "already_running", "host_id": self.host_id}
|
||||
|
||||
# 应用 config 覆盖(如果有)
|
||||
if config:
|
||||
self.camera_rtsp_url = config.get("camera_rtsp_url", self.camera_rtsp_url)
|
||||
cfg_host_id = config.get("host_id")
|
||||
if cfg_host_id:
|
||||
self.host_id = cfg_host_id
|
||||
|
||||
signal_backend_url = config.get("signal_backend_url")
|
||||
if signal_backend_url:
|
||||
signal_backend_url = signal_backend_url.rstrip("/")
|
||||
if not signal_backend_url.endswith("/host"):
|
||||
signal_backend_url = signal_backend_url + "/host"
|
||||
self.signal_backend_url = f"{signal_backend_url}/{self.host_id}"
|
||||
|
||||
self.rtmp_url = config.get("rtmp_url", self.rtmp_url)
|
||||
self.webrtc_api = config.get("webrtc_api", self.webrtc_api)
|
||||
self.webrtc_stream_url = config.get(
|
||||
"webrtc_stream_url", self.webrtc_stream_url
|
||||
)
|
||||
|
||||
# PTZ 相关配置也允许通过 config 注入
|
||||
self.ptz_host = config.get("ptz_host", self.ptz_host)
|
||||
self.ptz_port = int(config.get("ptz_port", self.ptz_port))
|
||||
self.ptz_user = config.get("ptz_user", self.ptz_user)
|
||||
self.ptz_password = config.get("ptz_password", self.ptz_password)
|
||||
self._init_ptz_if_possible()
|
||||
|
||||
self._running = True
|
||||
|
||||
# === start 时启动 FFmpeg 推流 ===
|
||||
self._start_ffmpeg()
|
||||
|
||||
# 创建新的事件循环和线程(用于 WebSocket 信令)
|
||||
self._loop = asyncio.new_event_loop()
|
||||
|
||||
def loop_runner(loop: asyncio.AbstractEventLoop):
|
||||
asyncio.set_event_loop(loop)
|
||||
try:
|
||||
loop.run_forever()
|
||||
except Exception as e:
|
||||
print(f"[CameraController] event loop error: {e}", file=sys.stderr)
|
||||
|
||||
self._loop_thread = threading.Thread(
|
||||
target=loop_runner, args=(self._loop,), daemon=True
|
||||
)
|
||||
self._loop_thread.start()
|
||||
|
||||
self._loop_task = asyncio.run_coroutine_threadsafe(
|
||||
self._run_main_loop(), self._loop
|
||||
)
|
||||
|
||||
return {
|
||||
"status": "started",
|
||||
"host_id": self.host_id,
|
||||
"signal_backend_url": self.signal_backend_url,
|
||||
"rtmp_url": self.rtmp_url,
|
||||
"webrtc_api": self.webrtc_api,
|
||||
"webrtc_stream_url": self.webrtc_stream_url,
|
||||
}
|
||||
|
||||
def stop(self) -> Dict[str, Any]:
|
||||
"""
|
||||
停止推流 & 断开 WebSocket,并关闭事件循环线程。
|
||||
"""
|
||||
self._running = False
|
||||
|
||||
self._stop_ffmpeg()
|
||||
|
||||
if self._ws and self._loop is not None:
|
||||
async def close_ws():
|
||||
try:
|
||||
await self._ws.close()
|
||||
except Exception as e:
|
||||
print(
|
||||
f"[CameraController] error when closing WebSocket: {e}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
asyncio.run_coroutine_threadsafe(close_ws(), self._loop)
|
||||
|
||||
if self._loop_task is not None:
|
||||
if not self._loop_task.done():
|
||||
self._loop_task.cancel()
|
||||
try:
|
||||
self._loop_task.result()
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
except Exception as e:
|
||||
print(
|
||||
f"[CameraController] main loop task error in stop(): {e}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
finally:
|
||||
self._loop_task = None
|
||||
|
||||
if self._loop is not None:
|
||||
try:
|
||||
self._loop.call_soon_threadsafe(self._loop.stop)
|
||||
except Exception as e:
|
||||
print(
|
||||
f"[CameraController] error when stopping event loop: {e}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
if self._loop_thread is not None:
|
||||
try:
|
||||
self._loop_thread.join(timeout=5)
|
||||
except Exception as e:
|
||||
print(
|
||||
f"[CameraController] error when joining loop thread: {e}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
finally:
|
||||
self._loop_thread = None
|
||||
|
||||
self._ws = None
|
||||
self._loop = None
|
||||
|
||||
return {"status": "stopped", "host_id": self.host_id}
|
||||
|
||||
def get_status(self) -> Dict[str, Any]:
|
||||
"""
|
||||
查询当前状态,方便在 Uni-Lab-OS 中做监控。
|
||||
"""
|
||||
ws_closed = None
|
||||
if self._ws is not None:
|
||||
ws_closed = getattr(self._ws, "closed", None)
|
||||
|
||||
if ws_closed is None:
|
||||
websocket_connected = self._ws is not None
|
||||
else:
|
||||
websocket_connected = (self._ws is not None) and (not ws_closed)
|
||||
|
||||
return {
|
||||
"host_id": self.host_id,
|
||||
"running": self._running,
|
||||
"websocket_connected": websocket_connected,
|
||||
"ffmpeg_running": bool(
|
||||
self._ffmpeg_process and self._ffmpeg_process.poll() is None
|
||||
),
|
||||
"signal_backend_url": self.signal_backend_url,
|
||||
"rtmp_url": self.rtmp_url,
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------
|
||||
# 内部实现逻辑:WebSocket 循环 / FFmpeg / WebRTC Offer 处理
|
||||
# ---------------------------------------------------------------------
|
||||
|
||||
async def _run_main_loop(self):
|
||||
try:
|
||||
while self._running:
|
||||
try:
|
||||
async with websockets.connect(self.signal_backend_url) as ws:
|
||||
self._ws = ws
|
||||
await self._recv_loop()
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as e:
|
||||
if self._running:
|
||||
print(
|
||||
f"[CameraController] WebSocket connection error: {e}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
await asyncio.sleep(3)
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
async def _recv_loop(self):
|
||||
assert self._ws is not None
|
||||
ws = self._ws
|
||||
|
||||
async for message in ws:
|
||||
try:
|
||||
data = json.loads(message)
|
||||
except json.JSONDecodeError:
|
||||
print(
|
||||
f"[CameraController] received non-JSON message: {message}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
continue
|
||||
|
||||
try:
|
||||
await self._handle_message(data)
|
||||
except Exception as e:
|
||||
print(
|
||||
f"[CameraController] error while handling message {data}: {e}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
async def _handle_message(self, data: Dict[str, Any]):
|
||||
"""
|
||||
处理来自信令后端的消息:
|
||||
- command: start_stream / stop_stream / ptz_xxx
|
||||
- type: offer (WebRTC)
|
||||
"""
|
||||
cmd = data.get("command")
|
||||
|
||||
# ---------- 推流控制 ----------
|
||||
if cmd == "start_stream":
|
||||
try:
|
||||
self._start_ffmpeg()
|
||||
except Exception as e:
|
||||
print(
|
||||
f"[CameraController] error when starting FFmpeg on start_stream: {e}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return
|
||||
|
||||
if cmd == "stop_stream":
|
||||
try:
|
||||
self._stop_ffmpeg()
|
||||
except Exception as e:
|
||||
print(
|
||||
f"[CameraController] error when stopping FFmpeg on stop_stream: {e}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return
|
||||
|
||||
# # ---------- PTZ 控制 ----------
|
||||
# # 例如信令可以发:
|
||||
# # {"command": "ptz_move", "direction": "down", "speed": 0.5, "duration": 0.5}
|
||||
# if cmd == "ptz_move":
|
||||
# if self._ptz is None:
|
||||
# # 没有初始化 PTZ,静默忽略或打印一条
|
||||
# print("[CameraController] PTZ not initialized.", file=sys.stderr)
|
||||
# return
|
||||
|
||||
# direction = data.get("direction", "")
|
||||
# speed = float(data.get("speed", 0.5))
|
||||
# duration = float(data.get("duration", 0.5))
|
||||
|
||||
# try:
|
||||
# if direction == "up":
|
||||
# self._ptz.move_up(speed=speed, duration=duration)
|
||||
# elif direction == "down":
|
||||
# self._ptz.move_down(speed=speed, duration=duration)
|
||||
# elif direction == "left":
|
||||
# self._ptz.move_left(speed=speed, duration=duration)
|
||||
# elif direction == "right":
|
||||
# self._ptz.move_right(speed=speed, duration=duration)
|
||||
# elif direction == "zoom_in":
|
||||
# self._ptz.zoom_in(speed=speed, duration=duration)
|
||||
# elif direction == "zoom_out":
|
||||
# self._ptz.zoom_out(speed=speed, duration=duration)
|
||||
# elif direction == "stop":
|
||||
# self._ptz.stop()
|
||||
# else:
|
||||
# # 未知方向,忽略
|
||||
# pass
|
||||
# except Exception as e:
|
||||
# print(
|
||||
# f"[CameraController] error when handling PTZ move: {e}",
|
||||
# file=sys.stderr,
|
||||
# )
|
||||
# return
|
||||
|
||||
# ---------- WebRTC Offer ----------
|
||||
if data.get("type") == "offer":
|
||||
offer_sdp = data.get("sdp", "")
|
||||
camera_id = data.get("cameraId", "camera-01")
|
||||
try:
|
||||
answer_sdp = await self._handle_webrtc_offer(offer_sdp)
|
||||
except Exception as e:
|
||||
print(
|
||||
f"[CameraController] error when handling WebRTC offer: {e}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return
|
||||
|
||||
if self._ws:
|
||||
answer_payload = {
|
||||
"type": "answer",
|
||||
"sdp": answer_sdp,
|
||||
"cameraId": camera_id,
|
||||
"hostId": self.host_id,
|
||||
}
|
||||
try:
|
||||
await self._ws.send(json.dumps(answer_payload))
|
||||
except Exception as e:
|
||||
print(
|
||||
f"[CameraController] error when sending WebRTC answer: {e}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
# ------------------------ FFmpeg 相关 ------------------------
|
||||
|
||||
def _start_ffmpeg(self):
|
||||
if self._ffmpeg_process and self._ffmpeg_process.poll() is None:
|
||||
return
|
||||
|
||||
cmd = [
|
||||
"ffmpeg",
|
||||
"-rtsp_transport", "tcp",
|
||||
"-i", self.camera_rtsp_url,
|
||||
|
||||
"-c:v", "libx264",
|
||||
"-preset", "ultrafast",
|
||||
"-tune", "zerolatency",
|
||||
"-profile:v", "baseline",
|
||||
"-b:v", "1M",
|
||||
"-maxrate", "1M",
|
||||
"-bufsize", "2M",
|
||||
"-g", "10",
|
||||
"-keyint_min", "10",
|
||||
"-sc_threshold", "0",
|
||||
"-pix_fmt", "yuv420p",
|
||||
"-x264-params", "bframes=0",
|
||||
|
||||
"-c:a", "aac",
|
||||
"-ar", "44100",
|
||||
"-ac", "1",
|
||||
"-b:a", "64k",
|
||||
|
||||
"-f", "flv",
|
||||
self.rtmp_url,
|
||||
]
|
||||
|
||||
try:
|
||||
self._ffmpeg_process = subprocess.Popen(
|
||||
cmd,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.STDOUT,
|
||||
shell=False,
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"[CameraController] failed to start FFmpeg: {e}", file=sys.stderr)
|
||||
self._ffmpeg_process = None
|
||||
raise
|
||||
|
||||
def _stop_ffmpeg(self):
|
||||
proc = self._ffmpeg_process
|
||||
|
||||
if proc and proc.poll() is None:
|
||||
try:
|
||||
proc.terminate()
|
||||
try:
|
||||
proc.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
try:
|
||||
proc.kill()
|
||||
try:
|
||||
proc.wait(timeout=2)
|
||||
except subprocess.TimeoutExpired:
|
||||
print(
|
||||
f"[CameraController] FFmpeg process did not exit even after kill (pid={proc.pid})",
|
||||
file=sys.stderr,
|
||||
)
|
||||
except Exception as e:
|
||||
print(
|
||||
f"[CameraController] failed to kill FFmpeg process: {e}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
except Exception as e:
|
||||
print(
|
||||
f"[CameraController] error when stopping FFmpeg: {e}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
self._ffmpeg_process = None
|
||||
|
||||
# ------------------------ WebRTC Offer 相关 ------------------------
|
||||
|
||||
async def _handle_webrtc_offer(self, offer_sdp: str) -> str:
|
||||
payload = {
|
||||
"api": self.webrtc_api,
|
||||
"streamurl": self.webrtc_stream_url,
|
||||
"sdp": offer_sdp,
|
||||
}
|
||||
|
||||
headers = {"Content-Type": "application/json"}
|
||||
|
||||
def _do_request():
|
||||
return requests.post(
|
||||
self.webrtc_api,
|
||||
json=payload,
|
||||
headers=headers,
|
||||
timeout=10,
|
||||
)
|
||||
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
resp = await loop.run_in_executor(None, _do_request)
|
||||
except Exception as e:
|
||||
print(
|
||||
f"[CameraController] failed to send offer to media server: {e}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
raise
|
||||
|
||||
try:
|
||||
resp.raise_for_status()
|
||||
except Exception as e:
|
||||
print(
|
||||
f"[CameraController] media server HTTP error: {e}, "
|
||||
f"status={resp.status_code}, body={resp.text[:200]}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
raise
|
||||
|
||||
try:
|
||||
data = resp.json()
|
||||
except Exception as e:
|
||||
print(
|
||||
f"[CameraController] failed to parse media server JSON: {e}, "
|
||||
f"raw={resp.text[:200]}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
raise
|
||||
|
||||
answer_sdp = data.get("sdp", "")
|
||||
if not answer_sdp:
|
||||
msg = f"empty SDP from media server: {data}"
|
||||
print(f"[CameraController] {msg}", file=sys.stderr)
|
||||
raise RuntimeError(msg)
|
||||
|
||||
return answer_sdp
|
||||
401
unilabos/devices/cameraSII/cameraUSB.py
Normal file
401
unilabos/devices/cameraSII/cameraUSB.py
Normal file
@@ -0,0 +1,401 @@
|
||||
#!/usr/bin/env python3
|
||||
import asyncio
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
from typing import Optional, Dict, Any
|
||||
|
||||
import requests
|
||||
import websockets
|
||||
|
||||
|
||||
class CameraController:
|
||||
"""
|
||||
Uni-Lab-OS 摄像头驱动(Linux USB 摄像头版,无 PTZ)
|
||||
|
||||
- WebSocket 信令:signal_backend_url 连接到后端
|
||||
例如: wss://sciol.ac.cn/api/realtime/signal/host/<host_id>
|
||||
- 媒体服务器:RTMP 推流到 rtmp_url;WebRTC offer 转发到 SRS 的 webrtc_api
|
||||
- 视频源:本地 USB 摄像头(V4L2,默认 /dev/video0)
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
host_id: str = "demo-host",
|
||||
signal_backend_url: str = "wss://sciol.ac.cn/api/realtime/signal/host",
|
||||
rtmp_url: str = "rtmp://srs.sciol.ac.cn:4499/live/camera-01",
|
||||
webrtc_api: str = "https://srs.sciol.ac.cn/rtc/v1/play/",
|
||||
webrtc_stream_url: str = "webrtc://srs.sciol.ac.cn:4500/live/camera-01",
|
||||
video_device: str = "/dev/video0",
|
||||
width: int = 1280,
|
||||
height: int = 720,
|
||||
fps: int = 30,
|
||||
video_bitrate: str = "1500k",
|
||||
audio_device: Optional[str] = None, # 比如 "hw:1,0",没有音频就保持 None
|
||||
audio_bitrate: str = "64k",
|
||||
):
|
||||
self.host_id = host_id
|
||||
|
||||
# 拼接最终 WebSocket URL:.../host/<host_id>
|
||||
signal_backend_url = signal_backend_url.rstrip("/")
|
||||
if not signal_backend_url.endswith("/host"):
|
||||
signal_backend_url = signal_backend_url + "/host"
|
||||
self.signal_backend_url = f"{signal_backend_url}/{host_id}"
|
||||
|
||||
# 媒体服务器配置
|
||||
self.rtmp_url = rtmp_url
|
||||
self.webrtc_api = webrtc_api
|
||||
self.webrtc_stream_url = webrtc_stream_url
|
||||
|
||||
# 本地采集配置
|
||||
self.video_device = video_device
|
||||
self.width = int(width)
|
||||
self.height = int(height)
|
||||
self.fps = int(fps)
|
||||
self.video_bitrate = video_bitrate
|
||||
self.audio_device = audio_device
|
||||
self.audio_bitrate = audio_bitrate
|
||||
|
||||
# 运行时状态
|
||||
self._ws: Optional[object] = None
|
||||
self._ffmpeg_process: Optional[subprocess.Popen] = None
|
||||
self._running = False
|
||||
self._loop_task: Optional[asyncio.Future] = None
|
||||
|
||||
# 事件循环 & 线程
|
||||
self._loop: Optional[asyncio.AbstractEventLoop] = None
|
||||
self._loop_thread: Optional[threading.Thread] = None
|
||||
|
||||
try:
|
||||
self.start()
|
||||
except Exception as e:
|
||||
print(f"[CameraController] __init__ auto start failed: {e}", file=sys.stderr)
|
||||
|
||||
# ---------------------------------------------------------------------
|
||||
# 对外方法
|
||||
# ---------------------------------------------------------------------
|
||||
|
||||
def start(self, config: Optional[Dict[str, Any]] = None):
|
||||
if self._running:
|
||||
return {"status": "already_running", "host_id": self.host_id}
|
||||
|
||||
# 应用 config 覆盖(如果有)
|
||||
if config:
|
||||
cfg_host_id = config.get("host_id")
|
||||
if cfg_host_id:
|
||||
self.host_id = cfg_host_id
|
||||
|
||||
signal_backend_url = config.get("signal_backend_url")
|
||||
if signal_backend_url:
|
||||
signal_backend_url = signal_backend_url.rstrip("/")
|
||||
if not signal_backend_url.endswith("/host"):
|
||||
signal_backend_url = signal_backend_url + "/host"
|
||||
self.signal_backend_url = f"{signal_backend_url}/{self.host_id}"
|
||||
|
||||
self.rtmp_url = config.get("rtmp_url", self.rtmp_url)
|
||||
self.webrtc_api = config.get("webrtc_api", self.webrtc_api)
|
||||
self.webrtc_stream_url = config.get("webrtc_stream_url", self.webrtc_stream_url)
|
||||
|
||||
self.video_device = config.get("video_device", self.video_device)
|
||||
self.width = int(config.get("width", self.width))
|
||||
self.height = int(config.get("height", self.height))
|
||||
self.fps = int(config.get("fps", self.fps))
|
||||
self.video_bitrate = config.get("video_bitrate", self.video_bitrate)
|
||||
self.audio_device = config.get("audio_device", self.audio_device)
|
||||
self.audio_bitrate = config.get("audio_bitrate", self.audio_bitrate)
|
||||
|
||||
self._running = True
|
||||
|
||||
print("[CameraController] start(): starting FFmpeg streaming...", file=sys.stderr)
|
||||
self._start_ffmpeg()
|
||||
|
||||
self._loop = asyncio.new_event_loop()
|
||||
|
||||
def loop_runner(loop: asyncio.AbstractEventLoop):
|
||||
asyncio.set_event_loop(loop)
|
||||
try:
|
||||
loop.run_forever()
|
||||
except Exception as e:
|
||||
print(f"[CameraController] event loop error: {e}", file=sys.stderr)
|
||||
|
||||
self._loop_thread = threading.Thread(target=loop_runner, args=(self._loop,), daemon=True)
|
||||
self._loop_thread.start()
|
||||
|
||||
self._loop_task = asyncio.run_coroutine_threadsafe(self._run_main_loop(), self._loop)
|
||||
|
||||
return {
|
||||
"status": "started",
|
||||
"host_id": self.host_id,
|
||||
"signal_backend_url": self.signal_backend_url,
|
||||
"rtmp_url": self.rtmp_url,
|
||||
"webrtc_api": self.webrtc_api,
|
||||
"webrtc_stream_url": self.webrtc_stream_url,
|
||||
"video_device": self.video_device,
|
||||
"width": self.width,
|
||||
"height": self.height,
|
||||
"fps": self.fps,
|
||||
"video_bitrate": self.video_bitrate,
|
||||
"audio_device": self.audio_device,
|
||||
}
|
||||
|
||||
def stop(self) -> Dict[str, Any]:
|
||||
self._running = False
|
||||
|
||||
# 先取消主任务(让 ws connect/sleep 尽快退出)
|
||||
if self._loop_task is not None and not self._loop_task.done():
|
||||
self._loop_task.cancel()
|
||||
|
||||
# 停止推流
|
||||
self._stop_ffmpeg()
|
||||
|
||||
# 关闭 WebSocket(在 loop 中执行)
|
||||
if self._ws and self._loop is not None:
|
||||
|
||||
async def close_ws():
|
||||
try:
|
||||
await self._ws.close()
|
||||
except Exception as e:
|
||||
print(f"[CameraController] error closing WebSocket: {e}", file=sys.stderr)
|
||||
|
||||
try:
|
||||
asyncio.run_coroutine_threadsafe(close_ws(), self._loop)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 停止事件循环
|
||||
if self._loop is not None:
|
||||
try:
|
||||
self._loop.call_soon_threadsafe(self._loop.stop)
|
||||
except Exception as e:
|
||||
print(f"[CameraController] error stopping loop: {e}", file=sys.stderr)
|
||||
|
||||
# 等待线程退出
|
||||
if self._loop_thread is not None:
|
||||
try:
|
||||
self._loop_thread.join(timeout=5)
|
||||
except Exception as e:
|
||||
print(f"[CameraController] error joining loop thread: {e}", file=sys.stderr)
|
||||
|
||||
self._ws = None
|
||||
self._loop_task = None
|
||||
self._loop = None
|
||||
self._loop_thread = None
|
||||
|
||||
return {"status": "stopped", "host_id": self.host_id}
|
||||
|
||||
def get_status(self) -> Dict[str, Any]:
|
||||
ws_closed = None
|
||||
if self._ws is not None:
|
||||
ws_closed = getattr(self._ws, "closed", None)
|
||||
|
||||
if ws_closed is None:
|
||||
websocket_connected = self._ws is not None
|
||||
else:
|
||||
websocket_connected = (self._ws is not None) and (not ws_closed)
|
||||
|
||||
return {
|
||||
"host_id": self.host_id,
|
||||
"running": self._running,
|
||||
"websocket_connected": websocket_connected,
|
||||
"ffmpeg_running": bool(self._ffmpeg_process and self._ffmpeg_process.poll() is None),
|
||||
"signal_backend_url": self.signal_backend_url,
|
||||
"rtmp_url": self.rtmp_url,
|
||||
"video_device": self.video_device,
|
||||
"width": self.width,
|
||||
"height": self.height,
|
||||
"fps": self.fps,
|
||||
"video_bitrate": self.video_bitrate,
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------
|
||||
# WebSocket / 信令
|
||||
# ---------------------------------------------------------------------
|
||||
|
||||
async def _run_main_loop(self):
|
||||
print("[CameraController] main loop started", file=sys.stderr)
|
||||
try:
|
||||
while self._running:
|
||||
try:
|
||||
async with websockets.connect(self.signal_backend_url) as ws:
|
||||
self._ws = ws
|
||||
print(f"[CameraController] WebSocket connected: {self.signal_backend_url}", file=sys.stderr)
|
||||
await self._recv_loop()
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as e:
|
||||
if self._running:
|
||||
print(f"[CameraController] WebSocket connection error: {e}", file=sys.stderr)
|
||||
await asyncio.sleep(3)
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
finally:
|
||||
print("[CameraController] main loop exited", file=sys.stderr)
|
||||
|
||||
async def _recv_loop(self):
|
||||
assert self._ws is not None
|
||||
ws = self._ws
|
||||
|
||||
async for message in ws:
|
||||
try:
|
||||
data = json.loads(message)
|
||||
except json.JSONDecodeError:
|
||||
print(f"[CameraController] non-JSON message: {message}", file=sys.stderr)
|
||||
continue
|
||||
|
||||
try:
|
||||
await self._handle_message(data)
|
||||
except Exception as e:
|
||||
print(f"[CameraController] error handling message {data}: {e}", file=sys.stderr)
|
||||
|
||||
async def _handle_message(self, data: Dict[str, Any]):
|
||||
cmd = data.get("command")
|
||||
|
||||
if cmd == "start_stream":
|
||||
self._start_ffmpeg()
|
||||
return
|
||||
|
||||
if cmd == "stop_stream":
|
||||
self._stop_ffmpeg()
|
||||
return
|
||||
|
||||
if data.get("type") == "offer":
|
||||
offer_sdp = data.get("sdp", "")
|
||||
camera_id = data.get("cameraId", "camera-01")
|
||||
|
||||
answer_sdp = await self._handle_webrtc_offer(offer_sdp)
|
||||
|
||||
if self._ws:
|
||||
answer_payload = {
|
||||
"type": "answer",
|
||||
"sdp": answer_sdp,
|
||||
"cameraId": camera_id,
|
||||
"hostId": self.host_id,
|
||||
}
|
||||
await self._ws.send(json.dumps(answer_payload))
|
||||
|
||||
# ---------------------------------------------------------------------
|
||||
# FFmpeg 推流(V4L2 USB 摄像头)
|
||||
# ---------------------------------------------------------------------
|
||||
|
||||
def _start_ffmpeg(self):
|
||||
if self._ffmpeg_process and self._ffmpeg_process.poll() is None:
|
||||
return
|
||||
|
||||
# 兼容性优先:不强制输入像素格式;失败再通过外部调整 width/height/fps
|
||||
video_size = f"{self.width}x{self.height}"
|
||||
|
||||
cmd = [
|
||||
"ffmpeg",
|
||||
"-hide_banner",
|
||||
"-loglevel",
|
||||
"warning",
|
||||
|
||||
# video input
|
||||
"-f", "v4l2",
|
||||
"-framerate", str(self.fps),
|
||||
"-video_size", video_size,
|
||||
"-i", self.video_device,
|
||||
]
|
||||
|
||||
# optional audio input
|
||||
if self.audio_device:
|
||||
cmd += [
|
||||
"-f", "alsa",
|
||||
"-i", self.audio_device,
|
||||
"-c:a", "aac",
|
||||
"-b:a", self.audio_bitrate,
|
||||
"-ar", "44100",
|
||||
"-ac", "1",
|
||||
]
|
||||
else:
|
||||
cmd += ["-an"]
|
||||
|
||||
# video encode + rtmp out
|
||||
cmd += [
|
||||
"-c:v", "libx264",
|
||||
"-preset", "ultrafast",
|
||||
"-tune", "zerolatency",
|
||||
"-profile:v", "baseline",
|
||||
"-pix_fmt", "yuv420p",
|
||||
"-b:v", self.video_bitrate,
|
||||
"-maxrate", self.video_bitrate,
|
||||
"-bufsize", "2M",
|
||||
"-g", str(max(self.fps, 10)),
|
||||
"-keyint_min", str(max(self.fps, 10)),
|
||||
"-sc_threshold", "0",
|
||||
"-x264-params", "bframes=0",
|
||||
|
||||
"-f", "flv",
|
||||
self.rtmp_url,
|
||||
]
|
||||
|
||||
print(f"[CameraController] starting FFmpeg: {' '.join(cmd)}", file=sys.stderr)
|
||||
|
||||
try:
|
||||
# 不再丢弃日志,至少能看到 ffmpeg 报错(调试很关键)
|
||||
self._ffmpeg_process = subprocess.Popen(
|
||||
cmd,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=sys.stderr,
|
||||
shell=False,
|
||||
)
|
||||
except Exception as e:
|
||||
self._ffmpeg_process = None
|
||||
print(f"[CameraController] failed to start FFmpeg: {e}", file=sys.stderr)
|
||||
|
||||
def _stop_ffmpeg(self):
|
||||
proc = self._ffmpeg_process
|
||||
if proc and proc.poll() is None:
|
||||
try:
|
||||
proc.terminate()
|
||||
try:
|
||||
proc.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
proc.kill()
|
||||
except Exception as e:
|
||||
print(f"[CameraController] error stopping FFmpeg: {e}", file=sys.stderr)
|
||||
self._ffmpeg_process = None
|
||||
|
||||
# ---------------------------------------------------------------------
|
||||
# WebRTC offer -> SRS
|
||||
# ---------------------------------------------------------------------
|
||||
|
||||
async def _handle_webrtc_offer(self, offer_sdp: str) -> str:
|
||||
payload = {
|
||||
"api": self.webrtc_api,
|
||||
"streamurl": self.webrtc_stream_url,
|
||||
"sdp": offer_sdp,
|
||||
}
|
||||
headers = {"Content-Type": "application/json"}
|
||||
|
||||
def _do_post():
|
||||
return requests.post(self.webrtc_api, json=payload, headers=headers, timeout=10)
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
resp = await loop.run_in_executor(None, _do_post)
|
||||
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
answer_sdp = data.get("sdp", "")
|
||||
if not answer_sdp:
|
||||
raise RuntimeError(f"empty SDP from media server: {data}")
|
||||
return answer_sdp
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# 直接运行用于手动测试
|
||||
c = CameraController(
|
||||
host_id="demo-host",
|
||||
video_device="/dev/video0",
|
||||
width=1280,
|
||||
height=720,
|
||||
fps=30,
|
||||
video_bitrate="1500k",
|
||||
audio_device=None,
|
||||
)
|
||||
try:
|
||||
while True:
|
||||
asyncio.sleep(1)
|
||||
except KeyboardInterrupt:
|
||||
c.stop()
|
||||
51
unilabos/devices/cameraSII/cameraUSB_test.py
Normal file
51
unilabos/devices/cameraSII/cameraUSB_test.py
Normal file
@@ -0,0 +1,51 @@
|
||||
#!/usr/bin/env python3
|
||||
import time
|
||||
import json
|
||||
|
||||
from cameraUSB import CameraController
|
||||
|
||||
|
||||
def main():
|
||||
# 按你的实际情况改
|
||||
cfg = dict(
|
||||
host_id="demo-host",
|
||||
signal_backend_url="wss://sciol.ac.cn/api/realtime/signal/host",
|
||||
rtmp_url="rtmp://srs.sciol.ac.cn:4499/live/camera-01",
|
||||
webrtc_api="https://srs.sciol.ac.cn/rtc/v1/play/",
|
||||
webrtc_stream_url="webrtc://srs.sciol.ac.cn:4500/live/camera-01",
|
||||
video_device="/dev/video7",
|
||||
width=1280,
|
||||
height=720,
|
||||
fps=30,
|
||||
video_bitrate="1500k",
|
||||
audio_device=None,
|
||||
)
|
||||
|
||||
c = CameraController(**cfg)
|
||||
|
||||
# 可选:如果你不想依赖 __init__ 自动 start,可以这样显式调用:
|
||||
# c = CameraController(host_id=cfg["host_id"])
|
||||
# c.start(cfg)
|
||||
|
||||
run_seconds = 30 # 测试运行时长
|
||||
t0 = time.time()
|
||||
|
||||
try:
|
||||
while True:
|
||||
st = c.get_status()
|
||||
print(json.dumps(st, ensure_ascii=False, indent=2))
|
||||
|
||||
if time.time() - t0 >= run_seconds:
|
||||
break
|
||||
|
||||
time.sleep(2)
|
||||
except KeyboardInterrupt:
|
||||
print("Interrupted, stopping...")
|
||||
finally:
|
||||
print("Stopping controller...")
|
||||
c.stop()
|
||||
print("Done.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
36
unilabos/devices/cameraSII/demo_camera_pic.py
Normal file
36
unilabos/devices/cameraSII/demo_camera_pic.py
Normal file
@@ -0,0 +1,36 @@
|
||||
import cv2
|
||||
|
||||
# 推荐把 @ 进行 URL 编码:@ -> %40
|
||||
RTSP_URL = "rtsp://admin:admin123@192.168.31.164:554/stream1"
|
||||
OUTPUT_IMAGE = "rtsp_test_frame.jpg"
|
||||
|
||||
def main():
|
||||
print(f"尝试连接 RTSP 流: {RTSP_URL}")
|
||||
cap = cv2.VideoCapture(RTSP_URL)
|
||||
|
||||
if not cap.isOpened():
|
||||
print("错误:无法打开 RTSP 流,请检查:")
|
||||
print(" 1. IP/端口是否正确")
|
||||
print(" 2. 账号密码(尤其是 @ 是否已转成 %40)是否正确")
|
||||
print(" 3. 摄像头是否允许当前主机访问(同一网段、防火墙等)")
|
||||
return
|
||||
|
||||
print("连接成功,开始读取一帧...")
|
||||
ret, frame = cap.read()
|
||||
|
||||
if not ret or frame is None:
|
||||
print("错误:已连接但未能读取到帧数据(可能是码流未开启或网络抖动)")
|
||||
cap.release()
|
||||
return
|
||||
|
||||
# 保存当前帧
|
||||
success = cv2.imwrite(OUTPUT_IMAGE, frame)
|
||||
cap.release()
|
||||
|
||||
if success:
|
||||
print(f"成功截取一帧并保存为: {OUTPUT_IMAGE}")
|
||||
else:
|
||||
print("错误:写入图片失败,请检查磁盘权限/路径")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
21
unilabos/devices/cameraSII/demo_camera_push.py
Normal file
21
unilabos/devices/cameraSII/demo_camera_push.py
Normal file
@@ -0,0 +1,21 @@
|
||||
# run_camera_push.py
|
||||
import time
|
||||
from cameraDriver import CameraController # 这里根据你的文件名调整
|
||||
|
||||
if __name__ == "__main__":
|
||||
controller = CameraController(
|
||||
host_id="demo-host",
|
||||
signal_backend_url="wss://sciol.ac.cn/api/realtime/signal/host",
|
||||
rtmp_url="rtmp://srs.sciol.ac.cn:4499/live/camera-01",
|
||||
webrtc_api="https://srs.sciol.ac.cn/rtc/v1/play/",
|
||||
webrtc_stream_url="webrtc://srs.sciol.ac.cn:4500/live/camera-01",
|
||||
camera_rtsp_url="rtsp://admin:admin123@192.168.31.164:554/stream1",
|
||||
)
|
||||
|
||||
try:
|
||||
while True:
|
||||
status = controller.get_status()
|
||||
print(status)
|
||||
time.sleep(5)
|
||||
except KeyboardInterrupt:
|
||||
controller.stop()
|
||||
78
unilabos/devices/cameraSII/ptz_cameracontroller_test.py
Normal file
78
unilabos/devices/cameraSII/ptz_cameracontroller_test.py
Normal file
@@ -0,0 +1,78 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
"""
|
||||
使用 CameraController 来测试 PTZ:
|
||||
让摄像头按顺序向下、向上、向左、向右运动几次。
|
||||
"""
|
||||
|
||||
import time
|
||||
import sys
|
||||
|
||||
# 根据你的工程结构修改导入路径:
|
||||
# 假设 CameraController 定义在 cameraController.py 里
|
||||
from cameraDriver import CameraController
|
||||
|
||||
|
||||
def main():
|
||||
# === 根据你的实际情况填 IP、端口、账号密码 ===
|
||||
ptz_host = "192.168.31.164"
|
||||
ptz_port = 2020 # 注意要和你单独测试 PTZController 时保持一致
|
||||
ptz_user = "admin"
|
||||
ptz_password = "admin123"
|
||||
|
||||
# 1. 创建 CameraController 实例
|
||||
cam = CameraController(
|
||||
# 其他摄像机相关参数按你类的 __init__ 来补充
|
||||
ptz_host=ptz_host,
|
||||
ptz_port=ptz_port,
|
||||
ptz_user=ptz_user,
|
||||
ptz_password=ptz_password,
|
||||
)
|
||||
|
||||
# 2. 启动 / 初始化(如果你的 CameraController 有 start(config) 之类的接口)
|
||||
# 这里给一个最小的 config,重点是 PTZ 相关字段
|
||||
config = {
|
||||
"ptz_host": ptz_host,
|
||||
"ptz_port": ptz_port,
|
||||
"ptz_user": ptz_user,
|
||||
"ptz_password": ptz_password,
|
||||
}
|
||||
|
||||
try:
|
||||
cam.start(config)
|
||||
except Exception as e:
|
||||
print(f"[TEST] CameraController start() 失败: {e}", file=sys.stderr)
|
||||
return
|
||||
|
||||
# 这里可以判断一下内部 _ptz 是否初始化成功(如果你对 CameraController 做了封装)
|
||||
if getattr(cam, "_ptz", None) is None:
|
||||
print("[TEST] CameraController 内部 PTZ 未初始化成功,请检查 ptz_host/port/user/password 配置。", file=sys.stderr)
|
||||
return
|
||||
|
||||
# 3. 依次调用 CameraController 的 PTZ 方法
|
||||
# 这里假设你在 CameraController 中提供了这几个对外方法:
|
||||
# ptz_move_down / ptz_move_up / ptz_move_left / ptz_move_right
|
||||
# 如果你命名不一样,把下面调用名改成你的即可。
|
||||
|
||||
print("向下移动(通过 CameraController)...")
|
||||
cam.ptz_move_down(speed=0.5, duration=1.0)
|
||||
time.sleep(1)
|
||||
|
||||
print("向上移动(通过 CameraController)...")
|
||||
cam.ptz_move_up(speed=0.5, duration=1.0)
|
||||
time.sleep(1)
|
||||
|
||||
print("向左移动(通过 CameraController)...")
|
||||
cam.ptz_move_left(speed=0.5, duration=1.0)
|
||||
time.sleep(1)
|
||||
|
||||
print("向右移动(通过 CameraController)...")
|
||||
cam.ptz_move_right(speed=0.5, duration=1.0)
|
||||
time.sleep(1)
|
||||
|
||||
print("测试结束。")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
50
unilabos/devices/cameraSII/ptz_test.py
Normal file
50
unilabos/devices/cameraSII/ptz_test.py
Normal file
@@ -0,0 +1,50 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
"""
|
||||
测试 cameraDriver.py中的 PTZController 类,让摄像头按顺序运动几次
|
||||
"""
|
||||
|
||||
import time
|
||||
|
||||
from cameraDriver import PTZController
|
||||
|
||||
|
||||
def main():
|
||||
# 根据你的实际情况填 IP、端口、账号密码
|
||||
host = "192.168.31.164"
|
||||
port = 80
|
||||
user = "admin"
|
||||
password = "admin123"
|
||||
|
||||
ptz = PTZController(host=host, port=port, user=user, password=password)
|
||||
|
||||
# 1. 连接摄像头
|
||||
if not ptz.connect():
|
||||
print("连接 PTZ 失败,检查 IP/用户名/密码/端口。")
|
||||
return
|
||||
|
||||
# 2. 依次测试几个动作
|
||||
# 每个动作之间 sleep 一下方便观察
|
||||
|
||||
print("向下移动...")
|
||||
ptz.move_down(speed=0.5, duration=1.0)
|
||||
time.sleep(1)
|
||||
|
||||
print("向上移动...")
|
||||
ptz.move_up(speed=0.5, duration=1.0)
|
||||
time.sleep(1)
|
||||
|
||||
print("向左移动...")
|
||||
ptz.move_left(speed=0.5, duration=1.0)
|
||||
time.sleep(1)
|
||||
|
||||
print("向右移动...")
|
||||
ptz.move_right(speed=0.5, duration=1.0)
|
||||
time.sleep(1)
|
||||
|
||||
print("测试结束。")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,307 +0,0 @@
|
||||
"""
|
||||
LaiYu_Liquid 液体处理工作站集成模块
|
||||
|
||||
该模块提供了 LaiYu_Liquid 工作站与 UniLabOS 的完整集成,包括:
|
||||
- 硬件后端和抽象接口
|
||||
- 资源定义和管理
|
||||
- 协议执行和液体传输
|
||||
- 工作台配置和布局
|
||||
|
||||
主要组件:
|
||||
- LaiYuLiquidBackend: 硬件后端实现
|
||||
- LaiYuLiquid: 液体处理器抽象接口
|
||||
- 各种资源类:枪头架、板、容器等
|
||||
- 便捷创建函数和配置管理
|
||||
|
||||
使用示例:
|
||||
from unilabos.devices.laiyu_liquid import (
|
||||
LaiYuLiquid,
|
||||
LaiYuLiquidBackend,
|
||||
create_standard_deck,
|
||||
create_tip_rack_1000ul
|
||||
)
|
||||
|
||||
# 创建后端和液体处理器
|
||||
backend = LaiYuLiquidBackend()
|
||||
lh = LaiYuLiquid(backend=backend)
|
||||
|
||||
# 创建工作台
|
||||
deck = create_standard_deck()
|
||||
lh.deck = deck
|
||||
|
||||
# 设置和运行
|
||||
await lh.setup()
|
||||
"""
|
||||
|
||||
# 版本信息
|
||||
__version__ = "1.0.0"
|
||||
__author__ = "LaiYu_Liquid Integration Team"
|
||||
__description__ = "LaiYu_Liquid 液体处理工作站 UniLabOS 集成模块"
|
||||
|
||||
# 驱动程序导入
|
||||
from .drivers import (
|
||||
XYZStepperController,
|
||||
SOPAPipette,
|
||||
MotorAxis,
|
||||
MotorStatus,
|
||||
SOPAConfig,
|
||||
SOPAStatusCode,
|
||||
StepperMotorDriver
|
||||
)
|
||||
|
||||
# 控制器导入
|
||||
from .controllers import (
|
||||
XYZController,
|
||||
PipetteController,
|
||||
)
|
||||
|
||||
# 后端导入
|
||||
from .backend.rviz_backend import (
|
||||
LiquidHandlerRvizBackend,
|
||||
)
|
||||
|
||||
# 资源类和创建函数导入
|
||||
from .core.laiyu_liquid_res import (
|
||||
LaiYuLiquidDeck,
|
||||
LaiYuLiquidContainer,
|
||||
LaiYuLiquidTipRack
|
||||
)
|
||||
|
||||
# 主设备类和配置
|
||||
from .core.laiyu_liquid_main import (
|
||||
LaiYuLiquid,
|
||||
LaiYuLiquidConfig,
|
||||
LaiYuLiquidDeck,
|
||||
LaiYuLiquidContainer,
|
||||
LaiYuLiquidTipRack,
|
||||
create_quick_setup
|
||||
)
|
||||
|
||||
# 后端创建函数导入
|
||||
from .backend import (
|
||||
LaiYuLiquidBackend,
|
||||
create_laiyu_backend,
|
||||
)
|
||||
|
||||
# 导出所有公共接口
|
||||
__all__ = [
|
||||
# 版本信息
|
||||
"__version__",
|
||||
"__author__",
|
||||
"__description__",
|
||||
|
||||
# 驱动程序
|
||||
"SOPAPipette",
|
||||
"SOPAConfig",
|
||||
"StepperMotorDriver",
|
||||
"XYZStepperController",
|
||||
|
||||
# 控制器
|
||||
"PipetteController",
|
||||
"XYZController",
|
||||
|
||||
# 后端
|
||||
"LiquidHandlerRvizBackend",
|
||||
|
||||
# 资源创建函数
|
||||
"create_tip_rack_1000ul",
|
||||
"create_tip_rack_200ul",
|
||||
"create_96_well_plate",
|
||||
"create_deep_well_plate",
|
||||
"create_8_tube_rack",
|
||||
"create_standard_deck",
|
||||
"create_waste_container",
|
||||
"create_wash_container",
|
||||
"create_reagent_container",
|
||||
"load_deck_config",
|
||||
|
||||
# 后端创建函数
|
||||
"create_laiyu_backend",
|
||||
|
||||
# 主要类
|
||||
"LaiYuLiquid",
|
||||
"LaiYuLiquidConfig",
|
||||
"LaiYuLiquidBackend",
|
||||
"LaiYuLiquidDeck",
|
||||
|
||||
# 工具函数
|
||||
"get_version",
|
||||
"get_supported_resources",
|
||||
"create_quick_setup",
|
||||
"validate_installation",
|
||||
"print_module_info",
|
||||
"setup_logging",
|
||||
]
|
||||
|
||||
# 别名定义,为了向后兼容
|
||||
LaiYuLiquidDevice = LaiYuLiquid # 主设备类别名
|
||||
LaiYuLiquidController = XYZController # 控制器别名
|
||||
LaiYuLiquidDriver = XYZStepperController # 驱动器别名
|
||||
|
||||
# 模块级别的便捷函数
|
||||
|
||||
def get_version() -> str:
|
||||
"""
|
||||
获取模块版本
|
||||
|
||||
Returns:
|
||||
str: 版本号
|
||||
"""
|
||||
return __version__
|
||||
|
||||
|
||||
def get_supported_resources() -> dict:
|
||||
"""
|
||||
获取支持的资源类型
|
||||
|
||||
Returns:
|
||||
dict: 支持的资源类型字典
|
||||
"""
|
||||
return {
|
||||
"tip_racks": {
|
||||
"LaiYuLiquidTipRack": LaiYuLiquidTipRack,
|
||||
},
|
||||
"containers": {
|
||||
"LaiYuLiquidContainer": LaiYuLiquidContainer,
|
||||
},
|
||||
"decks": {
|
||||
"LaiYuLiquidDeck": LaiYuLiquidDeck,
|
||||
},
|
||||
"devices": {
|
||||
"LaiYuLiquid": LaiYuLiquid,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def create_quick_setup() -> tuple:
|
||||
"""
|
||||
快速创建基本设置
|
||||
|
||||
Returns:
|
||||
tuple: (backend, controllers, resources) 的元组
|
||||
"""
|
||||
# 创建后端
|
||||
backend = LiquidHandlerRvizBackend()
|
||||
|
||||
# 创建控制器(使用默认端口进行演示)
|
||||
pipette_controller = PipetteController(port="/dev/ttyUSB0", address=4)
|
||||
xyz_controller = XYZController(port="/dev/ttyUSB1", auto_connect=False)
|
||||
|
||||
# 创建测试资源
|
||||
tip_rack_1000 = create_tip_rack_1000ul("tip_rack_1000")
|
||||
tip_rack_200 = create_tip_rack_200ul("tip_rack_200")
|
||||
well_plate = create_96_well_plate("96_well_plate")
|
||||
|
||||
controllers = {
|
||||
'pipette': pipette_controller,
|
||||
'xyz': xyz_controller
|
||||
}
|
||||
|
||||
resources = {
|
||||
'tip_rack_1000': tip_rack_1000,
|
||||
'tip_rack_200': tip_rack_200,
|
||||
'well_plate': well_plate
|
||||
}
|
||||
|
||||
return backend, controllers, resources
|
||||
|
||||
|
||||
def validate_installation() -> bool:
|
||||
"""
|
||||
验证模块安装是否正确
|
||||
|
||||
Returns:
|
||||
bool: 安装是否正确
|
||||
"""
|
||||
try:
|
||||
# 检查核心类是否可以导入
|
||||
from .core.laiyu_liquid_main import LaiYuLiquid, LaiYuLiquidConfig
|
||||
from .backend import LaiYuLiquidBackend
|
||||
from .controllers import XYZController, PipetteController
|
||||
from .drivers import XYZStepperController, SOPAPipette
|
||||
|
||||
# 尝试创建基本对象
|
||||
config = LaiYuLiquidConfig()
|
||||
backend = create_laiyu_backend("validation_test")
|
||||
|
||||
print("模块安装验证成功")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"模块安装验证失败: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def print_module_info():
|
||||
"""打印模块信息"""
|
||||
print(f"LaiYu_Liquid 集成模块")
|
||||
print(f"版本: {__version__}")
|
||||
print(f"作者: {__author__}")
|
||||
print(f"描述: {__description__}")
|
||||
print(f"")
|
||||
print(f"支持的资源类型:")
|
||||
|
||||
resources = get_supported_resources()
|
||||
for category, types in resources.items():
|
||||
print(f" {category}:")
|
||||
for type_name, type_class in types.items():
|
||||
print(f" - {type_name}: {type_class.__name__}")
|
||||
|
||||
print(f"")
|
||||
print(f"主要功能:")
|
||||
print(f" - 硬件集成: LaiYuLiquidBackend")
|
||||
print(f" - 抽象接口: LaiYuLiquid")
|
||||
print(f" - 资源管理: 各种资源类和创建函数")
|
||||
print(f" - 协议执行: transfer_liquid 和相关函数")
|
||||
print(f" - 配置管理: deck.json 和加载函数")
|
||||
|
||||
|
||||
# 模块初始化时的检查
|
||||
def _check_dependencies():
|
||||
"""检查依赖项"""
|
||||
try:
|
||||
import pylabrobot
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
return True
|
||||
except ImportError as e:
|
||||
import logging
|
||||
logging.warning(f"缺少依赖项 {e}")
|
||||
return False
|
||||
|
||||
|
||||
# 执行依赖检查
|
||||
_dependencies_ok = _check_dependencies()
|
||||
|
||||
if not _dependencies_ok:
|
||||
import logging
|
||||
logging.warning("某些依赖项缺失,模块功能可能受限")
|
||||
|
||||
|
||||
# 模块级别的日志配置
|
||||
import logging
|
||||
|
||||
def setup_logging(level: str = "INFO"):
|
||||
"""
|
||||
设置模块日志
|
||||
|
||||
Args:
|
||||
level: 日志级别 (DEBUG, INFO, WARNING, ERROR)
|
||||
"""
|
||||
logger = logging.getLogger("LaiYu_Liquid")
|
||||
logger.setLevel(getattr(logging, level.upper()))
|
||||
|
||||
if not logger.handlers:
|
||||
handler = logging.StreamHandler()
|
||||
formatter = logging.Formatter(
|
||||
'%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
||||
)
|
||||
handler.setFormatter(formatter)
|
||||
logger.addHandler(handler)
|
||||
|
||||
return logger
|
||||
|
||||
|
||||
# 默认日志设置
|
||||
_logger = setup_logging()
|
||||
@@ -1,9 +0,0 @@
|
||||
"""
|
||||
LaiYu液体处理设备后端模块
|
||||
|
||||
提供设备后端接口和实现
|
||||
"""
|
||||
|
||||
from .laiyu_backend import LaiYuLiquidBackend, create_laiyu_backend
|
||||
|
||||
__all__ = ['LaiYuLiquidBackend', 'create_laiyu_backend']
|
||||
@@ -1,334 +0,0 @@
|
||||
"""
|
||||
LaiYu液体处理设备后端实现
|
||||
|
||||
提供设备的后端接口和控制逻辑
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Dict, Any, Optional, List
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
# 尝试导入PyLabRobot后端
|
||||
try:
|
||||
from pylabrobot.liquid_handling.backends import LiquidHandlerBackend
|
||||
PYLABROBOT_AVAILABLE = True
|
||||
except ImportError:
|
||||
PYLABROBOT_AVAILABLE = False
|
||||
# 创建模拟后端基类
|
||||
class LiquidHandlerBackend:
|
||||
def __init__(self, name: str):
|
||||
self.name = name
|
||||
self.is_connected = False
|
||||
|
||||
def connect(self):
|
||||
"""连接设备"""
|
||||
pass
|
||||
|
||||
def disconnect(self):
|
||||
"""断开连接"""
|
||||
pass
|
||||
|
||||
|
||||
class LaiYuLiquidBackend(LiquidHandlerBackend):
|
||||
"""LaiYu液体处理设备后端"""
|
||||
|
||||
def __init__(self, name: str = "LaiYu_Liquid_Backend"):
|
||||
"""
|
||||
初始化LaiYu液体处理设备后端
|
||||
|
||||
Args:
|
||||
name: 后端名称
|
||||
"""
|
||||
if PYLABROBOT_AVAILABLE:
|
||||
# PyLabRobot 的 LiquidHandlerBackend 不接受参数
|
||||
super().__init__()
|
||||
else:
|
||||
# 模拟版本接受 name 参数
|
||||
super().__init__(name)
|
||||
|
||||
self.name = name
|
||||
self.logger = logging.getLogger(__name__)
|
||||
self.is_connected = False
|
||||
self.device_info = {
|
||||
"name": "LaiYu液体处理设备",
|
||||
"version": "1.0.0",
|
||||
"manufacturer": "LaiYu",
|
||||
"model": "LaiYu_Liquid_Handler"
|
||||
}
|
||||
|
||||
def connect(self) -> bool:
|
||||
"""
|
||||
连接到LaiYu液体处理设备
|
||||
|
||||
Returns:
|
||||
bool: 连接是否成功
|
||||
"""
|
||||
try:
|
||||
self.logger.info("正在连接到LaiYu液体处理设备...")
|
||||
# 这里应该实现实际的设备连接逻辑
|
||||
# 目前返回模拟连接成功
|
||||
self.is_connected = True
|
||||
self.logger.info("成功连接到LaiYu液体处理设备")
|
||||
return True
|
||||
except Exception as e:
|
||||
self.logger.error(f"连接LaiYu液体处理设备失败: {e}")
|
||||
self.is_connected = False
|
||||
return False
|
||||
|
||||
def disconnect(self) -> bool:
|
||||
"""
|
||||
断开与LaiYu液体处理设备的连接
|
||||
|
||||
Returns:
|
||||
bool: 断开连接是否成功
|
||||
"""
|
||||
try:
|
||||
self.logger.info("正在断开与LaiYu液体处理设备的连接...")
|
||||
# 这里应该实现实际的设备断开连接逻辑
|
||||
self.is_connected = False
|
||||
self.logger.info("成功断开与LaiYu液体处理设备的连接")
|
||||
return True
|
||||
except Exception as e:
|
||||
self.logger.error(f"断开LaiYu液体处理设备连接失败: {e}")
|
||||
return False
|
||||
|
||||
def is_device_connected(self) -> bool:
|
||||
"""
|
||||
检查设备是否已连接
|
||||
|
||||
Returns:
|
||||
bool: 设备是否已连接
|
||||
"""
|
||||
return self.is_connected
|
||||
|
||||
def get_device_info(self) -> Dict[str, Any]:
|
||||
"""
|
||||
获取设备信息
|
||||
|
||||
Returns:
|
||||
Dict[str, Any]: 设备信息字典
|
||||
"""
|
||||
return self.device_info.copy()
|
||||
|
||||
def home_device(self) -> bool:
|
||||
"""
|
||||
设备归零操作
|
||||
|
||||
Returns:
|
||||
bool: 归零是否成功
|
||||
"""
|
||||
if not self.is_connected:
|
||||
self.logger.error("设备未连接,无法执行归零操作")
|
||||
return False
|
||||
|
||||
try:
|
||||
self.logger.info("正在执行设备归零操作...")
|
||||
# 这里应该实现实际的设备归零逻辑
|
||||
self.logger.info("设备归零操作完成")
|
||||
return True
|
||||
except Exception as e:
|
||||
self.logger.error(f"设备归零操作失败: {e}")
|
||||
return False
|
||||
|
||||
def aspirate(self, volume: float, location: Dict[str, Any]) -> bool:
|
||||
"""
|
||||
吸液操作
|
||||
|
||||
Args:
|
||||
volume: 吸液体积 (微升)
|
||||
location: 吸液位置信息
|
||||
|
||||
Returns:
|
||||
bool: 吸液是否成功
|
||||
"""
|
||||
if not self.is_connected:
|
||||
self.logger.error("设备未连接,无法执行吸液操作")
|
||||
return False
|
||||
|
||||
try:
|
||||
self.logger.info(f"正在执行吸液操作: 体积={volume}μL, 位置={location}")
|
||||
# 这里应该实现实际的吸液逻辑
|
||||
self.logger.info("吸液操作完成")
|
||||
return True
|
||||
except Exception as e:
|
||||
self.logger.error(f"吸液操作失败: {e}")
|
||||
return False
|
||||
|
||||
def dispense(self, volume: float, location: Dict[str, Any]) -> bool:
|
||||
"""
|
||||
排液操作
|
||||
|
||||
Args:
|
||||
volume: 排液体积 (微升)
|
||||
location: 排液位置信息
|
||||
|
||||
Returns:
|
||||
bool: 排液是否成功
|
||||
"""
|
||||
if not self.is_connected:
|
||||
self.logger.error("设备未连接,无法执行排液操作")
|
||||
return False
|
||||
|
||||
try:
|
||||
self.logger.info(f"正在执行排液操作: 体积={volume}μL, 位置={location}")
|
||||
# 这里应该实现实际的排液逻辑
|
||||
self.logger.info("排液操作完成")
|
||||
return True
|
||||
except Exception as e:
|
||||
self.logger.error(f"排液操作失败: {e}")
|
||||
return False
|
||||
|
||||
def pick_up_tip(self, location: Dict[str, Any]) -> bool:
|
||||
"""
|
||||
取枪头操作
|
||||
|
||||
Args:
|
||||
location: 枪头位置信息
|
||||
|
||||
Returns:
|
||||
bool: 取枪头是否成功
|
||||
"""
|
||||
if not self.is_connected:
|
||||
self.logger.error("设备未连接,无法执行取枪头操作")
|
||||
return False
|
||||
|
||||
try:
|
||||
self.logger.info(f"正在执行取枪头操作: 位置={location}")
|
||||
# 这里应该实现实际的取枪头逻辑
|
||||
self.logger.info("取枪头操作完成")
|
||||
return True
|
||||
except Exception as e:
|
||||
self.logger.error(f"取枪头操作失败: {e}")
|
||||
return False
|
||||
|
||||
def drop_tip(self, location: Dict[str, Any]) -> bool:
|
||||
"""
|
||||
丢弃枪头操作
|
||||
|
||||
Args:
|
||||
location: 丢弃位置信息
|
||||
|
||||
Returns:
|
||||
bool: 丢弃枪头是否成功
|
||||
"""
|
||||
if not self.is_connected:
|
||||
self.logger.error("设备未连接,无法执行丢弃枪头操作")
|
||||
return False
|
||||
|
||||
try:
|
||||
self.logger.info(f"正在执行丢弃枪头操作: 位置={location}")
|
||||
# 这里应该实现实际的丢弃枪头逻辑
|
||||
self.logger.info("丢弃枪头操作完成")
|
||||
return True
|
||||
except Exception as e:
|
||||
self.logger.error(f"丢弃枪头操作失败: {e}")
|
||||
return False
|
||||
|
||||
def move_to(self, location: Dict[str, Any]) -> bool:
|
||||
"""
|
||||
移动到指定位置
|
||||
|
||||
Args:
|
||||
location: 目标位置信息
|
||||
|
||||
Returns:
|
||||
bool: 移动是否成功
|
||||
"""
|
||||
if not self.is_connected:
|
||||
self.logger.error("设备未连接,无法执行移动操作")
|
||||
return False
|
||||
|
||||
try:
|
||||
self.logger.info(f"正在移动到位置: {location}")
|
||||
# 这里应该实现实际的移动逻辑
|
||||
self.logger.info("移动操作完成")
|
||||
return True
|
||||
except Exception as e:
|
||||
self.logger.error(f"移动操作失败: {e}")
|
||||
return False
|
||||
|
||||
def get_status(self) -> Dict[str, Any]:
|
||||
"""
|
||||
获取设备状态
|
||||
|
||||
Returns:
|
||||
Dict[str, Any]: 设备状态信息
|
||||
"""
|
||||
return {
|
||||
"connected": self.is_connected,
|
||||
"device_info": self.device_info,
|
||||
"status": "ready" if self.is_connected else "disconnected"
|
||||
}
|
||||
|
||||
# PyLabRobot 抽象方法实现
|
||||
def stop(self):
|
||||
"""停止所有操作"""
|
||||
self.logger.info("停止所有操作")
|
||||
pass
|
||||
|
||||
@property
|
||||
def num_channels(self) -> int:
|
||||
"""返回通道数量"""
|
||||
return 1 # 单通道移液器
|
||||
|
||||
def can_pick_up_tip(self, tip_rack, tip_position) -> bool:
|
||||
"""检查是否可以拾取吸头"""
|
||||
return True # 简化实现,总是返回True
|
||||
|
||||
def pick_up_tips(self, tip_rack, tip_positions):
|
||||
"""拾取多个吸头"""
|
||||
self.logger.info(f"拾取吸头: {tip_positions}")
|
||||
pass
|
||||
|
||||
def drop_tips(self, tip_rack, tip_positions):
|
||||
"""丢弃多个吸头"""
|
||||
self.logger.info(f"丢弃吸头: {tip_positions}")
|
||||
pass
|
||||
|
||||
def pick_up_tips96(self, tip_rack):
|
||||
"""拾取96个吸头"""
|
||||
self.logger.info("拾取96个吸头")
|
||||
pass
|
||||
|
||||
def drop_tips96(self, tip_rack):
|
||||
"""丢弃96个吸头"""
|
||||
self.logger.info("丢弃96个吸头")
|
||||
pass
|
||||
|
||||
def aspirate96(self, volume, plate, well_positions):
|
||||
"""96通道吸液"""
|
||||
self.logger.info(f"96通道吸液: 体积={volume}")
|
||||
pass
|
||||
|
||||
def dispense96(self, volume, plate, well_positions):
|
||||
"""96通道排液"""
|
||||
self.logger.info(f"96通道排液: 体积={volume}")
|
||||
pass
|
||||
|
||||
def pick_up_resource(self, resource, location):
|
||||
"""拾取资源"""
|
||||
self.logger.info(f"拾取资源: {resource}")
|
||||
pass
|
||||
|
||||
def drop_resource(self, resource, location):
|
||||
"""放置资源"""
|
||||
self.logger.info(f"放置资源: {resource}")
|
||||
pass
|
||||
|
||||
def move_picked_up_resource(self, resource, location):
|
||||
"""移动已拾取的资源"""
|
||||
self.logger.info(f"移动资源: {resource} 到 {location}")
|
||||
pass
|
||||
|
||||
|
||||
def create_laiyu_backend(name: str = "LaiYu_Liquid_Backend") -> LaiYuLiquidBackend:
|
||||
"""
|
||||
创建LaiYu液体处理设备后端实例
|
||||
|
||||
Args:
|
||||
name: 后端名称
|
||||
|
||||
Returns:
|
||||
LaiYuLiquidBackend: 后端实例
|
||||
"""
|
||||
return LaiYuLiquidBackend(name)
|
||||
@@ -1,209 +0,0 @@
|
||||
|
||||
import json
|
||||
from typing import List, Optional, Union
|
||||
|
||||
from pylabrobot.liquid_handling.backends.backend import (
|
||||
LiquidHandlerBackend,
|
||||
)
|
||||
from pylabrobot.liquid_handling.standard import (
|
||||
Drop,
|
||||
DropTipRack,
|
||||
MultiHeadAspirationContainer,
|
||||
MultiHeadAspirationPlate,
|
||||
MultiHeadDispenseContainer,
|
||||
MultiHeadDispensePlate,
|
||||
Pickup,
|
||||
PickupTipRack,
|
||||
ResourceDrop,
|
||||
ResourceMove,
|
||||
ResourcePickup,
|
||||
SingleChannelAspiration,
|
||||
SingleChannelDispense,
|
||||
)
|
||||
from pylabrobot.resources import Resource, Tip
|
||||
|
||||
import rclpy
|
||||
from rclpy.node import Node
|
||||
from sensor_msgs.msg import JointState
|
||||
import time
|
||||
from rclpy.action import ActionClient
|
||||
from unilabos_msgs.action import SendCmd
|
||||
import re
|
||||
|
||||
from unilabos.devices.ros_dev.liquid_handler_joint_publisher import JointStatePublisher
|
||||
|
||||
|
||||
class LiquidHandlerRvizBackend(LiquidHandlerBackend):
|
||||
"""Chatter box backend for device-free testing. Prints out all operations."""
|
||||
|
||||
_pip_length = 5
|
||||
_vol_length = 8
|
||||
_resource_length = 20
|
||||
_offset_length = 16
|
||||
_flow_rate_length = 10
|
||||
_blowout_length = 10
|
||||
_lld_z_length = 10
|
||||
_kwargs_length = 15
|
||||
_tip_type_length = 12
|
||||
_max_volume_length = 16
|
||||
_fitting_depth_length = 20
|
||||
_tip_length_length = 16
|
||||
# _pickup_method_length = 20
|
||||
_filter_length = 10
|
||||
|
||||
def __init__(self, num_channels: int = 8):
|
||||
"""Initialize a chatter box backend."""
|
||||
super().__init__()
|
||||
self._num_channels = num_channels
|
||||
# rclpy.init()
|
||||
if not rclpy.ok():
|
||||
rclpy.init()
|
||||
self.joint_state_publisher = None
|
||||
|
||||
async def setup(self):
|
||||
self.joint_state_publisher = JointStatePublisher()
|
||||
await super().setup()
|
||||
async def stop(self):
|
||||
pass
|
||||
|
||||
def serialize(self) -> dict:
|
||||
return {**super().serialize(), "num_channels": self.num_channels}
|
||||
|
||||
@property
|
||||
def num_channels(self) -> int:
|
||||
return self._num_channels
|
||||
|
||||
async def assigned_resource_callback(self, resource: Resource):
|
||||
pass
|
||||
|
||||
async def unassigned_resource_callback(self, name: str):
|
||||
pass
|
||||
|
||||
async def pick_up_tips(self, ops: List[Pickup], use_channels: List[int], **backend_kwargs):
|
||||
|
||||
for op, channel in zip(ops, use_channels):
|
||||
offset = f"{round(op.offset.x, 1)},{round(op.offset.y, 1)},{round(op.offset.z, 1)}"
|
||||
row = (
|
||||
f" p{channel}: "
|
||||
f"{op.resource.name[-30:]:<{LiquidHandlerRvizBackend._resource_length}} "
|
||||
f"{offset:<{LiquidHandlerRvizBackend._offset_length}} "
|
||||
f"{op.tip.__class__.__name__:<{LiquidHandlerRvizBackend._tip_type_length}} "
|
||||
f"{op.tip.maximal_volume:<{LiquidHandlerRvizBackend._max_volume_length}} "
|
||||
f"{op.tip.fitting_depth:<{LiquidHandlerRvizBackend._fitting_depth_length}} "
|
||||
f"{op.tip.total_tip_length:<{LiquidHandlerRvizBackend._tip_length_length}} "
|
||||
# f"{str(op.tip.pickup_method)[-20:]:<{ChatterboxBackend._pickup_method_length}} "
|
||||
f"{'Yes' if op.tip.has_filter else 'No':<{LiquidHandlerRvizBackend._filter_length}}"
|
||||
)
|
||||
coordinate = ops[0].resource.get_absolute_location(x="c",y="c")
|
||||
x = coordinate.x
|
||||
y = coordinate.y
|
||||
z = coordinate.z + 70
|
||||
self.joint_state_publisher.send_resource_action(ops[0].resource.name, x, y, z, "pick")
|
||||
# goback()
|
||||
|
||||
|
||||
|
||||
|
||||
async def drop_tips(self, ops: List[Drop], use_channels: List[int], **backend_kwargs):
|
||||
|
||||
coordinate = ops[0].resource.get_absolute_location(x="c",y="c")
|
||||
x = coordinate.x
|
||||
y = coordinate.y
|
||||
z = coordinate.z + 70
|
||||
self.joint_state_publisher.send_resource_action(ops[0].resource.name, x, y, z, "drop_trash")
|
||||
# goback()
|
||||
|
||||
async def aspirate(
|
||||
self,
|
||||
ops: List[SingleChannelAspiration],
|
||||
use_channels: List[int],
|
||||
**backend_kwargs,
|
||||
):
|
||||
# 执行吸液操作
|
||||
pass
|
||||
|
||||
for o, p in zip(ops, use_channels):
|
||||
offset = f"{round(o.offset.x, 1)},{round(o.offset.y, 1)},{round(o.offset.z, 1)}"
|
||||
row = (
|
||||
f" p{p}: "
|
||||
f"{o.volume:<{LiquidHandlerRvizBackend._vol_length}} "
|
||||
f"{o.resource.name[-20:]:<{LiquidHandlerRvizBackend._resource_length}} "
|
||||
f"{offset:<{LiquidHandlerRvizBackend._offset_length}} "
|
||||
f"{str(o.flow_rate):<{LiquidHandlerRvizBackend._flow_rate_length}} "
|
||||
f"{str(o.blow_out_air_volume):<{LiquidHandlerRvizBackend._blowout_length}} "
|
||||
f"{str(o.liquid_height):<{LiquidHandlerRvizBackend._lld_z_length}} "
|
||||
# f"{o.liquids if o.liquids is not None else 'none'}"
|
||||
)
|
||||
for key, value in backend_kwargs.items():
|
||||
if isinstance(value, list) and all(isinstance(v, bool) for v in value):
|
||||
value = "".join("T" if v else "F" for v in value)
|
||||
if isinstance(value, list):
|
||||
value = "".join(map(str, value))
|
||||
row += f" {value:<15}"
|
||||
coordinate = ops[0].resource.get_absolute_location(x="c",y="c")
|
||||
x = coordinate.x
|
||||
y = coordinate.y
|
||||
z = coordinate.z + 70
|
||||
self.joint_state_publisher.send_resource_action(ops[0].resource.name, x, y, z, "")
|
||||
|
||||
|
||||
async def dispense(
|
||||
self,
|
||||
ops: List[SingleChannelDispense],
|
||||
use_channels: List[int],
|
||||
**backend_kwargs,
|
||||
):
|
||||
|
||||
for o, p in zip(ops, use_channels):
|
||||
offset = f"{round(o.offset.x, 1)},{round(o.offset.y, 1)},{round(o.offset.z, 1)}"
|
||||
row = (
|
||||
f" p{p}: "
|
||||
f"{o.volume:<{LiquidHandlerRvizBackend._vol_length}} "
|
||||
f"{o.resource.name[-20:]:<{LiquidHandlerRvizBackend._resource_length}} "
|
||||
f"{offset:<{LiquidHandlerRvizBackend._offset_length}} "
|
||||
f"{str(o.flow_rate):<{LiquidHandlerRvizBackend._flow_rate_length}} "
|
||||
f"{str(o.blow_out_air_volume):<{LiquidHandlerRvizBackend._blowout_length}} "
|
||||
f"{str(o.liquid_height):<{LiquidHandlerRvizBackend._lld_z_length}} "
|
||||
# f"{o.liquids if o.liquids is not None else 'none'}"
|
||||
)
|
||||
for key, value in backend_kwargs.items():
|
||||
if isinstance(value, list) and all(isinstance(v, bool) for v in value):
|
||||
value = "".join("T" if v else "F" for v in value)
|
||||
if isinstance(value, list):
|
||||
value = "".join(map(str, value))
|
||||
row += f" {value:<{LiquidHandlerRvizBackend._kwargs_length}}"
|
||||
coordinate = ops[0].resource.get_absolute_location(x="c",y="c")
|
||||
x = coordinate.x
|
||||
y = coordinate.y
|
||||
z = coordinate.z + 70
|
||||
self.joint_state_publisher.send_resource_action(ops[0].resource.name, x, y, z, "")
|
||||
|
||||
async def pick_up_tips96(self, pickup: PickupTipRack, **backend_kwargs):
|
||||
pass
|
||||
|
||||
async def drop_tips96(self, drop: DropTipRack, **backend_kwargs):
|
||||
pass
|
||||
|
||||
async def aspirate96(
|
||||
self, aspiration: Union[MultiHeadAspirationPlate, MultiHeadAspirationContainer]
|
||||
):
|
||||
pass
|
||||
|
||||
async def dispense96(self, dispense: Union[MultiHeadDispensePlate, MultiHeadDispenseContainer]):
|
||||
pass
|
||||
|
||||
async def pick_up_resource(self, pickup: ResourcePickup):
|
||||
# 执行资源拾取操作
|
||||
pass
|
||||
|
||||
async def move_picked_up_resource(self, move: ResourceMove):
|
||||
# 执行资源移动操作
|
||||
pass
|
||||
|
||||
async def drop_resource(self, drop: ResourceDrop):
|
||||
# 执行资源放置操作
|
||||
pass
|
||||
|
||||
def can_pick_up_tip(self, channel_idx: int, tip: Tip) -> bool:
|
||||
return True
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,14 +0,0 @@
|
||||
goto 171 178 57 H1
|
||||
goto 171 117 57 A1
|
||||
goto 172 178 130
|
||||
goto 173 179 133
|
||||
goto 173 180 133
|
||||
goto 173 180 138
|
||||
goto 173 180 125 (+10mm,在空的上面边缘)
|
||||
goto 173 180 130 取不到
|
||||
goto 173 180 133 取不到
|
||||
goto 173 180 135
|
||||
goto 173 180 137 取到了!!!!
|
||||
goto 173 180 131 弹出枪头 H1
|
||||
|
||||
goto 173 117 137 A1 (+10mm,可以取到新枪头了!!!!)
|
||||
@@ -1,25 +0,0 @@
|
||||
"""
|
||||
LaiYu_Liquid 控制器模块
|
||||
|
||||
该模块包含了LaiYu_Liquid液体处理工作站的高级控制器:
|
||||
- 移液器控制器:提供液体处理的高级接口
|
||||
- XYZ运动控制器:提供三轴运动的高级接口
|
||||
"""
|
||||
|
||||
# 移液器控制器导入
|
||||
from .pipette_controller import PipetteController
|
||||
|
||||
# XYZ运动控制器导入
|
||||
from .xyz_controller import XYZController
|
||||
|
||||
__all__ = [
|
||||
# 移液器控制器
|
||||
"PipetteController",
|
||||
|
||||
# XYZ运动控制器
|
||||
"XYZController",
|
||||
]
|
||||
|
||||
__version__ = "1.0.0"
|
||||
__author__ = "LaiYu_Liquid Controller Team"
|
||||
__description__ = "LaiYu_Liquid 高级控制器集合"
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,44 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
LaiYu液体处理设备核心模块
|
||||
|
||||
该模块包含LaiYu液体处理设备的核心功能组件:
|
||||
- LaiYu_Liquid.py: 主设备类和配置管理
|
||||
- abstract_protocol.py: 抽象协议定义
|
||||
- laiyu_liquid_res.py: 设备资源管理
|
||||
|
||||
作者: UniLab团队
|
||||
版本: 2.0.0
|
||||
"""
|
||||
|
||||
from .laiyu_liquid_main import (
|
||||
LaiYuLiquid,
|
||||
LaiYuLiquidConfig,
|
||||
LaiYuLiquidBackend,
|
||||
LaiYuLiquidDeck,
|
||||
LaiYuLiquidContainer,
|
||||
LaiYuLiquidTipRack,
|
||||
create_quick_setup
|
||||
)
|
||||
|
||||
from .laiyu_liquid_res import (
|
||||
LaiYuLiquidDeck,
|
||||
LaiYuLiquidContainer,
|
||||
LaiYuLiquidTipRack
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
# 主设备类
|
||||
'LaiYuLiquid',
|
||||
'LaiYuLiquidConfig',
|
||||
'LaiYuLiquidBackend',
|
||||
|
||||
# 设备资源
|
||||
'LaiYuLiquidDeck',
|
||||
'LaiYuLiquidContainer',
|
||||
'LaiYuLiquidTipRack',
|
||||
|
||||
# 工具函数
|
||||
'create_quick_setup'
|
||||
]
|
||||
@@ -1,529 +0,0 @@
|
||||
"""
|
||||
LaiYu_Liquid 抽象协议实现
|
||||
|
||||
该模块提供了液体资源管理和转移的抽象协议,包括:
|
||||
- MaterialResource: 液体资源管理类
|
||||
- transfer_liquid: 液体转移函数
|
||||
- 相关的辅助类和函数
|
||||
|
||||
主要功能:
|
||||
- 管理多孔位的液体资源
|
||||
- 计算和跟踪液体体积
|
||||
- 处理液体转移操作
|
||||
- 提供资源状态查询
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Dict, List, Optional, Union, Any, Tuple
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
import uuid
|
||||
import time
|
||||
|
||||
# pylabrobot 导入
|
||||
from pylabrobot.resources import Resource, Well, Plate
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class LiquidType(Enum):
|
||||
"""液体类型枚举"""
|
||||
WATER = "water"
|
||||
ETHANOL = "ethanol"
|
||||
DMSO = "dmso"
|
||||
BUFFER = "buffer"
|
||||
SAMPLE = "sample"
|
||||
REAGENT = "reagent"
|
||||
WASTE = "waste"
|
||||
UNKNOWN = "unknown"
|
||||
|
||||
|
||||
@dataclass
|
||||
class LiquidInfo:
|
||||
"""液体信息类"""
|
||||
liquid_type: LiquidType = LiquidType.UNKNOWN
|
||||
volume: float = 0.0 # 体积 (μL)
|
||||
concentration: Optional[float] = None # 浓度 (mg/ml, M等)
|
||||
ph: Optional[float] = None # pH值
|
||||
temperature: Optional[float] = None # 温度 (°C)
|
||||
viscosity: Optional[float] = None # 粘度 (cP)
|
||||
density: Optional[float] = None # 密度 (g/ml)
|
||||
description: str = "" # 描述信息
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.liquid_type.value}({self.description})"
|
||||
|
||||
|
||||
@dataclass
|
||||
class WellContent:
|
||||
"""孔位内容类"""
|
||||
volume: float = 0.0 # 当前体积 (ul)
|
||||
max_volume: float = 1000.0 # 最大容量 (ul)
|
||||
liquid_info: LiquidInfo = field(default_factory=LiquidInfo)
|
||||
last_updated: float = field(default_factory=time.time)
|
||||
|
||||
@property
|
||||
def is_empty(self) -> bool:
|
||||
"""检查是否为空"""
|
||||
return self.volume <= 0.0
|
||||
|
||||
@property
|
||||
def is_full(self) -> bool:
|
||||
"""检查是否已满"""
|
||||
return self.volume >= self.max_volume
|
||||
|
||||
@property
|
||||
def available_volume(self) -> float:
|
||||
"""可用体积"""
|
||||
return max(0.0, self.max_volume - self.volume)
|
||||
|
||||
@property
|
||||
def fill_percentage(self) -> float:
|
||||
"""填充百分比"""
|
||||
return (self.volume / self.max_volume) * 100.0 if self.max_volume > 0 else 0.0
|
||||
|
||||
def can_add_volume(self, volume: float) -> bool:
|
||||
"""检查是否可以添加指定体积"""
|
||||
return (self.volume + volume) <= self.max_volume
|
||||
|
||||
def can_remove_volume(self, volume: float) -> bool:
|
||||
"""检查是否可以移除指定体积"""
|
||||
return self.volume >= volume
|
||||
|
||||
def add_volume(self, volume: float, liquid_info: Optional[LiquidInfo] = None) -> bool:
|
||||
"""
|
||||
添加液体体积
|
||||
|
||||
Args:
|
||||
volume: 要添加的体积 (ul)
|
||||
liquid_info: 液体信息
|
||||
|
||||
Returns:
|
||||
bool: 是否成功添加
|
||||
"""
|
||||
if not self.can_add_volume(volume):
|
||||
return False
|
||||
|
||||
self.volume += volume
|
||||
if liquid_info:
|
||||
self.liquid_info = liquid_info
|
||||
self.last_updated = time.time()
|
||||
return True
|
||||
|
||||
def remove_volume(self, volume: float) -> bool:
|
||||
"""
|
||||
移除液体体积
|
||||
|
||||
Args:
|
||||
volume: 要移除的体积 (ul)
|
||||
|
||||
Returns:
|
||||
bool: 是否成功移除
|
||||
"""
|
||||
if not self.can_remove_volume(volume):
|
||||
return False
|
||||
|
||||
self.volume -= volume
|
||||
self.last_updated = time.time()
|
||||
|
||||
# 如果完全清空,重置液体信息
|
||||
if self.volume <= 0.0:
|
||||
self.volume = 0.0
|
||||
self.liquid_info = LiquidInfo()
|
||||
|
||||
return True
|
||||
|
||||
|
||||
class MaterialResource:
|
||||
"""
|
||||
液体资源管理类
|
||||
|
||||
该类用于管理液体处理过程中的资源状态,包括:
|
||||
- 跟踪多个孔位的液体体积和类型
|
||||
- 计算总体积和可用体积
|
||||
- 处理液体的添加和移除
|
||||
- 提供资源状态查询
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
resource: Resource,
|
||||
wells: Optional[List[Well]] = None,
|
||||
default_max_volume: float = 1000.0
|
||||
):
|
||||
"""
|
||||
初始化材料资源
|
||||
|
||||
Args:
|
||||
resource: pylabrobot 资源对象
|
||||
wells: 孔位列表,如果为None则自动获取
|
||||
default_max_volume: 默认最大体积 (ul)
|
||||
"""
|
||||
self.resource = resource
|
||||
self.resource_id = str(uuid.uuid4())
|
||||
self.default_max_volume = default_max_volume
|
||||
|
||||
# 获取孔位列表
|
||||
if wells is None:
|
||||
if hasattr(resource, 'get_wells'):
|
||||
self.wells = resource.get_wells()
|
||||
elif hasattr(resource, 'wells'):
|
||||
self.wells = resource.wells
|
||||
else:
|
||||
# 如果没有孔位,创建一个虚拟孔位
|
||||
self.wells = [resource]
|
||||
else:
|
||||
self.wells = wells
|
||||
|
||||
# 初始化孔位内容
|
||||
self.well_contents: Dict[str, WellContent] = {}
|
||||
for well in self.wells:
|
||||
well_id = self._get_well_id(well)
|
||||
self.well_contents[well_id] = WellContent(
|
||||
max_volume=default_max_volume
|
||||
)
|
||||
|
||||
logger.info(f"初始化材料资源: {resource.name}, 孔位数: {len(self.wells)}")
|
||||
|
||||
def _get_well_id(self, well: Union[Well, Resource]) -> str:
|
||||
"""获取孔位ID"""
|
||||
if hasattr(well, 'name'):
|
||||
return well.name
|
||||
else:
|
||||
return str(id(well))
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
"""资源名称"""
|
||||
return self.resource.name
|
||||
|
||||
@property
|
||||
def total_volume(self) -> float:
|
||||
"""总液体体积"""
|
||||
return sum(content.volume for content in self.well_contents.values())
|
||||
|
||||
@property
|
||||
def total_max_volume(self) -> float:
|
||||
"""总最大容量"""
|
||||
return sum(content.max_volume for content in self.well_contents.values())
|
||||
|
||||
@property
|
||||
def available_volume(self) -> float:
|
||||
"""总可用体积"""
|
||||
return sum(content.available_volume for content in self.well_contents.values())
|
||||
|
||||
@property
|
||||
def well_count(self) -> int:
|
||||
"""孔位数量"""
|
||||
return len(self.wells)
|
||||
|
||||
@property
|
||||
def empty_wells(self) -> List[str]:
|
||||
"""空孔位列表"""
|
||||
return [well_id for well_id, content in self.well_contents.items()
|
||||
if content.is_empty]
|
||||
|
||||
@property
|
||||
def full_wells(self) -> List[str]:
|
||||
"""满孔位列表"""
|
||||
return [well_id for well_id, content in self.well_contents.items()
|
||||
if content.is_full]
|
||||
|
||||
@property
|
||||
def occupied_wells(self) -> List[str]:
|
||||
"""有液体的孔位列表"""
|
||||
return [well_id for well_id, content in self.well_contents.items()
|
||||
if not content.is_empty]
|
||||
|
||||
def get_well_content(self, well_id: str) -> Optional[WellContent]:
|
||||
"""获取指定孔位的内容"""
|
||||
return self.well_contents.get(well_id)
|
||||
|
||||
def get_well_volume(self, well_id: str) -> float:
|
||||
"""获取指定孔位的体积"""
|
||||
content = self.get_well_content(well_id)
|
||||
return content.volume if content else 0.0
|
||||
|
||||
def set_well_volume(
|
||||
self,
|
||||
well_id: str,
|
||||
volume: float,
|
||||
liquid_info: Optional[LiquidInfo] = None
|
||||
) -> bool:
|
||||
"""
|
||||
设置指定孔位的体积
|
||||
|
||||
Args:
|
||||
well_id: 孔位ID
|
||||
volume: 体积 (ul)
|
||||
liquid_info: 液体信息
|
||||
|
||||
Returns:
|
||||
bool: 是否成功设置
|
||||
"""
|
||||
if well_id not in self.well_contents:
|
||||
logger.error(f"孔位 {well_id} 不存在")
|
||||
return False
|
||||
|
||||
content = self.well_contents[well_id]
|
||||
if volume > content.max_volume:
|
||||
logger.error(f"体积 {volume} 超过最大容量 {content.max_volume}")
|
||||
return False
|
||||
|
||||
content.volume = max(0.0, volume)
|
||||
if liquid_info:
|
||||
content.liquid_info = liquid_info
|
||||
content.last_updated = time.time()
|
||||
|
||||
logger.info(f"设置孔位 {well_id} 体积: {volume}ul")
|
||||
return True
|
||||
|
||||
def add_liquid(
|
||||
self,
|
||||
well_id: str,
|
||||
volume: float,
|
||||
liquid_info: Optional[LiquidInfo] = None
|
||||
) -> bool:
|
||||
"""
|
||||
向指定孔位添加液体
|
||||
|
||||
Args:
|
||||
well_id: 孔位ID
|
||||
volume: 添加的体积 (ul)
|
||||
liquid_info: 液体信息
|
||||
|
||||
Returns:
|
||||
bool: 是否成功添加
|
||||
"""
|
||||
if well_id not in self.well_contents:
|
||||
logger.error(f"孔位 {well_id} 不存在")
|
||||
return False
|
||||
|
||||
content = self.well_contents[well_id]
|
||||
success = content.add_volume(volume, liquid_info)
|
||||
|
||||
if success:
|
||||
logger.info(f"向孔位 {well_id} 添加 {volume}ul 液体")
|
||||
else:
|
||||
logger.error(f"无法向孔位 {well_id} 添加 {volume}ul 液体")
|
||||
|
||||
return success
|
||||
|
||||
def remove_liquid(self, well_id: str, volume: float) -> bool:
|
||||
"""
|
||||
从指定孔位移除液体
|
||||
|
||||
Args:
|
||||
well_id: 孔位ID
|
||||
volume: 移除的体积 (ul)
|
||||
|
||||
Returns:
|
||||
bool: 是否成功移除
|
||||
"""
|
||||
if well_id not in self.well_contents:
|
||||
logger.error(f"孔位 {well_id} 不存在")
|
||||
return False
|
||||
|
||||
content = self.well_contents[well_id]
|
||||
success = content.remove_volume(volume)
|
||||
|
||||
if success:
|
||||
logger.info(f"从孔位 {well_id} 移除 {volume}ul 液体")
|
||||
else:
|
||||
logger.error(f"无法从孔位 {well_id} 移除 {volume}ul 液体")
|
||||
|
||||
return success
|
||||
|
||||
def find_wells_with_volume(self, min_volume: float) -> List[str]:
|
||||
"""
|
||||
查找具有指定最小体积的孔位
|
||||
|
||||
Args:
|
||||
min_volume: 最小体积 (ul)
|
||||
|
||||
Returns:
|
||||
List[str]: 符合条件的孔位ID列表
|
||||
"""
|
||||
return [well_id for well_id, content in self.well_contents.items()
|
||||
if content.volume >= min_volume]
|
||||
|
||||
def find_wells_with_space(self, min_space: float) -> List[str]:
|
||||
"""
|
||||
查找具有指定最小空间的孔位
|
||||
|
||||
Args:
|
||||
min_space: 最小空间 (ul)
|
||||
|
||||
Returns:
|
||||
List[str]: 符合条件的孔位ID列表
|
||||
"""
|
||||
return [well_id for well_id, content in self.well_contents.items()
|
||||
if content.available_volume >= min_space]
|
||||
|
||||
def get_status_summary(self) -> Dict[str, Any]:
|
||||
"""获取资源状态摘要"""
|
||||
return {
|
||||
"resource_name": self.name,
|
||||
"resource_id": self.resource_id,
|
||||
"well_count": self.well_count,
|
||||
"total_volume": self.total_volume,
|
||||
"total_max_volume": self.total_max_volume,
|
||||
"available_volume": self.available_volume,
|
||||
"fill_percentage": (self.total_volume / self.total_max_volume) * 100.0,
|
||||
"empty_wells": len(self.empty_wells),
|
||||
"full_wells": len(self.full_wells),
|
||||
"occupied_wells": len(self.occupied_wells)
|
||||
}
|
||||
|
||||
def get_detailed_status(self) -> Dict[str, Any]:
|
||||
"""获取详细状态信息"""
|
||||
well_details = {}
|
||||
for well_id, content in self.well_contents.items():
|
||||
well_details[well_id] = {
|
||||
"volume": content.volume,
|
||||
"max_volume": content.max_volume,
|
||||
"available_volume": content.available_volume,
|
||||
"fill_percentage": content.fill_percentage,
|
||||
"liquid_type": content.liquid_info.liquid_type.value,
|
||||
"description": content.liquid_info.description,
|
||||
"last_updated": content.last_updated
|
||||
}
|
||||
|
||||
return {
|
||||
"summary": self.get_status_summary(),
|
||||
"wells": well_details
|
||||
}
|
||||
|
||||
|
||||
def transfer_liquid(
|
||||
source: MaterialResource,
|
||||
target: MaterialResource,
|
||||
volume: float,
|
||||
source_well_id: Optional[str] = None,
|
||||
target_well_id: Optional[str] = None,
|
||||
liquid_info: Optional[LiquidInfo] = None
|
||||
) -> bool:
|
||||
"""
|
||||
在两个材料资源之间转移液体
|
||||
|
||||
Args:
|
||||
source: 源资源
|
||||
target: 目标资源
|
||||
volume: 转移体积 (ul)
|
||||
source_well_id: 源孔位ID,如果为None则自动选择
|
||||
target_well_id: 目标孔位ID,如果为None则自动选择
|
||||
liquid_info: 液体信息
|
||||
|
||||
Returns:
|
||||
bool: 转移是否成功
|
||||
"""
|
||||
try:
|
||||
# 自动选择源孔位
|
||||
if source_well_id is None:
|
||||
available_wells = source.find_wells_with_volume(volume)
|
||||
if not available_wells:
|
||||
logger.error(f"源资源 {source.name} 没有足够体积的孔位")
|
||||
return False
|
||||
source_well_id = available_wells[0]
|
||||
|
||||
# 自动选择目标孔位
|
||||
if target_well_id is None:
|
||||
available_wells = target.find_wells_with_space(volume)
|
||||
if not available_wells:
|
||||
logger.error(f"目标资源 {target.name} 没有足够空间的孔位")
|
||||
return False
|
||||
target_well_id = available_wells[0]
|
||||
|
||||
# 检查源孔位是否有足够液体
|
||||
if not source.get_well_content(source_well_id).can_remove_volume(volume):
|
||||
logger.error(f"源孔位 {source_well_id} 液体不足")
|
||||
return False
|
||||
|
||||
# 检查目标孔位是否有足够空间
|
||||
if not target.get_well_content(target_well_id).can_add_volume(volume):
|
||||
logger.error(f"目标孔位 {target_well_id} 空间不足")
|
||||
return False
|
||||
|
||||
# 获取源液体信息
|
||||
source_content = source.get_well_content(source_well_id)
|
||||
transfer_liquid_info = liquid_info or source_content.liquid_info
|
||||
|
||||
# 执行转移
|
||||
if source.remove_liquid(source_well_id, volume):
|
||||
if target.add_liquid(target_well_id, volume, transfer_liquid_info):
|
||||
logger.info(f"成功转移 {volume}ul 液体: {source.name}[{source_well_id}] -> {target.name}[{target_well_id}]")
|
||||
return True
|
||||
else:
|
||||
# 如果目标添加失败,回滚源操作
|
||||
source.add_liquid(source_well_id, volume, source_content.liquid_info)
|
||||
logger.error("目标添加失败,已回滚源操作")
|
||||
return False
|
||||
else:
|
||||
logger.error("源移除失败")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"液体转移失败: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def create_material_resource(
|
||||
name: str,
|
||||
resource: Resource,
|
||||
initial_volumes: Optional[Dict[str, float]] = None,
|
||||
liquid_info: Optional[LiquidInfo] = None,
|
||||
max_volume: float = 1000.0
|
||||
) -> MaterialResource:
|
||||
"""
|
||||
创建材料资源的便捷函数
|
||||
|
||||
Args:
|
||||
name: 资源名称
|
||||
resource: pylabrobot 资源对象
|
||||
initial_volumes: 初始体积字典 {well_id: volume}
|
||||
liquid_info: 液体信息
|
||||
max_volume: 最大体积
|
||||
|
||||
Returns:
|
||||
MaterialResource: 创建的材料资源
|
||||
"""
|
||||
material_resource = MaterialResource(
|
||||
resource=resource,
|
||||
default_max_volume=max_volume
|
||||
)
|
||||
|
||||
# 设置初始体积
|
||||
if initial_volumes:
|
||||
for well_id, volume in initial_volumes.items():
|
||||
material_resource.set_well_volume(well_id, volume, liquid_info)
|
||||
|
||||
return material_resource
|
||||
|
||||
|
||||
def batch_transfer_liquid(
|
||||
transfers: List[Tuple[MaterialResource, MaterialResource, float]],
|
||||
liquid_info: Optional[LiquidInfo] = None
|
||||
) -> List[bool]:
|
||||
"""
|
||||
批量液体转移
|
||||
|
||||
Args:
|
||||
transfers: 转移列表 [(source, target, volume), ...]
|
||||
liquid_info: 液体信息
|
||||
|
||||
Returns:
|
||||
List[bool]: 每个转移操作的结果
|
||||
"""
|
||||
results = []
|
||||
|
||||
for source, target, volume in transfers:
|
||||
result = transfer_liquid(source, target, volume, liquid_info=liquid_info)
|
||||
results.append(result)
|
||||
|
||||
if not result:
|
||||
logger.warning(f"批量转移中的操作失败: {source.name} -> {target.name}")
|
||||
|
||||
success_count = sum(results)
|
||||
logger.info(f"批量转移完成: {success_count}/{len(transfers)} 成功")
|
||||
|
||||
return results
|
||||
@@ -1,888 +0,0 @@
|
||||
"""
|
||||
LaiYu_Liquid 液体处理工作站主要集成文件
|
||||
|
||||
该模块实现了 LaiYu_Liquid 与 UniLabOS 系统的集成,提供标准化的液体处理接口。
|
||||
主要包含:
|
||||
- LaiYuLiquidBackend: 硬件通信后端
|
||||
- LaiYuLiquid: 主要接口类
|
||||
- 相关的异常类和容器类
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from typing import List, Optional, Dict, Any, Union, Tuple
|
||||
from dataclasses import dataclass
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode
|
||||
|
||||
# 基础导入
|
||||
try:
|
||||
from pylabrobot.resources import Deck, Plate, TipRack, Tip, Resource, Well
|
||||
|
||||
PYLABROBOT_AVAILABLE = True
|
||||
except ImportError:
|
||||
# 如果 pylabrobot 不可用,创建基础的模拟类
|
||||
PYLABROBOT_AVAILABLE = False
|
||||
|
||||
class Resource:
|
||||
def __init__(self, name: str):
|
||||
self.name = name
|
||||
|
||||
class Deck(Resource):
|
||||
pass
|
||||
|
||||
class Plate(Resource):
|
||||
pass
|
||||
|
||||
class TipRack(Resource):
|
||||
pass
|
||||
|
||||
class Tip(Resource):
|
||||
pass
|
||||
|
||||
class Well(Resource):
|
||||
pass
|
||||
|
||||
|
||||
# LaiYu_Liquid 控制器导入
|
||||
try:
|
||||
from .controllers.pipette_controller import PipetteController, TipStatus, LiquidClass, LiquidParameters
|
||||
from .controllers.xyz_controller import XYZController, MachineConfig, CoordinateOrigin, MotorAxis
|
||||
|
||||
CONTROLLERS_AVAILABLE = True
|
||||
except ImportError:
|
||||
CONTROLLERS_AVAILABLE = False
|
||||
|
||||
# 创建模拟的控制器类
|
||||
class PipetteController:
|
||||
def __init__(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
def connect(self):
|
||||
return True
|
||||
|
||||
def initialize(self):
|
||||
return True
|
||||
|
||||
class XYZController:
|
||||
def __init__(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
def connect_device(self):
|
||||
return True
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class LaiYuLiquidError(RuntimeError):
|
||||
"""LaiYu_Liquid 设备异常"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
@dataclass
|
||||
class LaiYuLiquidConfig:
|
||||
"""LaiYu_Liquid 设备配置"""
|
||||
|
||||
port: str = "/dev/cu.usbserial-3130" # RS485转USB端口
|
||||
address: int = 1 # 设备地址
|
||||
baudrate: int = 9600 # 波特率
|
||||
timeout: float = 5.0 # 通信超时时间
|
||||
|
||||
# 工作台尺寸
|
||||
deck_width: float = 340.0 # 工作台宽度 (mm)
|
||||
deck_height: float = 250.0 # 工作台高度 (mm)
|
||||
deck_depth: float = 160.0 # 工作台深度 (mm)
|
||||
|
||||
# 移液参数
|
||||
max_volume: float = 1000.0 # 最大体积 (μL)
|
||||
min_volume: float = 0.1 # 最小体积 (μL)
|
||||
|
||||
# 运动参数
|
||||
max_speed: float = 100.0 # 最大速度 (mm/s)
|
||||
acceleration: float = 50.0 # 加速度 (mm/s²)
|
||||
|
||||
# 安全参数
|
||||
safe_height: float = 50.0 # 安全高度 (mm)
|
||||
tip_pickup_depth: float = 10.0 # 吸头拾取深度 (mm)
|
||||
liquid_detection: bool = True # 液面检测
|
||||
|
||||
# 取枪头相关参数
|
||||
tip_pickup_speed: int = 30 # 取枪头时的移动速度 (rpm)
|
||||
tip_pickup_acceleration: int = 500 # 取枪头时的加速度 (rpm/s)
|
||||
tip_approach_height: float = 5.0 # 接近枪头时的高度 (mm)
|
||||
tip_pickup_force_depth: float = 2.0 # 强制插入深度 (mm)
|
||||
tip_pickup_retract_height: float = 20.0 # 取枪头后的回退高度 (mm)
|
||||
|
||||
# 丢弃枪头相关参数
|
||||
tip_drop_height: float = 10.0 # 丢弃枪头时的高度 (mm)
|
||||
tip_drop_speed: int = 50 # 丢弃枪头时的移动速度 (rpm)
|
||||
trash_position: Tuple[float, float, float] = (300.0, 200.0, 0.0) # 垃圾桶位置 (mm)
|
||||
|
||||
# 安全范围配置
|
||||
deck_width: float = 300.0 # 工作台宽度 (mm)
|
||||
deck_height: float = 200.0 # 工作台高度 (mm)
|
||||
deck_depth: float = 100.0 # 工作台深度 (mm)
|
||||
safe_height: float = 50.0 # 安全高度 (mm)
|
||||
position_validation: bool = True # 启用位置验证
|
||||
emergency_stop_enabled: bool = True # 启用紧急停止
|
||||
|
||||
|
||||
class LaiYuLiquidDeck:
|
||||
"""LaiYu_Liquid 工作台管理"""
|
||||
|
||||
def __init__(self, config: LaiYuLiquidConfig):
|
||||
self.config = config
|
||||
self.resources: Dict[str, Resource] = {}
|
||||
self.positions: Dict[str, Tuple[float, float, float]] = {}
|
||||
|
||||
def add_resource(self, name: str, resource: Resource, position: Tuple[float, float, float]):
|
||||
"""添加资源到工作台"""
|
||||
self.resources[name] = resource
|
||||
self.positions[name] = position
|
||||
|
||||
def get_resource(self, name: str) -> Optional[Resource]:
|
||||
"""获取资源"""
|
||||
return self.resources.get(name)
|
||||
|
||||
def get_position(self, name: str) -> Optional[Tuple[float, float, float]]:
|
||||
"""获取资源位置"""
|
||||
return self.positions.get(name)
|
||||
|
||||
def list_resources(self) -> List[str]:
|
||||
"""列出所有资源"""
|
||||
return list(self.resources.keys())
|
||||
|
||||
|
||||
class LaiYuLiquidContainer:
|
||||
"""LaiYu_Liquid 容器类"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name: str,
|
||||
size_x: float = 0,
|
||||
size_y: float = 0,
|
||||
size_z: float = 0,
|
||||
container_type: str = "",
|
||||
volume: float = 0.0,
|
||||
max_volume: float = 1000.0,
|
||||
lid_height: float = 0.0,
|
||||
):
|
||||
self.name = name
|
||||
self.size_x = size_x
|
||||
self.size_y = size_y
|
||||
self.size_z = size_z
|
||||
self.lid_height = lid_height
|
||||
self.container_type = container_type
|
||||
self.volume = volume
|
||||
self.max_volume = max_volume
|
||||
self.last_updated = time.time()
|
||||
self.child_resources = {} # 存储子资源
|
||||
|
||||
@property
|
||||
def is_empty(self) -> bool:
|
||||
return self.volume <= 0.0
|
||||
|
||||
@property
|
||||
def is_full(self) -> bool:
|
||||
return self.volume >= self.max_volume
|
||||
|
||||
@property
|
||||
def available_volume(self) -> float:
|
||||
return max(0.0, self.max_volume - self.volume)
|
||||
|
||||
def add_volume(self, volume: float) -> bool:
|
||||
"""添加体积"""
|
||||
if self.volume + volume <= self.max_volume:
|
||||
self.volume += volume
|
||||
self.last_updated = time.time()
|
||||
return True
|
||||
return False
|
||||
|
||||
def remove_volume(self, volume: float) -> bool:
|
||||
"""移除体积"""
|
||||
if self.volume >= volume:
|
||||
self.volume -= volume
|
||||
self.last_updated = time.time()
|
||||
return True
|
||||
return False
|
||||
|
||||
def assign_child_resource(self, resource, location=None):
|
||||
"""分配子资源 - 与 PyLabRobot 资源管理系统兼容"""
|
||||
if hasattr(resource, "name"):
|
||||
self.child_resources[resource.name] = {"resource": resource, "location": location}
|
||||
|
||||
|
||||
class LaiYuLiquidTipRack:
|
||||
"""LaiYu_Liquid 吸头架类"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name: str,
|
||||
size_x: float = 0,
|
||||
size_y: float = 0,
|
||||
size_z: float = 0,
|
||||
tip_count: int = 96,
|
||||
tip_volume: float = 1000.0,
|
||||
):
|
||||
self.name = name
|
||||
self.size_x = size_x
|
||||
self.size_y = size_y
|
||||
self.size_z = size_z
|
||||
self.tip_count = tip_count
|
||||
self.tip_volume = tip_volume
|
||||
self.tips_available = [True] * tip_count
|
||||
self.child_resources = {} # 存储子资源
|
||||
|
||||
@property
|
||||
def available_tips(self) -> int:
|
||||
return sum(self.tips_available)
|
||||
|
||||
@property
|
||||
def is_empty(self) -> bool:
|
||||
return self.available_tips == 0
|
||||
|
||||
def pick_tip(self, position: int) -> bool:
|
||||
"""拾取吸头"""
|
||||
if 0 <= position < self.tip_count and self.tips_available[position]:
|
||||
self.tips_available[position] = False
|
||||
return True
|
||||
return False
|
||||
|
||||
def has_tip(self, position: int) -> bool:
|
||||
"""检查位置是否有吸头"""
|
||||
if 0 <= position < self.tip_count:
|
||||
return self.tips_available[position]
|
||||
return False
|
||||
|
||||
def assign_child_resource(self, resource, location=None):
|
||||
"""分配子资源到指定位置"""
|
||||
self.child_resources[resource.name] = {"resource": resource, "location": location}
|
||||
|
||||
|
||||
def get_module_info():
|
||||
"""获取模块信息"""
|
||||
return {
|
||||
"name": "LaiYu_Liquid",
|
||||
"version": "1.0.0",
|
||||
"description": "LaiYu液体处理工作站模块,提供移液器控制、XYZ轴控制和资源管理功能",
|
||||
"author": "UniLabOS Team",
|
||||
"capabilities": ["移液器控制", "XYZ轴运动控制", "吸头架管理", "板和容器管理", "资源位置管理"],
|
||||
"dependencies": {"required": ["serial"], "optional": ["pylabrobot"]},
|
||||
}
|
||||
|
||||
|
||||
class LaiYuLiquidBackend:
|
||||
"""LaiYu_Liquid 硬件通信后端"""
|
||||
|
||||
_ros_node: BaseROS2DeviceNode
|
||||
|
||||
def __init__(self, config: LaiYuLiquidConfig, deck: Optional["LaiYuLiquidDeck"] = None):
|
||||
self.config = config
|
||||
self.deck = deck # 工作台引用,用于获取资源位置信息
|
||||
self.pipette_controller = None
|
||||
self.xyz_controller = None
|
||||
self.is_connected = False
|
||||
self.is_initialized = False
|
||||
|
||||
# 状态跟踪
|
||||
self.current_position = (0.0, 0.0, 0.0)
|
||||
self.tip_attached = False
|
||||
self.current_volume = 0.0
|
||||
|
||||
def post_init(self, ros_node: BaseROS2DeviceNode):
|
||||
self._ros_node = ros_node
|
||||
|
||||
def _validate_position(self, x: float, y: float, z: float) -> bool:
|
||||
"""验证位置是否在安全范围内"""
|
||||
try:
|
||||
# 检查X轴范围
|
||||
if not (0 <= x <= self.config.deck_width):
|
||||
logger.error(f"X轴位置 {x:.2f}mm 超出范围 [0, {self.config.deck_width}]")
|
||||
return False
|
||||
|
||||
# 检查Y轴范围
|
||||
if not (0 <= y <= self.config.deck_height):
|
||||
logger.error(f"Y轴位置 {y:.2f}mm 超出范围 [0, {self.config.deck_height}]")
|
||||
return False
|
||||
|
||||
# 检查Z轴范围(负值表示向下,0为工作台表面)
|
||||
if not (-self.config.deck_depth <= z <= self.config.safe_height):
|
||||
logger.error(f"Z轴位置 {z:.2f}mm 超出安全范围 [{-self.config.deck_depth}, {self.config.safe_height}]")
|
||||
return False
|
||||
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"位置验证失败: {e}")
|
||||
return False
|
||||
|
||||
def _check_hardware_ready(self) -> bool:
|
||||
"""检查硬件是否准备就绪"""
|
||||
if not self.is_connected:
|
||||
logger.error("设备未连接")
|
||||
return False
|
||||
|
||||
if CONTROLLERS_AVAILABLE:
|
||||
if self.xyz_controller is None:
|
||||
logger.error("XYZ控制器未初始化")
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
async def emergency_stop(self) -> bool:
|
||||
"""紧急停止所有运动"""
|
||||
try:
|
||||
logger.warning("执行紧急停止")
|
||||
|
||||
if CONTROLLERS_AVAILABLE and self.xyz_controller:
|
||||
# 停止XYZ控制器
|
||||
await self.xyz_controller.stop_all_motion()
|
||||
logger.info("XYZ控制器已停止")
|
||||
|
||||
if self.pipette_controller:
|
||||
# 停止移液器控制器
|
||||
await self.pipette_controller.stop()
|
||||
logger.info("移液器控制器已停止")
|
||||
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"紧急停止失败: {e}")
|
||||
return False
|
||||
|
||||
async def move_to_safe_position(self) -> bool:
|
||||
"""移动到安全位置"""
|
||||
try:
|
||||
if not self._check_hardware_ready():
|
||||
return False
|
||||
|
||||
safe_position = (
|
||||
self.config.deck_width / 2, # 工作台中心X
|
||||
self.config.deck_height / 2, # 工作台中心Y
|
||||
self.config.safe_height, # 安全高度Z
|
||||
)
|
||||
|
||||
if not self._validate_position(*safe_position):
|
||||
logger.error("安全位置无效")
|
||||
return False
|
||||
|
||||
if CONTROLLERS_AVAILABLE and self.xyz_controller:
|
||||
await self.xyz_controller.move_to_work_coord(*safe_position)
|
||||
self.current_position = safe_position
|
||||
logger.info(f"已移动到安全位置: {safe_position}")
|
||||
return True
|
||||
else:
|
||||
# 模拟模式
|
||||
self.current_position = safe_position
|
||||
logger.info("模拟移动到安全位置")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"移动到安全位置失败: {e}")
|
||||
return False
|
||||
|
||||
async def setup(self) -> bool:
|
||||
"""设置硬件连接"""
|
||||
try:
|
||||
if CONTROLLERS_AVAILABLE:
|
||||
# 初始化移液器控制器
|
||||
self.pipette_controller = PipetteController(port=self.config.port, address=self.config.address)
|
||||
|
||||
# 初始化XYZ控制器
|
||||
machine_config = MachineConfig()
|
||||
self.xyz_controller = XYZController(
|
||||
port=self.config.port, baudrate=self.config.baudrate, machine_config=machine_config
|
||||
)
|
||||
|
||||
# 连接设备
|
||||
pipette_connected = await asyncio.to_thread(self.pipette_controller.connect)
|
||||
xyz_connected = await asyncio.to_thread(self.xyz_controller.connect_device)
|
||||
|
||||
if pipette_connected and xyz_connected:
|
||||
self.is_connected = True
|
||||
logger.info("LaiYu_Liquid 硬件连接成功")
|
||||
return True
|
||||
else:
|
||||
logger.error("LaiYu_Liquid 硬件连接失败")
|
||||
return False
|
||||
else:
|
||||
# 模拟模式
|
||||
logger.info("LaiYu_Liquid 运行在模拟模式")
|
||||
self.is_connected = True
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"LaiYu_Liquid 设置失败: {e}")
|
||||
return False
|
||||
|
||||
async def stop(self):
|
||||
"""停止设备"""
|
||||
try:
|
||||
if self.pipette_controller and hasattr(self.pipette_controller, "disconnect"):
|
||||
await asyncio.to_thread(self.pipette_controller.disconnect)
|
||||
|
||||
if self.xyz_controller and hasattr(self.xyz_controller, "disconnect"):
|
||||
await asyncio.to_thread(self.xyz_controller.disconnect)
|
||||
|
||||
self.is_connected = False
|
||||
self.is_initialized = False
|
||||
logger.info("LaiYu_Liquid 已停止")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"LaiYu_Liquid 停止失败: {e}")
|
||||
|
||||
async def move_to(self, x: float, y: float, z: float) -> bool:
|
||||
"""移动到指定位置"""
|
||||
try:
|
||||
if not self.is_connected:
|
||||
raise LaiYuLiquidError("设备未连接")
|
||||
|
||||
# 模拟移动
|
||||
await self._ros_node.sleep(0.1) # 模拟移动时间
|
||||
self.current_position = (x, y, z)
|
||||
logger.debug(f"移动到位置: ({x}, {y}, {z})")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"移动失败: {e}")
|
||||
return False
|
||||
|
||||
async def pick_up_tip(self, tip_rack: str, position: int) -> bool:
|
||||
"""拾取吸头 - 包含真正的Z轴下降控制"""
|
||||
try:
|
||||
# 硬件准备检查
|
||||
if not self._check_hardware_ready():
|
||||
return False
|
||||
|
||||
if self.tip_attached:
|
||||
logger.warning("已有吸头附着,无法拾取新吸头")
|
||||
return False
|
||||
|
||||
logger.info(f"开始从 {tip_rack} 位置 {position} 拾取吸头")
|
||||
|
||||
# 获取枪头架位置信息
|
||||
if self.deck is None:
|
||||
logger.error("工作台未初始化")
|
||||
return False
|
||||
|
||||
tip_position = self.deck.get_position(tip_rack)
|
||||
if tip_position is None:
|
||||
logger.error(f"未找到枪头架 {tip_rack} 的位置信息")
|
||||
return False
|
||||
|
||||
# 计算具体枪头位置(这里简化处理,实际应根据position计算偏移)
|
||||
tip_x, tip_y, tip_z = tip_position
|
||||
|
||||
# 验证所有关键位置的安全性
|
||||
safe_z = tip_z + self.config.tip_approach_height
|
||||
pickup_z = tip_z - self.config.tip_pickup_force_depth
|
||||
retract_z = tip_z + self.config.tip_pickup_retract_height
|
||||
|
||||
if not (
|
||||
self._validate_position(tip_x, tip_y, safe_z)
|
||||
and self._validate_position(tip_x, tip_y, pickup_z)
|
||||
and self._validate_position(tip_x, tip_y, retract_z)
|
||||
):
|
||||
logger.error("枪头拾取位置超出安全范围")
|
||||
return False
|
||||
|
||||
if CONTROLLERS_AVAILABLE and self.xyz_controller:
|
||||
# 真实硬件控制流程
|
||||
logger.info("使用真实XYZ控制器进行枪头拾取")
|
||||
|
||||
try:
|
||||
# 1. 移动到枪头上方的安全位置
|
||||
safe_z = tip_z + self.config.tip_approach_height
|
||||
logger.info(f"移动到枪头上方安全位置: ({tip_x:.2f}, {tip_y:.2f}, {safe_z:.2f})")
|
||||
move_success = await asyncio.to_thread(
|
||||
self.xyz_controller.move_to_work_coord, tip_x, tip_y, safe_z
|
||||
)
|
||||
if not move_success:
|
||||
logger.error("移动到枪头上方失败")
|
||||
return False
|
||||
|
||||
# 2. Z轴下降到枪头位置
|
||||
pickup_z = tip_z - self.config.tip_pickup_force_depth
|
||||
logger.info(f"Z轴下降到枪头拾取位置: {pickup_z:.2f}mm")
|
||||
z_down_success = await asyncio.to_thread(
|
||||
self.xyz_controller.move_to_work_coord, tip_x, tip_y, pickup_z
|
||||
)
|
||||
if not z_down_success:
|
||||
logger.error("Z轴下降到枪头位置失败")
|
||||
return False
|
||||
|
||||
# 3. 等待一小段时间确保枪头牢固附着
|
||||
await self._ros_node.sleep(0.2)
|
||||
|
||||
# 4. Z轴上升到回退高度
|
||||
retract_z = tip_z + self.config.tip_pickup_retract_height
|
||||
logger.info(f"Z轴上升到回退高度: {retract_z:.2f}mm")
|
||||
z_up_success = await asyncio.to_thread(
|
||||
self.xyz_controller.move_to_work_coord, tip_x, tip_y, retract_z
|
||||
)
|
||||
if not z_up_success:
|
||||
logger.error("Z轴上升失败")
|
||||
return False
|
||||
|
||||
# 5. 更新当前位置
|
||||
self.current_position = (tip_x, tip_y, retract_z)
|
||||
|
||||
except Exception as move_error:
|
||||
logger.error(f"枪头拾取过程中发生错误: {move_error}")
|
||||
# 尝试移动到安全位置
|
||||
if self.config.emergency_stop_enabled:
|
||||
await self.emergency_stop()
|
||||
await self.move_to_safe_position()
|
||||
return False
|
||||
|
||||
else:
|
||||
# 模拟模式
|
||||
logger.info("模拟模式:执行枪头拾取动作")
|
||||
await self._ros_node.sleep(1.0) # 模拟整个拾取过程的时间
|
||||
self.current_position = (tip_x, tip_y, tip_z + self.config.tip_pickup_retract_height)
|
||||
|
||||
# 6. 标记枪头已附着
|
||||
self.tip_attached = True
|
||||
logger.info("吸头拾取成功")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"拾取吸头失败: {e}")
|
||||
return False
|
||||
|
||||
async def drop_tip(self, location: str = "trash") -> bool:
|
||||
"""丢弃吸头 - 包含真正的Z轴控制"""
|
||||
try:
|
||||
# 硬件准备检查
|
||||
if not self._check_hardware_ready():
|
||||
return False
|
||||
|
||||
if not self.tip_attached:
|
||||
logger.warning("没有吸头附着,无需丢弃")
|
||||
return True
|
||||
|
||||
logger.info(f"开始丢弃吸头到 {location}")
|
||||
|
||||
# 确定丢弃位置
|
||||
if location == "trash":
|
||||
# 使用配置中的垃圾桶位置
|
||||
drop_x, drop_y, drop_z = self.config.trash_position
|
||||
else:
|
||||
# 尝试从deck获取指定位置
|
||||
if self.deck is None:
|
||||
logger.error("工作台未初始化")
|
||||
return False
|
||||
|
||||
drop_position = self.deck.get_position(location)
|
||||
if drop_position is None:
|
||||
logger.error(f"未找到丢弃位置 {location} 的信息")
|
||||
return False
|
||||
drop_x, drop_y, drop_z = drop_position
|
||||
|
||||
# 验证丢弃位置的安全性
|
||||
safe_z = drop_z + self.config.safe_height
|
||||
drop_height_z = drop_z + self.config.tip_drop_height
|
||||
|
||||
if not (
|
||||
self._validate_position(drop_x, drop_y, safe_z)
|
||||
and self._validate_position(drop_x, drop_y, drop_height_z)
|
||||
):
|
||||
logger.error("枪头丢弃位置超出安全范围")
|
||||
return False
|
||||
|
||||
if CONTROLLERS_AVAILABLE and self.xyz_controller:
|
||||
# 真实硬件控制流程
|
||||
logger.info("使用真实XYZ控制器进行枪头丢弃")
|
||||
|
||||
try:
|
||||
# 1. 移动到丢弃位置上方的安全高度
|
||||
safe_z = drop_z + self.config.tip_drop_height
|
||||
logger.info(f"移动到丢弃位置上方: ({drop_x:.2f}, {drop_y:.2f}, {safe_z:.2f})")
|
||||
move_success = await asyncio.to_thread(
|
||||
self.xyz_controller.move_to_work_coord, drop_x, drop_y, safe_z
|
||||
)
|
||||
if not move_success:
|
||||
logger.error("移动到丢弃位置上方失败")
|
||||
return False
|
||||
|
||||
# 2. Z轴下降到丢弃高度
|
||||
logger.info(f"Z轴下降到丢弃高度: {drop_z:.2f}mm")
|
||||
z_down_success = await asyncio.to_thread(
|
||||
self.xyz_controller.move_to_work_coord, drop_x, drop_y, drop_z
|
||||
)
|
||||
if not z_down_success:
|
||||
logger.error("Z轴下降到丢弃位置失败")
|
||||
return False
|
||||
|
||||
# 3. 执行枪头弹出动作(如果有移液器控制器)
|
||||
if self.pipette_controller:
|
||||
try:
|
||||
# 发送弹出枪头命令
|
||||
await asyncio.to_thread(self.pipette_controller.eject_tip)
|
||||
logger.info("执行枪头弹出命令")
|
||||
except Exception as e:
|
||||
logger.warning(f"枪头弹出命令失败: {e}")
|
||||
|
||||
# 4. 等待一小段时间确保枪头完全脱离
|
||||
await self._ros_node.sleep(0.3)
|
||||
|
||||
# 5. Z轴上升到安全高度
|
||||
logger.info(f"Z轴上升到安全高度: {safe_z:.2f}mm")
|
||||
z_up_success = await asyncio.to_thread(
|
||||
self.xyz_controller.move_to_work_coord, drop_x, drop_y, safe_z
|
||||
)
|
||||
if not z_up_success:
|
||||
logger.error("Z轴上升失败")
|
||||
return False
|
||||
|
||||
# 6. 更新当前位置
|
||||
self.current_position = (drop_x, drop_y, safe_z)
|
||||
|
||||
except Exception as drop_error:
|
||||
logger.error(f"枪头丢弃过程中发生错误: {drop_error}")
|
||||
# 尝试移动到安全位置
|
||||
if self.config.emergency_stop_enabled:
|
||||
await self.emergency_stop()
|
||||
await self.move_to_safe_position()
|
||||
return False
|
||||
|
||||
else:
|
||||
# 模拟模式
|
||||
logger.info("模拟模式:执行枪头丢弃动作")
|
||||
await self._ros_node.sleep(0.8) # 模拟整个丢弃过程的时间
|
||||
self.current_position = (drop_x, drop_y, drop_z + self.config.tip_drop_height)
|
||||
|
||||
# 7. 标记枪头已脱离,清空体积
|
||||
self.tip_attached = False
|
||||
self.current_volume = 0.0
|
||||
logger.info("吸头丢弃成功")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"丢弃吸头失败: {e}")
|
||||
return False
|
||||
|
||||
async def aspirate(self, volume: float, location: str) -> bool:
|
||||
"""吸取液体"""
|
||||
try:
|
||||
if not self.is_connected:
|
||||
raise LaiYuLiquidError("设备未连接")
|
||||
|
||||
if not self.tip_attached:
|
||||
raise LaiYuLiquidError("没有吸头附着")
|
||||
|
||||
if volume <= 0 or volume > self.config.max_volume:
|
||||
raise LaiYuLiquidError(f"体积超出范围: {volume}")
|
||||
|
||||
# 模拟吸取
|
||||
await self._ros_node.sleep(0.3)
|
||||
self.current_volume += volume
|
||||
logger.debug(f"从 {location} 吸取 {volume} μL")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"吸取失败: {e}")
|
||||
return False
|
||||
|
||||
async def dispense(self, volume: float, location: str) -> bool:
|
||||
"""分配液体"""
|
||||
try:
|
||||
if not self.is_connected:
|
||||
raise LaiYuLiquidError("设备未连接")
|
||||
|
||||
if not self.tip_attached:
|
||||
raise LaiYuLiquidError("没有吸头附着")
|
||||
|
||||
if volume <= 0 or volume > self.current_volume:
|
||||
raise LaiYuLiquidError(f"分配体积无效: {volume}")
|
||||
|
||||
# 模拟分配
|
||||
await self._ros_node.sleep(0.3)
|
||||
self.current_volume -= volume
|
||||
logger.debug(f"向 {location} 分配 {volume} μL")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"分配失败: {e}")
|
||||
return False
|
||||
|
||||
|
||||
class LaiYuLiquid:
|
||||
"""LaiYu_Liquid 主要接口类"""
|
||||
|
||||
def __init__(self, config: Optional[LaiYuLiquidConfig] = None, **kwargs):
|
||||
# 如果传入了关键字参数,创建配置对象
|
||||
if kwargs and config is None:
|
||||
# 从kwargs中提取配置参数
|
||||
config_params = {}
|
||||
for key, value in kwargs.items():
|
||||
if hasattr(LaiYuLiquidConfig, key):
|
||||
config_params[key] = value
|
||||
self.config = LaiYuLiquidConfig(**config_params)
|
||||
else:
|
||||
self.config = config or LaiYuLiquidConfig()
|
||||
|
||||
# 先创建deck,然后传递给backend
|
||||
self.deck = LaiYuLiquidDeck(self.config)
|
||||
self.backend = LaiYuLiquidBackend(self.config, self.deck)
|
||||
self.is_setup = False
|
||||
|
||||
@property
|
||||
def current_position(self) -> Tuple[float, float, float]:
|
||||
"""获取当前位置"""
|
||||
return self.backend.current_position
|
||||
|
||||
@property
|
||||
def current_volume(self) -> float:
|
||||
"""获取当前体积"""
|
||||
return self.backend.current_volume
|
||||
|
||||
@property
|
||||
def is_connected(self) -> bool:
|
||||
"""获取连接状态"""
|
||||
return self.backend.is_connected
|
||||
|
||||
@property
|
||||
def is_initialized(self) -> bool:
|
||||
"""获取初始化状态"""
|
||||
return self.backend.is_initialized
|
||||
|
||||
@property
|
||||
def tip_attached(self) -> bool:
|
||||
"""获取吸头附着状态"""
|
||||
return self.backend.tip_attached
|
||||
|
||||
async def setup(self) -> bool:
|
||||
"""设置液体处理器"""
|
||||
try:
|
||||
success = await self.backend.setup()
|
||||
if success:
|
||||
self.is_setup = True
|
||||
logger.info("LaiYu_Liquid 设置完成")
|
||||
return success
|
||||
except Exception as e:
|
||||
logger.error(f"LaiYu_Liquid 设置失败: {e}")
|
||||
return False
|
||||
|
||||
async def stop(self):
|
||||
"""停止液体处理器"""
|
||||
await self.backend.stop()
|
||||
self.is_setup = False
|
||||
|
||||
async def transfer(
|
||||
self, source: str, target: str, volume: float, tip_rack: str = "tip_rack_1", tip_position: int = 0
|
||||
) -> bool:
|
||||
"""液体转移"""
|
||||
try:
|
||||
if not self.is_setup:
|
||||
raise LaiYuLiquidError("设备未设置")
|
||||
|
||||
# 获取源和目标位置
|
||||
source_pos = self.deck.get_position(source)
|
||||
target_pos = self.deck.get_position(target)
|
||||
tip_pos = self.deck.get_position(tip_rack)
|
||||
|
||||
if not all([source_pos, target_pos, tip_pos]):
|
||||
raise LaiYuLiquidError("位置信息不完整")
|
||||
|
||||
# 执行转移步骤
|
||||
steps = [
|
||||
("移动到吸头架", self.backend.move_to(*tip_pos)),
|
||||
("拾取吸头", self.backend.pick_up_tip(tip_rack, tip_position)),
|
||||
("移动到源位置", self.backend.move_to(*source_pos)),
|
||||
("吸取液体", self.backend.aspirate(volume, source)),
|
||||
("移动到目标位置", self.backend.move_to(*target_pos)),
|
||||
("分配液体", self.backend.dispense(volume, target)),
|
||||
("丢弃吸头", self.backend.drop_tip()),
|
||||
]
|
||||
|
||||
for step_name, step_coro in steps:
|
||||
logger.debug(f"执行步骤: {step_name}")
|
||||
success = await step_coro
|
||||
if not success:
|
||||
raise LaiYuLiquidError(f"步骤失败: {step_name}")
|
||||
|
||||
logger.info(f"液体转移完成: {source} -> {target}, {volume} μL")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"液体转移失败: {e}")
|
||||
return False
|
||||
|
||||
def add_resource(self, name: str, resource_type: str, position: Tuple[float, float, float]):
|
||||
"""添加资源到工作台"""
|
||||
if resource_type == "plate":
|
||||
resource = Plate(name)
|
||||
elif resource_type == "tip_rack":
|
||||
resource = TipRack(name)
|
||||
else:
|
||||
resource = Resource(name)
|
||||
|
||||
self.deck.add_resource(name, resource, position)
|
||||
|
||||
def get_status(self) -> Dict[str, Any]:
|
||||
"""获取设备状态"""
|
||||
return {
|
||||
"connected": self.backend.is_connected,
|
||||
"setup": self.is_setup,
|
||||
"current_position": self.backend.current_position,
|
||||
"tip_attached": self.backend.tip_attached,
|
||||
"current_volume": self.backend.current_volume,
|
||||
"resources": self.deck.list_resources(),
|
||||
}
|
||||
|
||||
|
||||
def create_quick_setup() -> LaiYuLiquidDeck:
|
||||
"""
|
||||
创建快速设置的LaiYu液体处理工作站
|
||||
|
||||
Returns:
|
||||
LaiYuLiquidDeck: 配置好的工作台实例
|
||||
"""
|
||||
# 创建默认配置
|
||||
config = LaiYuLiquidConfig()
|
||||
|
||||
# 创建工作台
|
||||
deck = LaiYuLiquidDeck(config)
|
||||
|
||||
# 导入资源创建函数
|
||||
try:
|
||||
from .laiyu_liquid_res import (
|
||||
create_tip_rack_1000ul,
|
||||
create_tip_rack_200ul,
|
||||
create_96_well_plate,
|
||||
create_waste_container,
|
||||
)
|
||||
|
||||
# 添加基本资源
|
||||
tip_rack_1000 = create_tip_rack_1000ul("tip_rack_1000")
|
||||
tip_rack_200 = create_tip_rack_200ul("tip_rack_200")
|
||||
plate_96 = create_96_well_plate("plate_96")
|
||||
waste = create_waste_container("waste")
|
||||
|
||||
# 添加到工作台
|
||||
deck.add_resource("tip_rack_1000", tip_rack_1000, (50, 50, 0))
|
||||
deck.add_resource("tip_rack_200", tip_rack_200, (150, 50, 0))
|
||||
deck.add_resource("plate_96", plate_96, (250, 50, 0))
|
||||
deck.add_resource("waste", waste, (50, 150, 0))
|
||||
|
||||
except ImportError:
|
||||
# 如果资源模块不可用,创建空的工作台
|
||||
logger.warning("资源模块不可用,创建空的工作台")
|
||||
|
||||
return deck
|
||||
|
||||
|
||||
__all__ = [
|
||||
"LaiYuLiquid",
|
||||
"LaiYuLiquidBackend",
|
||||
"LaiYuLiquidConfig",
|
||||
"LaiYuLiquidDeck",
|
||||
"LaiYuLiquidContainer",
|
||||
"LaiYuLiquidTipRack",
|
||||
"LaiYuLiquidError",
|
||||
"create_quick_setup",
|
||||
"get_module_info",
|
||||
]
|
||||
@@ -1,954 +0,0 @@
|
||||
"""
|
||||
LaiYu_Liquid 资源定义模块
|
||||
|
||||
该模块提供了 LaiYu_Liquid 工作站专用的资源定义函数,包括:
|
||||
- 各种规格的枪头架
|
||||
- 不同类型的板和容器
|
||||
- 特殊功能位置
|
||||
- 资源创建的便捷函数
|
||||
|
||||
所有资源都基于 deck.json 中的配置参数创建。
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
from typing import Dict, List, Optional, Tuple, Any
|
||||
from pathlib import Path
|
||||
|
||||
# PyLabRobot 资源导入
|
||||
try:
|
||||
from pylabrobot.resources import (
|
||||
Resource, Deck, Plate, TipRack, Container, Tip,
|
||||
Coordinate
|
||||
)
|
||||
from pylabrobot.resources.tip_rack import TipSpot
|
||||
from pylabrobot.resources.well import Well as PlateWell
|
||||
PYLABROBOT_AVAILABLE = True
|
||||
except ImportError:
|
||||
# 如果 PyLabRobot 不可用,创建模拟类
|
||||
PYLABROBOT_AVAILABLE = False
|
||||
|
||||
class Resource:
|
||||
def __init__(self, name: str):
|
||||
self.name = name
|
||||
|
||||
class Deck(Resource):
|
||||
pass
|
||||
|
||||
class Plate(Resource):
|
||||
pass
|
||||
|
||||
class TipRack(Resource):
|
||||
pass
|
||||
|
||||
class Container(Resource):
|
||||
pass
|
||||
|
||||
class Tip(Resource):
|
||||
pass
|
||||
|
||||
class TipSpot(Resource):
|
||||
def __init__(self, name: str, **kwargs):
|
||||
super().__init__(name)
|
||||
# 忽略其他参数
|
||||
|
||||
class PlateWell(Resource):
|
||||
pass
|
||||
|
||||
class Coordinate:
|
||||
def __init__(self, x: float, y: float, z: float):
|
||||
self.x = x
|
||||
self.y = y
|
||||
self.z = z
|
||||
|
||||
# 本地导入
|
||||
from .laiyu_liquid_main import LaiYuLiquidDeck, LaiYuLiquidContainer, LaiYuLiquidTipRack
|
||||
|
||||
|
||||
def load_deck_config() -> Dict[str, Any]:
|
||||
"""
|
||||
加载工作台配置文件
|
||||
|
||||
Returns:
|
||||
Dict[str, Any]: 配置字典
|
||||
"""
|
||||
# 优先使用最新的deckconfig.json文件
|
||||
config_path = Path(__file__).parent / "controllers" / "deckconfig.json"
|
||||
|
||||
# 如果最新配置文件不存在,回退到旧配置文件
|
||||
if not config_path.exists():
|
||||
config_path = Path(__file__).parent / "config" / "deck.json"
|
||||
|
||||
try:
|
||||
with open(config_path, 'r', encoding='utf-8') as f:
|
||||
return json.load(f)
|
||||
except FileNotFoundError:
|
||||
# 如果找不到配置文件,返回默认配置
|
||||
return {
|
||||
"name": "LaiYu_Liquid_Deck",
|
||||
"size_x": 340.0,
|
||||
"size_y": 250.0,
|
||||
"size_z": 160.0
|
||||
}
|
||||
|
||||
|
||||
# 加载配置
|
||||
DECK_CONFIG = load_deck_config()
|
||||
|
||||
|
||||
class LaiYuTipRack1000(LaiYuLiquidTipRack):
|
||||
"""1000μL 枪头架"""
|
||||
|
||||
def __init__(self, name: str):
|
||||
"""
|
||||
初始化1000μL枪头架
|
||||
|
||||
Args:
|
||||
name: 枪头架名称
|
||||
"""
|
||||
super().__init__(
|
||||
name=name,
|
||||
size_x=127.76,
|
||||
size_y=85.48,
|
||||
size_z=30.0,
|
||||
tip_count=96,
|
||||
tip_volume=1000.0
|
||||
)
|
||||
|
||||
# 创建枪头位置
|
||||
self._create_tip_spots(
|
||||
tip_count=96,
|
||||
tip_spacing=9.0,
|
||||
tip_type="1000ul"
|
||||
)
|
||||
|
||||
def _create_tip_spots(self, tip_count: int, tip_spacing: float, tip_type: str):
|
||||
"""
|
||||
创建枪头位置 - 从配置文件中读取绝对坐标
|
||||
|
||||
Args:
|
||||
tip_count: 枪头数量
|
||||
tip_spacing: 枪头间距
|
||||
tip_type: 枪头类型
|
||||
"""
|
||||
# 从配置文件中获取枪头架的孔位信息
|
||||
config = DECK_CONFIG
|
||||
tip_module = None
|
||||
|
||||
# 查找枪头架模块
|
||||
for module in config.get("children", []):
|
||||
if module.get("type") == "tip_rack":
|
||||
tip_module = module
|
||||
break
|
||||
|
||||
if not tip_module:
|
||||
# 如果配置文件中没有找到,使用默认的相对坐标计算
|
||||
rows = 8
|
||||
cols = 12
|
||||
|
||||
for row in range(rows):
|
||||
for col in range(cols):
|
||||
spot_name = f"{chr(65 + row)}{col + 1:02d}"
|
||||
x = col * tip_spacing + tip_spacing / 2
|
||||
y = row * tip_spacing + tip_spacing / 2
|
||||
|
||||
# 创建枪头 - 根据PyLabRobot或模拟类使用不同参数
|
||||
if PYLABROBOT_AVAILABLE:
|
||||
# PyLabRobot的Tip需要特定参数
|
||||
tip = Tip(
|
||||
has_filter=False,
|
||||
total_tip_length=95.0, # 1000ul枪头长度
|
||||
maximal_volume=1000.0, # 最大体积
|
||||
fitting_depth=8.0 # 安装深度
|
||||
)
|
||||
else:
|
||||
# 模拟类只需要name
|
||||
tip = Tip(name=f"tip_{spot_name}")
|
||||
|
||||
# 创建枪头位置
|
||||
if PYLABROBOT_AVAILABLE:
|
||||
# PyLabRobot的TipSpot需要特定参数
|
||||
tip_spot = TipSpot(
|
||||
name=spot_name,
|
||||
size_x=9.0, # 枪头位置宽度
|
||||
size_y=9.0, # 枪头位置深度
|
||||
size_z=95.0, # 枪头位置高度
|
||||
make_tip=lambda: tip # 创建枪头的函数
|
||||
)
|
||||
else:
|
||||
# 模拟类只需要name
|
||||
tip_spot = TipSpot(name=spot_name)
|
||||
|
||||
# 将吸头位置分配到吸头架
|
||||
self.assign_child_resource(
|
||||
tip_spot,
|
||||
location=Coordinate(x, y, 0)
|
||||
)
|
||||
return
|
||||
|
||||
# 使用配置文件中的绝对坐标
|
||||
module_position = tip_module.get("position", {"x": 0, "y": 0, "z": 0})
|
||||
|
||||
for well_config in tip_module.get("wells", []):
|
||||
spot_name = well_config["id"]
|
||||
well_pos = well_config["position"]
|
||||
|
||||
# 计算相对于模块的坐标(绝对坐标减去模块位置)
|
||||
relative_x = well_pos["x"] - module_position["x"]
|
||||
relative_y = well_pos["y"] - module_position["y"]
|
||||
relative_z = well_pos["z"] - module_position["z"]
|
||||
|
||||
# 创建枪头 - 根据PyLabRobot或模拟类使用不同参数
|
||||
if PYLABROBOT_AVAILABLE:
|
||||
# PyLabRobot的Tip需要特定参数
|
||||
tip = Tip(
|
||||
has_filter=False,
|
||||
total_tip_length=95.0, # 1000ul枪头长度
|
||||
maximal_volume=1000.0, # 最大体积
|
||||
fitting_depth=8.0 # 安装深度
|
||||
)
|
||||
else:
|
||||
# 模拟类只需要name
|
||||
tip = Tip(name=f"tip_{spot_name}")
|
||||
|
||||
# 创建枪头位置
|
||||
if PYLABROBOT_AVAILABLE:
|
||||
# PyLabRobot的TipSpot需要特定参数
|
||||
tip_spot = TipSpot(
|
||||
name=spot_name,
|
||||
size_x=well_config.get("diameter", 9.0), # 使用配置中的直径
|
||||
size_y=well_config.get("diameter", 9.0),
|
||||
size_z=well_config.get("depth", 95.0), # 使用配置中的深度
|
||||
make_tip=lambda: tip # 创建枪头的函数
|
||||
)
|
||||
else:
|
||||
# 模拟类只需要name
|
||||
tip_spot = TipSpot(name=spot_name)
|
||||
|
||||
# 将吸头位置分配到吸头架
|
||||
self.assign_child_resource(
|
||||
tip_spot,
|
||||
location=Coordinate(relative_x, relative_y, relative_z)
|
||||
)
|
||||
|
||||
# 注意:在PyLabRobot中,Tip不是Resource,不需要分配给TipSpot
|
||||
# TipSpot的make_tip函数会在需要时创建Tip
|
||||
|
||||
|
||||
class LaiYuTipRack200(LaiYuLiquidTipRack):
|
||||
"""200μL 枪头架"""
|
||||
|
||||
def __init__(self, name: str):
|
||||
"""
|
||||
初始化200μL枪头架
|
||||
|
||||
Args:
|
||||
name: 枪头架名称
|
||||
"""
|
||||
super().__init__(
|
||||
name=name,
|
||||
size_x=127.76,
|
||||
size_y=85.48,
|
||||
size_z=30.0,
|
||||
tip_count=96,
|
||||
tip_volume=200.0
|
||||
)
|
||||
|
||||
# 创建枪头位置
|
||||
self._create_tip_spots(
|
||||
tip_count=96,
|
||||
tip_spacing=9.0,
|
||||
tip_type="200ul"
|
||||
)
|
||||
|
||||
def _create_tip_spots(self, tip_count: int, tip_spacing: float, tip_type: str):
|
||||
"""
|
||||
创建枪头位置
|
||||
|
||||
Args:
|
||||
tip_count: 枪头数量
|
||||
tip_spacing: 枪头间距
|
||||
tip_type: 枪头类型
|
||||
"""
|
||||
rows = 8
|
||||
cols = 12
|
||||
|
||||
for row in range(rows):
|
||||
for col in range(cols):
|
||||
spot_name = f"{chr(65 + row)}{col + 1:02d}"
|
||||
x = col * tip_spacing + tip_spacing / 2
|
||||
y = row * tip_spacing + tip_spacing / 2
|
||||
|
||||
# 创建枪头 - 根据PyLabRobot或模拟类使用不同参数
|
||||
if PYLABROBOT_AVAILABLE:
|
||||
# PyLabRobot的Tip需要特定参数
|
||||
tip = Tip(
|
||||
has_filter=False,
|
||||
total_tip_length=72.0, # 200ul枪头长度
|
||||
maximal_volume=200.0, # 最大体积
|
||||
fitting_depth=8.0 # 安装深度
|
||||
)
|
||||
else:
|
||||
# 模拟类只需要name
|
||||
tip = Tip(name=f"tip_{spot_name}")
|
||||
|
||||
# 创建枪头位置
|
||||
if PYLABROBOT_AVAILABLE:
|
||||
# PyLabRobot的TipSpot需要特定参数
|
||||
tip_spot = TipSpot(
|
||||
name=spot_name,
|
||||
size_x=9.0, # 枪头位置宽度
|
||||
size_y=9.0, # 枪头位置深度
|
||||
size_z=72.0, # 枪头位置高度
|
||||
make_tip=lambda: tip # 创建枪头的函数
|
||||
)
|
||||
else:
|
||||
# 模拟类只需要name
|
||||
tip_spot = TipSpot(name=spot_name)
|
||||
|
||||
# 将吸头位置分配到吸头架
|
||||
self.assign_child_resource(
|
||||
tip_spot,
|
||||
location=Coordinate(x, y, 0)
|
||||
)
|
||||
|
||||
# 注意:在PyLabRobot中,Tip不是Resource,不需要分配给TipSpot
|
||||
# TipSpot的make_tip函数会在需要时创建Tip
|
||||
|
||||
|
||||
class LaiYu96WellPlate(LaiYuLiquidContainer):
|
||||
"""96孔板"""
|
||||
|
||||
def __init__(self, name: str, lid_height: float = 0.0):
|
||||
"""
|
||||
初始化96孔板
|
||||
|
||||
Args:
|
||||
name: 板名称
|
||||
lid_height: 盖子高度
|
||||
"""
|
||||
super().__init__(
|
||||
name=name,
|
||||
size_x=127.76,
|
||||
size_y=85.48,
|
||||
size_z=14.22,
|
||||
container_type="96_well_plate",
|
||||
volume=0.0,
|
||||
max_volume=200.0,
|
||||
lid_height=lid_height
|
||||
)
|
||||
|
||||
# 创建孔位
|
||||
self._create_wells(
|
||||
well_count=96,
|
||||
well_volume=200.0,
|
||||
well_spacing=9.0
|
||||
)
|
||||
|
||||
def get_size_z(self) -> float:
|
||||
"""获取孔位深度"""
|
||||
return 10.0 # 96孔板孔位深度
|
||||
|
||||
def _create_wells(self, well_count: int, well_volume: float, well_spacing: float):
|
||||
"""
|
||||
创建孔位 - 从配置文件中读取绝对坐标
|
||||
|
||||
Args:
|
||||
well_count: 孔位数量
|
||||
well_volume: 孔位体积
|
||||
well_spacing: 孔位间距
|
||||
"""
|
||||
# 从配置文件中获取96孔板的孔位信息
|
||||
config = DECK_CONFIG
|
||||
plate_module = None
|
||||
|
||||
# 查找96孔板模块
|
||||
for module in config.get("children", []):
|
||||
if module.get("type") == "96_well_plate":
|
||||
plate_module = module
|
||||
break
|
||||
|
||||
if not plate_module:
|
||||
# 如果配置文件中没有找到,使用默认的相对坐标计算
|
||||
rows = 8
|
||||
cols = 12
|
||||
|
||||
for row in range(rows):
|
||||
for col in range(cols):
|
||||
well_name = f"{chr(65 + row)}{col + 1:02d}"
|
||||
x = col * well_spacing + well_spacing / 2
|
||||
y = row * well_spacing + well_spacing / 2
|
||||
|
||||
# 创建孔位
|
||||
well = PlateWell(
|
||||
name=well_name,
|
||||
size_x=well_spacing * 0.8,
|
||||
size_y=well_spacing * 0.8,
|
||||
size_z=self.get_size_z(),
|
||||
max_volume=well_volume
|
||||
)
|
||||
|
||||
# 添加到板
|
||||
self.assign_child_resource(
|
||||
well,
|
||||
location=Coordinate(x, y, 0)
|
||||
)
|
||||
return
|
||||
|
||||
# 使用配置文件中的绝对坐标
|
||||
module_position = plate_module.get("position", {"x": 0, "y": 0, "z": 0})
|
||||
|
||||
for well_config in plate_module.get("wells", []):
|
||||
well_name = well_config["id"]
|
||||
well_pos = well_config["position"]
|
||||
|
||||
# 计算相对于模块的坐标(绝对坐标减去模块位置)
|
||||
relative_x = well_pos["x"] - module_position["x"]
|
||||
relative_y = well_pos["y"] - module_position["y"]
|
||||
relative_z = well_pos["z"] - module_position["z"]
|
||||
|
||||
# 创建孔位
|
||||
well = PlateWell(
|
||||
name=well_name,
|
||||
size_x=well_config.get("diameter", 8.2) * 0.8, # 使用配置中的直径
|
||||
size_y=well_config.get("diameter", 8.2) * 0.8,
|
||||
size_z=well_config.get("depth", self.get_size_z()),
|
||||
max_volume=well_config.get("volume", well_volume)
|
||||
)
|
||||
|
||||
# 添加到板
|
||||
self.assign_child_resource(
|
||||
well,
|
||||
location=Coordinate(relative_x, relative_y, relative_z)
|
||||
)
|
||||
|
||||
|
||||
class LaiYuDeepWellPlate(LaiYuLiquidContainer):
|
||||
"""深孔板"""
|
||||
|
||||
def __init__(self, name: str, lid_height: float = 0.0):
|
||||
"""
|
||||
初始化深孔板
|
||||
|
||||
Args:
|
||||
name: 板名称
|
||||
lid_height: 盖子高度
|
||||
"""
|
||||
super().__init__(
|
||||
name=name,
|
||||
size_x=127.76,
|
||||
size_y=85.48,
|
||||
size_z=41.3,
|
||||
container_type="deep_well_plate",
|
||||
volume=0.0,
|
||||
max_volume=2000.0,
|
||||
lid_height=lid_height
|
||||
)
|
||||
|
||||
# 创建孔位
|
||||
self._create_wells(
|
||||
well_count=96,
|
||||
well_volume=2000.0,
|
||||
well_spacing=9.0
|
||||
)
|
||||
|
||||
def get_size_z(self) -> float:
|
||||
"""获取孔位深度"""
|
||||
return 35.0 # 深孔板孔位深度
|
||||
|
||||
def _create_wells(self, well_count: int, well_volume: float, well_spacing: float):
|
||||
"""
|
||||
创建孔位 - 从配置文件中读取绝对坐标
|
||||
|
||||
Args:
|
||||
well_count: 孔位数量
|
||||
well_volume: 孔位体积
|
||||
well_spacing: 孔位间距
|
||||
"""
|
||||
# 从配置文件中获取深孔板的孔位信息
|
||||
config = DECK_CONFIG
|
||||
plate_module = None
|
||||
|
||||
# 查找深孔板模块(通常是第二个96孔板模块)
|
||||
plate_modules = []
|
||||
for module in config.get("children", []):
|
||||
if module.get("type") == "96_well_plate":
|
||||
plate_modules.append(module)
|
||||
|
||||
# 如果有多个96孔板模块,选择第二个作为深孔板
|
||||
if len(plate_modules) > 1:
|
||||
plate_module = plate_modules[1]
|
||||
elif len(plate_modules) == 1:
|
||||
plate_module = plate_modules[0]
|
||||
|
||||
if not plate_module:
|
||||
# 如果配置文件中没有找到,使用默认的相对坐标计算
|
||||
rows = 8
|
||||
cols = 12
|
||||
|
||||
for row in range(rows):
|
||||
for col in range(cols):
|
||||
well_name = f"{chr(65 + row)}{col + 1:02d}"
|
||||
x = col * well_spacing + well_spacing / 2
|
||||
y = row * well_spacing + well_spacing / 2
|
||||
|
||||
# 创建孔位
|
||||
well = PlateWell(
|
||||
name=well_name,
|
||||
size_x=well_spacing * 0.8,
|
||||
size_y=well_spacing * 0.8,
|
||||
size_z=self.get_size_z(),
|
||||
max_volume=well_volume
|
||||
)
|
||||
|
||||
# 添加到板
|
||||
self.assign_child_resource(
|
||||
well,
|
||||
location=Coordinate(x, y, 0)
|
||||
)
|
||||
return
|
||||
|
||||
# 使用配置文件中的绝对坐标
|
||||
module_position = plate_module.get("position", {"x": 0, "y": 0, "z": 0})
|
||||
|
||||
for well_config in plate_module.get("wells", []):
|
||||
well_name = well_config["id"]
|
||||
well_pos = well_config["position"]
|
||||
|
||||
# 计算相对于模块的坐标(绝对坐标减去模块位置)
|
||||
relative_x = well_pos["x"] - module_position["x"]
|
||||
relative_y = well_pos["y"] - module_position["y"]
|
||||
relative_z = well_pos["z"] - module_position["z"]
|
||||
|
||||
# 创建孔位
|
||||
well = PlateWell(
|
||||
name=well_name,
|
||||
size_x=well_config.get("diameter", 8.2) * 0.8, # 使用配置中的直径
|
||||
size_y=well_config.get("diameter", 8.2) * 0.8,
|
||||
size_z=well_config.get("depth", self.get_size_z()),
|
||||
max_volume=well_config.get("volume", well_volume)
|
||||
)
|
||||
|
||||
# 添加到板
|
||||
self.assign_child_resource(
|
||||
well,
|
||||
location=Coordinate(relative_x, relative_y, relative_z)
|
||||
)
|
||||
|
||||
|
||||
class LaiYuWasteContainer(Container):
|
||||
"""废液容器"""
|
||||
|
||||
def __init__(self, name: str):
|
||||
"""
|
||||
初始化废液容器
|
||||
|
||||
Args:
|
||||
name: 容器名称
|
||||
"""
|
||||
super().__init__(
|
||||
name=name,
|
||||
size_x=100.0,
|
||||
size_y=100.0,
|
||||
size_z=50.0,
|
||||
max_volume=5000.0
|
||||
)
|
||||
|
||||
|
||||
class LaiYuWashContainer(Container):
|
||||
"""清洗容器"""
|
||||
|
||||
def __init__(self, name: str):
|
||||
"""
|
||||
初始化清洗容器
|
||||
|
||||
Args:
|
||||
name: 容器名称
|
||||
"""
|
||||
super().__init__(
|
||||
name=name,
|
||||
size_x=100.0,
|
||||
size_y=100.0,
|
||||
size_z=50.0,
|
||||
max_volume=5000.0
|
||||
)
|
||||
|
||||
|
||||
class LaiYuReagentContainer(Container):
|
||||
"""试剂容器"""
|
||||
|
||||
def __init__(self, name: str):
|
||||
"""
|
||||
初始化试剂容器
|
||||
|
||||
Args:
|
||||
name: 容器名称
|
||||
"""
|
||||
super().__init__(
|
||||
name=name,
|
||||
size_x=50.0,
|
||||
size_y=50.0,
|
||||
size_z=100.0,
|
||||
max_volume=2000.0
|
||||
)
|
||||
|
||||
|
||||
class LaiYu8TubeRack(LaiYuLiquidContainer):
|
||||
"""8管试管架"""
|
||||
|
||||
def __init__(self, name: str):
|
||||
"""
|
||||
初始化8管试管架
|
||||
|
||||
Args:
|
||||
name: 试管架名称
|
||||
"""
|
||||
super().__init__(
|
||||
name=name,
|
||||
size_x=151.0,
|
||||
size_y=75.0,
|
||||
size_z=75.0,
|
||||
container_type="tube_rack",
|
||||
volume=0.0,
|
||||
max_volume=77000.0
|
||||
)
|
||||
|
||||
# 创建孔位
|
||||
self._create_wells(
|
||||
well_count=8,
|
||||
well_volume=77000.0,
|
||||
well_spacing=35.0
|
||||
)
|
||||
|
||||
def get_size_z(self) -> float:
|
||||
"""获取孔位深度"""
|
||||
return 117.0 # 试管深度
|
||||
|
||||
def _create_wells(self, well_count: int, well_volume: float, well_spacing: float):
|
||||
"""
|
||||
创建孔位 - 从配置文件中读取绝对坐标
|
||||
|
||||
Args:
|
||||
well_count: 孔位数量
|
||||
well_volume: 孔位体积
|
||||
well_spacing: 孔位间距
|
||||
"""
|
||||
# 从配置文件中获取8管试管架的孔位信息
|
||||
config = DECK_CONFIG
|
||||
tube_module = None
|
||||
|
||||
# 查找8管试管架模块
|
||||
for module in config.get("children", []):
|
||||
if module.get("type") == "tube_rack":
|
||||
tube_module = module
|
||||
break
|
||||
|
||||
if not tube_module:
|
||||
# 如果配置文件中没有找到,使用默认的相对坐标计算
|
||||
rows = 2
|
||||
cols = 4
|
||||
|
||||
for row in range(rows):
|
||||
for col in range(cols):
|
||||
well_name = f"{chr(65 + row)}{col + 1}"
|
||||
x = col * well_spacing + well_spacing / 2
|
||||
y = row * well_spacing + well_spacing / 2
|
||||
|
||||
# 创建孔位
|
||||
well = PlateWell(
|
||||
name=well_name,
|
||||
size_x=29.0,
|
||||
size_y=29.0,
|
||||
size_z=self.get_size_z(),
|
||||
max_volume=well_volume
|
||||
)
|
||||
|
||||
# 添加到试管架
|
||||
self.assign_child_resource(
|
||||
well,
|
||||
location=Coordinate(x, y, 0)
|
||||
)
|
||||
return
|
||||
|
||||
# 使用配置文件中的绝对坐标
|
||||
module_position = tube_module.get("position", {"x": 0, "y": 0, "z": 0})
|
||||
|
||||
for well_config in tube_module.get("wells", []):
|
||||
well_name = well_config["id"]
|
||||
well_pos = well_config["position"]
|
||||
|
||||
# 计算相对于模块的坐标(绝对坐标减去模块位置)
|
||||
relative_x = well_pos["x"] - module_position["x"]
|
||||
relative_y = well_pos["y"] - module_position["y"]
|
||||
relative_z = well_pos["z"] - module_position["z"]
|
||||
|
||||
# 创建孔位
|
||||
well = PlateWell(
|
||||
name=well_name,
|
||||
size_x=well_config.get("diameter", 29.0),
|
||||
size_y=well_config.get("diameter", 29.0),
|
||||
size_z=well_config.get("depth", self.get_size_z()),
|
||||
max_volume=well_config.get("volume", well_volume)
|
||||
)
|
||||
|
||||
# 添加到试管架
|
||||
self.assign_child_resource(
|
||||
well,
|
||||
location=Coordinate(relative_x, relative_y, relative_z)
|
||||
)
|
||||
|
||||
|
||||
class LaiYuTipDisposal(Resource):
|
||||
"""枪头废料位置"""
|
||||
|
||||
def __init__(self, name: str):
|
||||
"""
|
||||
初始化枪头废料位置
|
||||
|
||||
Args:
|
||||
name: 位置名称
|
||||
"""
|
||||
super().__init__(
|
||||
name=name,
|
||||
size_x=100.0,
|
||||
size_y=100.0,
|
||||
size_z=50.0
|
||||
)
|
||||
|
||||
|
||||
class LaiYuMaintenancePosition(Resource):
|
||||
"""维护位置"""
|
||||
|
||||
def __init__(self, name: str):
|
||||
"""
|
||||
初始化维护位置
|
||||
|
||||
Args:
|
||||
name: 位置名称
|
||||
"""
|
||||
super().__init__(
|
||||
name=name,
|
||||
size_x=50.0,
|
||||
size_y=50.0,
|
||||
size_z=100.0
|
||||
)
|
||||
|
||||
|
||||
# 资源创建函数
|
||||
def create_tip_rack_1000ul(name: str = "tip_rack_1000ul") -> LaiYuTipRack1000:
|
||||
"""
|
||||
创建1000μL枪头架
|
||||
|
||||
Args:
|
||||
name: 枪头架名称
|
||||
|
||||
Returns:
|
||||
LaiYuTipRack1000: 1000μL枪头架实例
|
||||
"""
|
||||
return LaiYuTipRack1000(name)
|
||||
|
||||
|
||||
def create_tip_rack_200ul(name: str = "tip_rack_200ul") -> LaiYuTipRack200:
|
||||
"""
|
||||
创建200μL枪头架
|
||||
|
||||
Args:
|
||||
name: 枪头架名称
|
||||
|
||||
Returns:
|
||||
LaiYuTipRack200: 200μL枪头架实例
|
||||
"""
|
||||
return LaiYuTipRack200(name)
|
||||
|
||||
|
||||
def create_96_well_plate(name: str = "96_well_plate", lid_height: float = 0.0) -> LaiYu96WellPlate:
|
||||
"""
|
||||
创建96孔板
|
||||
|
||||
Args:
|
||||
name: 板名称
|
||||
lid_height: 盖子高度
|
||||
|
||||
Returns:
|
||||
LaiYu96WellPlate: 96孔板实例
|
||||
"""
|
||||
return LaiYu96WellPlate(name, lid_height)
|
||||
|
||||
|
||||
def create_deep_well_plate(name: str = "deep_well_plate", lid_height: float = 0.0) -> LaiYuDeepWellPlate:
|
||||
"""
|
||||
创建深孔板
|
||||
|
||||
Args:
|
||||
name: 板名称
|
||||
lid_height: 盖子高度
|
||||
|
||||
Returns:
|
||||
LaiYuDeepWellPlate: 深孔板实例
|
||||
"""
|
||||
return LaiYuDeepWellPlate(name, lid_height)
|
||||
|
||||
|
||||
def create_8_tube_rack(name: str = "8_tube_rack") -> LaiYu8TubeRack:
|
||||
"""
|
||||
创建8管试管架
|
||||
|
||||
Args:
|
||||
name: 试管架名称
|
||||
|
||||
Returns:
|
||||
LaiYu8TubeRack: 8管试管架实例
|
||||
"""
|
||||
return LaiYu8TubeRack(name)
|
||||
|
||||
|
||||
def create_waste_container(name: str = "waste_container") -> LaiYuWasteContainer:
|
||||
"""
|
||||
创建废液容器
|
||||
|
||||
Args:
|
||||
name: 容器名称
|
||||
|
||||
Returns:
|
||||
LaiYuWasteContainer: 废液容器实例
|
||||
"""
|
||||
return LaiYuWasteContainer(name)
|
||||
|
||||
|
||||
def create_wash_container(name: str = "wash_container") -> LaiYuWashContainer:
|
||||
"""
|
||||
创建清洗容器
|
||||
|
||||
Args:
|
||||
name: 容器名称
|
||||
|
||||
Returns:
|
||||
LaiYuWashContainer: 清洗容器实例
|
||||
"""
|
||||
return LaiYuWashContainer(name)
|
||||
|
||||
|
||||
def create_reagent_container(name: str = "reagent_container") -> LaiYuReagentContainer:
|
||||
"""
|
||||
创建试剂容器
|
||||
|
||||
Args:
|
||||
name: 容器名称
|
||||
|
||||
Returns:
|
||||
LaiYuReagentContainer: 试剂容器实例
|
||||
"""
|
||||
return LaiYuReagentContainer(name)
|
||||
|
||||
|
||||
def create_tip_disposal(name: str = "tip_disposal") -> LaiYuTipDisposal:
|
||||
"""
|
||||
创建枪头废料位置
|
||||
|
||||
Args:
|
||||
name: 位置名称
|
||||
|
||||
Returns:
|
||||
LaiYuTipDisposal: 枪头废料位置实例
|
||||
"""
|
||||
return LaiYuTipDisposal(name)
|
||||
|
||||
|
||||
def create_maintenance_position(name: str = "maintenance_position") -> LaiYuMaintenancePosition:
|
||||
"""
|
||||
创建维护位置
|
||||
|
||||
Args:
|
||||
name: 位置名称
|
||||
|
||||
Returns:
|
||||
LaiYuMaintenancePosition: 维护位置实例
|
||||
"""
|
||||
return LaiYuMaintenancePosition(name)
|
||||
|
||||
|
||||
def create_standard_deck() -> LaiYuLiquidDeck:
|
||||
"""
|
||||
创建标准工作台配置
|
||||
|
||||
Returns:
|
||||
LaiYuLiquidDeck: 配置好的工作台实例
|
||||
"""
|
||||
# 从配置文件创建工作台
|
||||
deck = LaiYuLiquidDeck(config=DECK_CONFIG)
|
||||
|
||||
return deck
|
||||
|
||||
|
||||
def get_resource_by_name(deck: LaiYuLiquidDeck, name: str) -> Optional[Resource]:
|
||||
"""
|
||||
根据名称获取资源
|
||||
|
||||
Args:
|
||||
deck: 工作台实例
|
||||
name: 资源名称
|
||||
|
||||
Returns:
|
||||
Optional[Resource]: 找到的资源,如果不存在则返回None
|
||||
"""
|
||||
for child in deck.children:
|
||||
if child.name == name:
|
||||
return child
|
||||
return None
|
||||
|
||||
|
||||
def get_resources_by_type(deck: LaiYuLiquidDeck, resource_type: type) -> List[Resource]:
|
||||
"""
|
||||
根据类型获取资源列表
|
||||
|
||||
Args:
|
||||
deck: 工作台实例
|
||||
resource_type: 资源类型
|
||||
|
||||
Returns:
|
||||
List[Resource]: 匹配类型的资源列表
|
||||
"""
|
||||
return [child for child in deck.children if isinstance(child, resource_type)]
|
||||
|
||||
|
||||
def list_all_resources(deck: LaiYuLiquidDeck) -> Dict[str, List[str]]:
|
||||
"""
|
||||
列出所有资源
|
||||
|
||||
Args:
|
||||
deck: 工作台实例
|
||||
|
||||
Returns:
|
||||
Dict[str, List[str]]: 按类型分组的资源名称字典
|
||||
"""
|
||||
resources = {
|
||||
"tip_racks": [],
|
||||
"plates": [],
|
||||
"containers": [],
|
||||
"positions": []
|
||||
}
|
||||
|
||||
for child in deck.children:
|
||||
if isinstance(child, (LaiYuTipRack1000, LaiYuTipRack200)):
|
||||
resources["tip_racks"].append(child.name)
|
||||
elif isinstance(child, (LaiYu96WellPlate, LaiYuDeepWellPlate)):
|
||||
resources["plates"].append(child.name)
|
||||
elif isinstance(child, (LaiYuWasteContainer, LaiYuWashContainer, LaiYuReagentContainer)):
|
||||
resources["containers"].append(child.name)
|
||||
elif isinstance(child, (LaiYuTipDisposal, LaiYuMaintenancePosition)):
|
||||
resources["positions"].append(child.name)
|
||||
|
||||
return resources
|
||||
|
||||
|
||||
# 导出的类别名(向后兼容)
|
||||
TipRack1000ul = LaiYuTipRack1000
|
||||
TipRack200ul = LaiYuTipRack200
|
||||
Plate96Well = LaiYu96WellPlate
|
||||
Plate96DeepWell = LaiYuDeepWellPlate
|
||||
TubeRack8 = LaiYu8TubeRack
|
||||
WasteContainer = LaiYuWasteContainer
|
||||
WashContainer = LaiYuWashContainer
|
||||
ReagentContainer = LaiYuReagentContainer
|
||||
TipDisposal = LaiYuTipDisposal
|
||||
MaintenancePosition = LaiYuMaintenancePosition
|
||||
@@ -1,69 +0,0 @@
|
||||
# 更新日志
|
||||
|
||||
本文档记录了 LaiYu_Liquid 模块的所有重要变更。
|
||||
|
||||
## [1.0.0] - 2024-01-XX
|
||||
|
||||
### 新增功能
|
||||
- ✅ 完整的液体处理工作站集成
|
||||
- ✅ RS485 通信协议支持
|
||||
- ✅ SOPA 气动式移液器驱动
|
||||
- ✅ XYZ 三轴步进电机控制
|
||||
- ✅ PyLabRobot 兼容后端
|
||||
- ✅ 标准化资源管理系统
|
||||
- ✅ 96孔板、离心管架、枪头架支持
|
||||
- ✅ RViz 可视化后端
|
||||
- ✅ 完整的配置管理系统
|
||||
- ✅ 抽象协议实现
|
||||
- ✅ 生产级错误处理和日志记录
|
||||
|
||||
### 技术特性
|
||||
- **硬件支持**: SOPA移液器 + XYZ三轴运动平台
|
||||
- **通信协议**: RS485总线,波特率115200
|
||||
- **坐标系统**: 机械坐标与工作坐标自动转换
|
||||
- **安全机制**: 限位保护、紧急停止、错误恢复
|
||||
- **兼容性**: 完全兼容 PyLabRobot 框架
|
||||
|
||||
### 文件结构
|
||||
```
|
||||
LaiYu_Liquid/
|
||||
├── core/
|
||||
│ └── LaiYu_Liquid.py # 主模块文件
|
||||
├── __init__.py # 模块初始化
|
||||
├── abstract_protocol.py # 抽象协议
|
||||
├── laiyu_liquid_res.py # 资源管理
|
||||
├── rviz_backend.py # RViz后端
|
||||
├── backend/ # 后端驱动
|
||||
├── config/ # 配置文件
|
||||
├── controllers/ # 控制器
|
||||
├── docs/ # 技术文档
|
||||
└── drivers/ # 底层驱动
|
||||
```
|
||||
|
||||
### 已知问题
|
||||
- 无
|
||||
|
||||
### 依赖要求
|
||||
- Python 3.8+
|
||||
- PyLabRobot
|
||||
- pyserial
|
||||
- asyncio
|
||||
|
||||
---
|
||||
|
||||
## 版本说明
|
||||
|
||||
### 版本号格式
|
||||
采用语义化版本控制 (Semantic Versioning): `MAJOR.MINOR.PATCH`
|
||||
|
||||
- **MAJOR**: 不兼容的API变更
|
||||
- **MINOR**: 向后兼容的功能新增
|
||||
- **PATCH**: 向后兼容的问题修复
|
||||
|
||||
### 变更类型
|
||||
- **新增功能**: 新的功能特性
|
||||
- **变更**: 现有功能的变更
|
||||
- **弃用**: 即将移除的功能
|
||||
- **移除**: 已移除的功能
|
||||
- **修复**: 问题修复
|
||||
- **安全**: 安全相关的修复
|
||||
@@ -1,267 +0,0 @@
|
||||
# SOPA气动式移液器RS485控制指令合集
|
||||
|
||||
## 1. RS485通信基本配置
|
||||
|
||||
### 1.1 支持的设备型号
|
||||
- **仅SC-STxxx-00-13支持RS485通信**
|
||||
- 其他型号主要使用CAN通信
|
||||
|
||||
### 1.2 通信参数
|
||||
- **波特率**: 9600, 115200(默认值)
|
||||
- **地址范围**: 1~254个设备,255为广播地址
|
||||
- **通信接口**: RS485差分信号
|
||||
|
||||
### 1.3 引脚分配(10位LIF连接器)
|
||||
- **引脚7**: RS485+ (RS485通信正极)
|
||||
- **引脚8**: RS485- (RS485通信负极)
|
||||
|
||||
## 2. RS485通信协议格式
|
||||
|
||||
### 2.1 发送数据格式
|
||||
```
|
||||
头码 | 地址 | 命令/数据 | 尾码 | 校验和
|
||||
```
|
||||
|
||||
### 2.2 从机回应格式
|
||||
```
|
||||
头码 | 地址 | 数据(固定9字节) | 尾码 | 校验和
|
||||
```
|
||||
|
||||
### 2.3 格式详细说明
|
||||
- **头码**:
|
||||
- 终端调试: '/' (0x2F)
|
||||
- OEM通信: '[' (0x5B)
|
||||
- **地址**: 设备节点地址,1~254,多字节ASCII(注意:地址不可为47,69,91)
|
||||
- **命令/数据**: ASCII格式的命令字符串
|
||||
- **尾码**: 'E' (0x45)
|
||||
- **校验和**: 以上数据的累加值,1字节
|
||||
|
||||
## 3. 初始化和基本控制指令
|
||||
|
||||
### 3.1 初始化指令
|
||||
```bash
|
||||
# 初始化活塞驱动机构
|
||||
HE
|
||||
|
||||
# 示例(OEM通信):
|
||||
# 主机发送: 5B 32 48 45 1A
|
||||
# 从机回应开始: 2F 02 06 0A 30 00 00 00 00 00 00 45 B6
|
||||
# 从机回应完成: 2F 02 06 00 30 00 00 00 00 00 00 45 AC
|
||||
```
|
||||
|
||||
### 3.2 枪头操作指令
|
||||
```bash
|
||||
# 顶出枪头
|
||||
RE
|
||||
|
||||
# 枪头检测状态报告
|
||||
Q28 # 返回枪头存在状态(0=不存在,1=存在)
|
||||
```
|
||||
|
||||
## 4. 移液控制指令
|
||||
|
||||
### 4.1 位置控制指令
|
||||
```bash
|
||||
# 绝对位置移动(微升)
|
||||
A[n]E
|
||||
# 示例:移动到位置0
|
||||
A0E
|
||||
|
||||
# 相对抽吸(向上移动)
|
||||
P[n]E
|
||||
# 示例:抽吸200微升
|
||||
P200E
|
||||
|
||||
# 相对分配(向下移动)
|
||||
D[n]E
|
||||
# 示例:分配200微升
|
||||
D200E
|
||||
```
|
||||
|
||||
### 4.2 速度设置指令
|
||||
```bash
|
||||
# 设置最高速度(0.1ul/秒为单位)
|
||||
s[n]E
|
||||
# 示例:设置最高速度为2000(200ul/秒)
|
||||
s2000E
|
||||
|
||||
# 设置启动速度
|
||||
b[n]E
|
||||
# 示例:设置启动速度为100(10ul/秒)
|
||||
b100E
|
||||
|
||||
# 设置断流速度
|
||||
c[n]E
|
||||
# 示例:设置断流速度为100(10ul/秒)
|
||||
c100E
|
||||
|
||||
# 设置加速度
|
||||
a[n]E
|
||||
# 示例:设置加速度为30000
|
||||
a30000E
|
||||
```
|
||||
|
||||
## 5. 液体检测和安全控制指令
|
||||
|
||||
### 5.1 吸排液检测控制
|
||||
```bash
|
||||
# 开启吸排液检测
|
||||
f1E # 开启
|
||||
f0E # 关闭
|
||||
|
||||
# 设置空吸门限
|
||||
$[n]E
|
||||
# 示例:设置空吸门限为4
|
||||
$4E
|
||||
|
||||
# 设置泡沫门限
|
||||
![n]E
|
||||
# 示例:设置泡沫门限为20
|
||||
!20E
|
||||
|
||||
# 设置堵塞门限
|
||||
%[n]E
|
||||
# 示例:设置堵塞门限为350
|
||||
%350E
|
||||
```
|
||||
|
||||
### 5.2 液位检测指令
|
||||
```bash
|
||||
# 压力式液位检测
|
||||
m0E # 设置为压力探测模式
|
||||
L[n]E # 执行液位检测,[n]为灵敏度(3~40)
|
||||
k[n]E # 设置检测速度(100~2000)
|
||||
|
||||
# 电容式液位检测
|
||||
m1E # 设置为电容探测模式
|
||||
```
|
||||
|
||||
## 6. 状态查询和报告指令
|
||||
|
||||
### 6.1 基本状态查询
|
||||
```bash
|
||||
# 查询固件版本
|
||||
V
|
||||
|
||||
# 查询设备状态
|
||||
Q[n]
|
||||
# 常用查询参数:
|
||||
Q01 # 报告加速度
|
||||
Q02 # 报告启动速度
|
||||
Q03 # 报告断流速度
|
||||
Q06 # 报告最大速度
|
||||
Q08 # 报告节点地址
|
||||
Q11 # 报告波特率
|
||||
Q18 # 报告当前位置
|
||||
Q28 # 报告枪头存在状态
|
||||
Q29 # 报告校准系数
|
||||
Q30 # 报告空吸门限
|
||||
Q31 # 报告堵针门限
|
||||
Q32 # 报告泡沫门限
|
||||
```
|
||||
|
||||
## 7. 配置和校准指令
|
||||
|
||||
### 7.1 校准参数设置
|
||||
```bash
|
||||
# 设置校准系数
|
||||
j[n]E
|
||||
# 示例:设置校准系数为1.04
|
||||
j1.04E
|
||||
|
||||
# 设置补偿偏差
|
||||
e[n]E
|
||||
# 示例:设置补偿偏差为2.03
|
||||
e2.03E
|
||||
|
||||
# 设置吸头容量
|
||||
C[n]E
|
||||
# 示例:设置1000ul吸头
|
||||
C1000E
|
||||
```
|
||||
|
||||
### 7.2 高级控制参数
|
||||
```bash
|
||||
# 设置回吸粘度
|
||||
][n]E
|
||||
# 示例:设置回吸粘度为30
|
||||
]30E
|
||||
|
||||
# 延时控制
|
||||
M[n]E
|
||||
# 示例:延时1000毫秒
|
||||
M1000E
|
||||
```
|
||||
|
||||
## 8. 复合操作指令示例
|
||||
|
||||
### 8.1 标准移液操作
|
||||
```bash
|
||||
# 完整的200ul移液操作
|
||||
a30000b200c200s2000P200E
|
||||
# 解析:设置加速度30000 + 启动速度200 + 断流速度200 + 最高速度2000 + 抽吸200ul + 执行
|
||||
```
|
||||
|
||||
### 8.2 带检测的移液操作
|
||||
```bash
|
||||
# 带空吸检测的200ul抽吸
|
||||
a30000b200c200s2000f1P200f0E
|
||||
# 解析:设置参数 + 开启检测 + 抽吸200ul + 关闭检测 + 执行
|
||||
```
|
||||
|
||||
### 8.3 液面检测操作
|
||||
```bash
|
||||
# 压力式液面检测
|
||||
m0k200L5E
|
||||
# 解析:压力模式 + 检测速度200 + 灵敏度5 + 执行检测
|
||||
|
||||
# 电容式液面检测
|
||||
m1L3E
|
||||
# 解析:电容模式 + 灵敏度3 + 执行检测
|
||||
```
|
||||
|
||||
## 9. 错误处理
|
||||
|
||||
### 9.1 状态字节说明
|
||||
- **00h**: 无错误
|
||||
- **01h**: 上次动作未完成
|
||||
- **02h**: 设备未初始化
|
||||
- **03h**: 设备过载
|
||||
- **04h**: 无效指令
|
||||
- **05h**: 液位探测故障
|
||||
- **0Dh**: 空吸
|
||||
- **0Eh**: 堵针
|
||||
- **10h**: 泡沫
|
||||
- **11h**: 吸液超过吸头容量
|
||||
|
||||
### 9.2 错误查询
|
||||
```bash
|
||||
# 查询当前错误状态
|
||||
Q # 返回状态字节和错误代码
|
||||
```
|
||||
|
||||
## 10. 通信示例
|
||||
|
||||
### 10.1 基本通信流程
|
||||
1. **执行命令**: 主机发送命令 → 从机确认 → 从机执行 → 从机回应完成
|
||||
2. **读取数据**: 主机发送查询 → 从机确认 → 从机返回数据
|
||||
|
||||
### 10.2 快速指令表
|
||||
| 操作 | 指令 | 说明 |
|
||||
|------|------|------|
|
||||
| 初始化 | `HE` | 初始化设备 |
|
||||
| 退枪头 | `RE` | 顶出枪头 |
|
||||
| 吸液200ul | `a30000b200c200s2000P200E` | 基本吸液 |
|
||||
| 带检测吸液 | `a30000b200c200s2000f1P200f0E` | 开启空吸检测 |
|
||||
| 吐液200ul | `a300000b500c500s6000D200E` | 基本分配 |
|
||||
| 压力液面检测 | `m0k200L5E` | pLLD检测 |
|
||||
| 电容液面检测 | `m1L3E` | cLLD检测 |
|
||||
|
||||
## 11. 注意事项
|
||||
|
||||
1. **地址限制**: RS485地址不可设为47、69、91
|
||||
2. **校验和**: 终端调试时不关心校验和,OEM通信需要校验
|
||||
3. **ASCII格式**: 所有命令和参数都使用ASCII字符
|
||||
4. **执行指令**: 大部分命令需要以'E'结尾才能执行
|
||||
5. **设备支持**: 只有SC-STxxx-00-13型号支持RS485通信
|
||||
6. **波特率设置**: 默认115200,可设置为9600
|
||||
@@ -1,162 +0,0 @@
|
||||
# 步进电机B系列控制指令详解
|
||||
|
||||
## 基本通信参数
|
||||
- **通信方式**: RS485
|
||||
- **协议**: Modbus
|
||||
- **波特率**: 115200 (默认)
|
||||
- **数据位**: 8位
|
||||
- **停止位**: 1位
|
||||
- **校验位**: 无
|
||||
- **默认站号**: 1 (可设置1-254)
|
||||
|
||||
## 支持的功能码
|
||||
- **03H**: 读取寄存器
|
||||
- **06H**: 写入单个寄存器
|
||||
- **10H**: 写入多个寄存器
|
||||
|
||||
## 寄存器地址表
|
||||
|
||||
### 状态监控寄存器 (只读)
|
||||
| 地址 | 功能码 | 内容 | 说明 |
|
||||
|------|--------|------|------|
|
||||
| 00H | 03H | 电机状态 | 0000H-待机/到位, 0001H-运行中, 0002H-碰撞停, 0003H-正光电停, 0004H-反光电停 |
|
||||
| 01H | 03H | 实际步数高位 | 当前电机位置的高16位 |
|
||||
| 02H | 03H | 实际步数低位 | 当前电机位置的低16位 |
|
||||
| 03H | 03H | 实际速度 | 当前转速 (rpm) |
|
||||
| 05H | 03H | 电流 | 当前工作电流 (mA) |
|
||||
|
||||
### 控制寄存器 (读写)
|
||||
| 地址 | 功能码 | 内容 | 说明 |
|
||||
|------|--------|------|------|
|
||||
| 04H | 03H/06H/10H | 急停指令 | 紧急停止控制 |
|
||||
| 06H | 03H/06H/10H | 失能控制 | 1-使能, 0-失能 |
|
||||
| 07H | 03H/06H/10H | PWM输出 | 0-1000对应0%-100%占空比 |
|
||||
| 0EH | 03H/06H/10H | 单圈绝对值归零 | 归零指令 |
|
||||
| 0FH | 03H/06H/10H | 归零指令 | 定点模式归零速度设置 |
|
||||
|
||||
### 位置模式寄存器
|
||||
| 地址 | 功能码 | 内容 | 说明 |
|
||||
|------|--------|------|------|
|
||||
| 10H | 03H/06H/10H | 目标步数高位 | 目标位置高16位 |
|
||||
| 11H | 03H/06H/10H | 目标步数低位 | 目标位置低16位 |
|
||||
| 12H | 03H/06H/10H | 保留 | - |
|
||||
| 13H | 03H/06H/10H | 速度 | 运行速度 (rpm) |
|
||||
| 14H | 03H/06H/10H | 加速度 | 0-60000 rpm/s |
|
||||
| 15H | 03H/06H/10H | 精度 | 到位精度设置 |
|
||||
|
||||
### 速度模式寄存器
|
||||
| 地址 | 功能码 | 内容 | 说明 |
|
||||
|------|--------|------|------|
|
||||
| 60H | 03H/06H/10H | 保留 | - |
|
||||
| 61H | 03H/06H/10H | 速度 | 正值正转,负值反转 |
|
||||
| 62H | 03H/06H/10H | 加速度 | 0-60000 rpm/s |
|
||||
|
||||
### 设备参数寄存器
|
||||
| 地址 | 功能码 | 内容 | 默认值 | 说明 |
|
||||
|------|--------|------|--------|------|
|
||||
| E0H | 03H/06H/10H | 设备地址 | 0001H | Modbus从站地址 |
|
||||
| E1H | 03H/06H/10H | 堵转电流 | 0BB8H | 堵转检测电流阈值 |
|
||||
| E2H | 03H/06H/10H | 保留 | 0258H | - |
|
||||
| E3H | 03H/06H/10H | 每圈步数 | 0640H | 细分设置 |
|
||||
| E4H | 03H/06H/10H | 限位开关使能 | F000H | 1-使能, 0-禁用 |
|
||||
| E5H | 03H/06H/10H | 堵转逻辑 | 0000H | 00-断电, 01-对抗 |
|
||||
| E6H | 03H/06H/10H | 堵转时间 | 0000H | 堵转检测时间(ms) |
|
||||
| E7H | 03H/06H/10H | 默认速度 | 1388H | 上电默认速度 |
|
||||
| E8H | 03H/06H/10H | 默认加速度 | EA60H | 上电默认加速度 |
|
||||
| E9H | 03H/06H/10H | 默认精度 | 0064H | 上电默认精度 |
|
||||
| EAH | 03H/06H/10H | 波特率高位 | 0001H | 通信波特率设置 |
|
||||
| EBH | 03H/06H/10H | 波特率低位 | C200H | 115200对应01C200H |
|
||||
|
||||
### 版本信息寄存器 (只读)
|
||||
| 地址 | 功能码 | 内容 | 说明 |
|
||||
|------|--------|------|------|
|
||||
| F0H | 03H | 版本号 | 固件版本信息 |
|
||||
| F1H-F4H | 03H | 型号 | 产品型号信息 |
|
||||
|
||||
## 常用控制指令示例
|
||||
|
||||
### 读取电机状态
|
||||
```
|
||||
发送: 01 03 00 00 00 01 84 0A
|
||||
接收: 01 03 02 00 01 79 84
|
||||
说明: 电机状态为0001H (正在运行)
|
||||
```
|
||||
|
||||
### 读取当前位置
|
||||
```
|
||||
发送: 01 03 00 01 00 02 95 CB
|
||||
接收: 01 03 04 00 19 00 00 2B F4
|
||||
说明: 当前位置为1638400步 (100圈)
|
||||
```
|
||||
|
||||
### 停止电机
|
||||
```
|
||||
发送: 01 10 00 04 00 01 02 00 00 A7 D4
|
||||
接收: 01 10 00 04 00 01 40 08
|
||||
说明: 急停指令
|
||||
```
|
||||
|
||||
### 位置模式运动
|
||||
```
|
||||
发送: 01 10 00 10 00 06 0C 00 19 00 00 00 00 13 88 00 00 00 00 9F FB
|
||||
接收: 01 10 00 10 00 06 41 CE
|
||||
说明: 以5000rpm速度运动到1638400步位置
|
||||
```
|
||||
|
||||
### 速度模式 - 正转
|
||||
```
|
||||
发送: 01 10 00 60 00 04 08 00 00 13 88 00 FA 00 00 F4 77
|
||||
接收: 01 10 00 60 00 04 C1 D4
|
||||
说明: 以5000rpm速度正转
|
||||
```
|
||||
|
||||
### 速度模式 - 反转
|
||||
```
|
||||
发送: 01 10 00 60 00 04 08 00 00 EC 78 00 FA 00 00 A0 6D
|
||||
接收: 01 10 00 60 00 04 C1 D4
|
||||
说明: 以5000rpm速度反转 (EC78H = -5000)
|
||||
```
|
||||
|
||||
### 设置设备地址
|
||||
```
|
||||
发送: 00 06 00 E0 00 02 C9 F1
|
||||
接收: 00 06 00 E0 00 02 C9 F1
|
||||
说明: 将设备地址设置为2
|
||||
```
|
||||
|
||||
## 错误码
|
||||
| 状态码 | 含义 |
|
||||
|--------|------|
|
||||
| 0001H | 功能码错误 |
|
||||
| 0002H | 地址错误 |
|
||||
| 0003H | 长度错误 |
|
||||
|
||||
## CRC校验算法
|
||||
```c
|
||||
public static byte[] ModBusCRC(byte[] data, int offset, int cnt) {
|
||||
int wCrc = 0x0000FFFF;
|
||||
byte[] CRC = new byte[2];
|
||||
for (int i = 0; i < cnt; i++) {
|
||||
wCrc ^= ((data[i + offset]) & 0xFF);
|
||||
for (int j = 0; j < 8; j++) {
|
||||
if ((wCrc & 0x00000001) == 1) {
|
||||
wCrc >>= 1;
|
||||
wCrc ^= 0x0000A001;
|
||||
} else {
|
||||
wCrc >>= 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
CRC[1] = (byte) ((wCrc & 0x0000FF00) >> 8);
|
||||
CRC[0] = (byte) (wCrc & 0x000000FF);
|
||||
return CRC;
|
||||
}
|
||||
```
|
||||
|
||||
## 注意事项
|
||||
1. 所有16位数据采用大端序传输
|
||||
2. 步数计算: 实际步数 = 高位<<16 | 低位
|
||||
3. 负数使用补码表示
|
||||
4. PWM输出K脚: 0%开漏, 100%接地, 其他输出1KHz PWM
|
||||
5. 光电开关需使用NPN开漏型
|
||||
6. 限位开关: LF正向, LB反向
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,269 +0,0 @@
|
||||
# LaiYu_Liquid 液体处理工作站
|
||||
|
||||
## 概述
|
||||
|
||||
LaiYu_Liquid 是一个完全集成到 UniLabOS 的自动化液体处理工作站,基于 RS485 通信协议,专为精确的液体分配和转移操作而设计。本模块已完成生产环境部署准备,提供完整的硬件控制、资源管理和标准化接口。
|
||||
|
||||
## 系统组成
|
||||
|
||||
### 硬件组件
|
||||
- **XYZ三轴运动平台**: 3个RS485步进电机驱动(地址:X轴=0x01, Y轴=0x02, Z轴=0x03)
|
||||
- **SOPA气动式移液器**: RS485总线控制,支持精密液体处理操作
|
||||
- **通信接口**: RS485转USB模块,默认波特率115200
|
||||
- **机械结构**: 稳固工作台面,支持离心管架、96孔板等标准实验耗材
|
||||
|
||||
### 软件架构
|
||||
- **驱动层**: 底层硬件通信驱动,支持RS485协议
|
||||
- **控制层**: 高级控制逻辑和坐标系管理
|
||||
- **抽象层**: 完全符合UniLabOS标准的液体处理接口
|
||||
- **资源层**: 标准化的实验器具和耗材管理
|
||||
|
||||
## 🎯 生产就绪组件
|
||||
|
||||
### ✅ 核心驱动程序 (`drivers/`)
|
||||
- **`sopa_pipette_driver.py`** - SOPA移液器完整驱动
|
||||
- 支持液体吸取、分配、检测
|
||||
- 完整的错误处理和状态管理
|
||||
- 生产级别的通信协议实现
|
||||
|
||||
- **`xyz_stepper_driver.py`** - XYZ三轴步进电机驱动
|
||||
- 精确的位置控制和运动规划
|
||||
- 安全限位和错误检测
|
||||
- 高性能运动控制算法
|
||||
|
||||
### ✅ 高级控制器 (`controllers/`)
|
||||
- **`pipette_controller.py`** - 移液控制器
|
||||
- 封装高级液体处理功能
|
||||
- 支持多种液体类型和处理参数
|
||||
- 智能错误恢复机制
|
||||
|
||||
- **`xyz_controller.py`** - XYZ运动控制器
|
||||
- 坐标系管理和转换
|
||||
- 运动路径优化
|
||||
- 安全运动控制
|
||||
|
||||
### ✅ UniLabOS集成 (`core/LaiYu_Liquid.py`)
|
||||
- **完整的液体处理抽象接口**
|
||||
- **标准化的资源管理系统**
|
||||
- **与PyLabRobot兼容的后端实现**
|
||||
- **生产级别的错误处理和日志记录**
|
||||
|
||||
### ✅ 资源管理系统
|
||||
- **`laiyu_liquid_res.py`** - 标准化资源定义
|
||||
- 96孔板、离心管架、枪头架等标准器具
|
||||
- 自动化的资源创建和配置函数
|
||||
- 与工作台布局的完美集成
|
||||
|
||||
### ✅ 配置管理 (`config/`)
|
||||
- **`config/deck.json`** - 工作台布局配置
|
||||
- 精确的空间定义和槽位管理
|
||||
- 支持多种实验器具的标准化放置
|
||||
- 可扩展的配置架构
|
||||
|
||||
- **`__init__.py`** - 模块集成和导出
|
||||
- 完整的API导出和版本管理
|
||||
- 依赖检查和安装验证
|
||||
- 专业的模块信息展示
|
||||
|
||||
<!-- ### ✅ 可视化支持
|
||||
- **`rviz_backend.py`** - RViz可视化后端
|
||||
- 实时运动状态可视化
|
||||
- 液体处理过程监控
|
||||
- 与ROS系统的无缝集成 -->
|
||||
|
||||
## 🚀 核心功能特性
|
||||
|
||||
### 液体处理能力
|
||||
- **精密体积控制**: 支持1-1000μL精确分配
|
||||
- **多种液体类型**: 水性、有机溶剂、粘稠液体等
|
||||
- **智能检测**: 液位检测、气泡检测、堵塞检测
|
||||
- **自动化流程**: 完整的吸取-转移-分配工作流
|
||||
|
||||
### 运动控制系统
|
||||
- **三轴精密定位**: 微米级精度控制
|
||||
- **路径优化**: 智能运动规划和碰撞避免
|
||||
- **安全机制**: 限位保护、紧急停止、错误恢复
|
||||
- **坐标系管理**: 工作坐标与机械坐标的自动转换
|
||||
|
||||
### 资源管理
|
||||
- **标准化器具**: 支持96孔板、离心管架、枪头架等
|
||||
- **状态跟踪**: 实时监控液体体积、枪头状态等
|
||||
- **自动配置**: 基于JSON的灵活配置系统
|
||||
- **扩展性**: 易于添加新的器具类型
|
||||
|
||||
## 📁 目录结构
|
||||
|
||||
```
|
||||
LaiYu_Liquid/
|
||||
├── __init__.py # 模块初始化和API导出
|
||||
├── readme.md # 本文档
|
||||
├── backend/ # 后端驱动模块
|
||||
│ ├── __init__.py
|
||||
│ └── laiyu_backend.py # PyLabRobot兼容后端
|
||||
├── core/ # 核心模块
|
||||
│ ├── core/
|
||||
│ │ └── LaiYu_Liquid.py # 主设备类
|
||||
│ ├── abstract_protocol.py # 抽象协议
|
||||
│ └── laiyu_liquid_res.py # 设备资源定义
|
||||
├── config/ # 配置文件目录
|
||||
│ └── deck.json # 工作台布局配置
|
||||
├── controllers/ # 高级控制器
|
||||
│ ├── __init__.py
|
||||
│ ├── pipette_controller.py # 移液控制器
|
||||
│ └── xyz_controller.py # XYZ运动控制器
|
||||
├── docs/ # 技术文档
|
||||
│ ├── SOPA气动式移液器RS485控制指令.md
|
||||
│ ├── 步进电机控制指令.md
|
||||
│ └── hardware/ # 硬件相关文档
|
||||
├── drivers/ # 底层驱动程序
|
||||
│ ├── __init__.py
|
||||
│ ├── sopa_pipette_driver.py # SOPA移液器驱动
|
||||
│ └── xyz_stepper_driver.py # XYZ步进电机驱动
|
||||
└── tests/ # 测试文件
|
||||
```
|
||||
|
||||
## 🔧 快速开始
|
||||
|
||||
### 1. 安装和验证
|
||||
|
||||
```python
|
||||
# 验证模块安装
|
||||
from unilabos.devices.laiyu_liquid import (
|
||||
LaiYuLiquid,
|
||||
LaiYuLiquidConfig,
|
||||
create_quick_setup,
|
||||
print_module_info
|
||||
)
|
||||
|
||||
# 查看模块信息
|
||||
print_module_info()
|
||||
|
||||
# 快速创建默认资源
|
||||
resources = create_quick_setup()
|
||||
print(f"已创建 {len(resources)} 个资源")
|
||||
```
|
||||
|
||||
### 2. 基本使用示例
|
||||
|
||||
```python
|
||||
from unilabos.devices.LaiYu_Liquid import (
|
||||
create_quick_setup,
|
||||
create_96_well_plate,
|
||||
create_laiyu_backend
|
||||
)
|
||||
|
||||
# 快速创建默认资源
|
||||
resources = create_quick_setup()
|
||||
print(f"创建了以下资源: {list(resources.keys())}")
|
||||
|
||||
# 创建96孔板
|
||||
plate_96 = create_96_well_plate("test_plate")
|
||||
print(f"96孔板包含 {len(plate_96.children)} 个孔位")
|
||||
|
||||
# 创建后端实例(用于PyLabRobot集成)
|
||||
backend = create_laiyu_backend("LaiYu_Device")
|
||||
print(f"后端设备: {backend.name}")
|
||||
```
|
||||
|
||||
### 3. 后端驱动使用
|
||||
|
||||
```python
|
||||
from unilabos.devices.laiyu_liquid.backend import create_laiyu_backend
|
||||
|
||||
# 创建后端实例
|
||||
backend = create_laiyu_backend("LaiYu_Liquid_Station")
|
||||
|
||||
# 连接设备
|
||||
await backend.connect()
|
||||
|
||||
# 设备归位
|
||||
await backend.home_device()
|
||||
|
||||
# 获取设备状态
|
||||
status = await backend.get_status()
|
||||
print(f"设备状态: {status}")
|
||||
|
||||
# 断开连接
|
||||
await backend.disconnect()
|
||||
```
|
||||
|
||||
### 4. 资源管理示例
|
||||
|
||||
```python
|
||||
from unilabos.devices.LaiYu_Liquid import (
|
||||
create_centrifuge_tube_rack,
|
||||
create_tip_rack,
|
||||
load_deck_config
|
||||
)
|
||||
|
||||
# 加载工作台配置
|
||||
deck_config = load_deck_config()
|
||||
print(f"工作台尺寸: {deck_config['size_x']}x{deck_config['size_y']}mm")
|
||||
|
||||
# 创建不同类型的资源
|
||||
tube_rack = create_centrifuge_tube_rack("sample_rack")
|
||||
tip_rack = create_tip_rack("tip_rack_200ul")
|
||||
|
||||
print(f"离心管架: {tube_rack.name}, 容量: {len(tube_rack.children)} 个位置")
|
||||
print(f"枪头架: {tip_rack.name}, 容量: {len(tip_rack.children)} 个枪头")
|
||||
```
|
||||
|
||||
## 🔍 技术架构
|
||||
|
||||
### 坐标系统
|
||||
- **机械坐标**: 基于步进电机的原始坐标系统
|
||||
- **工作坐标**: 用户友好的实验室坐标系统
|
||||
- **自动转换**: 透明的坐标系转换和校准
|
||||
|
||||
### 通信协议
|
||||
- **RS485总线**: 高可靠性工业通信标准
|
||||
- **Modbus协议**: 标准化的设备通信协议
|
||||
- **错误检测**: 完整的通信错误检测和恢复
|
||||
|
||||
### 安全机制
|
||||
- **限位保护**: 硬件和软件双重限位保护
|
||||
- **紧急停止**: 即时停止所有运动和操作
|
||||
- **状态监控**: 实时设备状态监控和报警
|
||||
|
||||
## 🧪 验证和测试
|
||||
|
||||
### 功能验证
|
||||
```python
|
||||
# 验证模块安装
|
||||
from unilabos.devices.laiyu_liquid import validate_installation
|
||||
validate_installation()
|
||||
|
||||
# 查看模块信息
|
||||
from unilabos.devices.laiyu_liquid import print_module_info
|
||||
print_module_info()
|
||||
```
|
||||
|
||||
### 硬件连接测试
|
||||
```python
|
||||
# 测试SOPA移液器连接
|
||||
from unilabos.devices.laiyu_liquid.drivers import SOPAPipette, SOPAConfig
|
||||
|
||||
config = SOPAConfig(port="/dev/cu.usbserial-3130", address=4)
|
||||
pipette = SOPAPipette(config)
|
||||
success = pipette.connect()
|
||||
print(f"SOPA连接状态: {'成功' if success else '失败'}")
|
||||
```
|
||||
|
||||
## 📚 维护和支持
|
||||
|
||||
### 日志记录
|
||||
- **结构化日志**: 使用Python logging模块的专业日志记录
|
||||
- **错误追踪**: 详细的错误信息和堆栈跟踪
|
||||
- **性能监控**: 操作时间和性能指标记录
|
||||
|
||||
### 配置管理
|
||||
- **JSON配置**: 灵活的JSON格式配置文件
|
||||
- **参数验证**: 自动配置参数验证和错误提示
|
||||
- **热重载**: 支持配置文件的动态重载
|
||||
|
||||
### 扩展性
|
||||
- **模块化设计**: 易于扩展和定制的模块化架构
|
||||
- **插件接口**: 支持第三方插件和扩展
|
||||
- **API兼容**: 向后兼容的API设计
|
||||
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
"""
|
||||
LaiYu_Liquid 驱动程序模块
|
||||
|
||||
该模块包含了LaiYu_Liquid液体处理工作站的硬件驱动程序:
|
||||
- SOPA移液器驱动程序
|
||||
- XYZ步进电机驱动程序
|
||||
"""
|
||||
|
||||
# SOPA移液器驱动程序导入
|
||||
from .sopa_pipette_driver import SOPAPipette, SOPAConfig, SOPAStatusCode
|
||||
|
||||
# XYZ步进电机驱动程序导入
|
||||
from .xyz_stepper_driver import StepperMotorDriver, XYZStepperController, MotorAxis, MotorStatus
|
||||
|
||||
__all__ = [
|
||||
# SOPA移液器
|
||||
"SOPAPipette",
|
||||
"SOPAConfig",
|
||||
"SOPAStatusCode",
|
||||
|
||||
# XYZ步进电机
|
||||
"StepperMotorDriver",
|
||||
"XYZStepperController",
|
||||
"MotorAxis",
|
||||
"MotorStatus",
|
||||
]
|
||||
|
||||
__version__ = "1.0.0"
|
||||
__author__ = "LaiYu_Liquid Driver Team"
|
||||
__description__ = "LaiYu_Liquid 硬件驱动程序集合"
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,663 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
XYZ三轴步进电机B系列驱动程序
|
||||
支持RS485通信,Modbus协议
|
||||
"""
|
||||
|
||||
import serial
|
||||
import struct
|
||||
import time
|
||||
import logging
|
||||
from typing import Optional, Tuple, Dict, Any
|
||||
from enum import Enum
|
||||
from dataclasses import dataclass
|
||||
|
||||
# 配置日志
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MotorAxis(Enum):
|
||||
"""电机轴枚举"""
|
||||
X = 1
|
||||
Y = 2
|
||||
Z = 3
|
||||
|
||||
|
||||
class MotorStatus(Enum):
|
||||
"""电机状态枚举"""
|
||||
STANDBY = 0x0000 # 待机/到位
|
||||
RUNNING = 0x0001 # 运行中
|
||||
COLLISION_STOP = 0x0002 # 碰撞停
|
||||
FORWARD_LIMIT_STOP = 0x0003 # 正光电停
|
||||
REVERSE_LIMIT_STOP = 0x0004 # 反光电停
|
||||
|
||||
|
||||
class ModbusFunction(Enum):
|
||||
"""Modbus功能码"""
|
||||
READ_HOLDING_REGISTERS = 0x03
|
||||
WRITE_SINGLE_REGISTER = 0x06
|
||||
WRITE_MULTIPLE_REGISTERS = 0x10
|
||||
|
||||
|
||||
@dataclass
|
||||
class MotorPosition:
|
||||
"""电机位置信息"""
|
||||
steps: int
|
||||
speed: int
|
||||
current: int
|
||||
status: MotorStatus
|
||||
|
||||
|
||||
class ModbusException(Exception):
|
||||
"""Modbus通信异常"""
|
||||
pass
|
||||
|
||||
|
||||
class StepperMotorDriver:
|
||||
"""步进电机驱动器基类"""
|
||||
|
||||
# 寄存器地址常量
|
||||
REG_STATUS = 0x00
|
||||
REG_POSITION_HIGH = 0x01
|
||||
REG_POSITION_LOW = 0x02
|
||||
REG_ACTUAL_SPEED = 0x03
|
||||
REG_EMERGENCY_STOP = 0x04
|
||||
REG_CURRENT = 0x05
|
||||
REG_ENABLE = 0x06
|
||||
REG_PWM_OUTPUT = 0x07
|
||||
REG_ZERO_SINGLE = 0x0E
|
||||
REG_ZERO_COMMAND = 0x0F
|
||||
|
||||
# 位置模式寄存器
|
||||
REG_TARGET_POSITION_HIGH = 0x10
|
||||
REG_TARGET_POSITION_LOW = 0x11
|
||||
REG_POSITION_SPEED = 0x13
|
||||
REG_POSITION_ACCELERATION = 0x14
|
||||
REG_POSITION_PRECISION = 0x15
|
||||
|
||||
# 速度模式寄存器
|
||||
REG_SPEED_MODE_SPEED = 0x61
|
||||
REG_SPEED_MODE_ACCELERATION = 0x62
|
||||
|
||||
# 设备参数寄存器
|
||||
REG_DEVICE_ADDRESS = 0xE0
|
||||
REG_DEFAULT_SPEED = 0xE7
|
||||
REG_DEFAULT_ACCELERATION = 0xE8
|
||||
|
||||
def __init__(self, port: str, baudrate: int = 115200, timeout: float = 1.0):
|
||||
"""
|
||||
初始化步进电机驱动器
|
||||
|
||||
Args:
|
||||
port: 串口端口名
|
||||
baudrate: 波特率
|
||||
timeout: 通信超时时间
|
||||
"""
|
||||
self.port = port
|
||||
self.baudrate = baudrate
|
||||
self.timeout = timeout
|
||||
self.serial_conn: Optional[serial.Serial] = None
|
||||
|
||||
def connect(self) -> bool:
|
||||
"""
|
||||
建立串口连接
|
||||
|
||||
Returns:
|
||||
连接是否成功
|
||||
"""
|
||||
try:
|
||||
self.serial_conn = serial.Serial(
|
||||
port=self.port,
|
||||
baudrate=self.baudrate,
|
||||
bytesize=serial.EIGHTBITS,
|
||||
parity=serial.PARITY_NONE,
|
||||
stopbits=serial.STOPBITS_ONE,
|
||||
timeout=self.timeout
|
||||
)
|
||||
logger.info(f"已连接到串口: {self.port}")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"串口连接失败: {e}")
|
||||
return False
|
||||
|
||||
def disconnect(self) -> None:
|
||||
"""关闭串口连接"""
|
||||
if self.serial_conn and self.serial_conn.is_open:
|
||||
self.serial_conn.close()
|
||||
logger.info("串口连接已关闭")
|
||||
|
||||
def __enter__(self):
|
||||
"""上下文管理器入口"""
|
||||
if self.connect():
|
||||
return self
|
||||
raise ModbusException("无法建立串口连接")
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||
"""上下文管理器出口"""
|
||||
self.disconnect()
|
||||
|
||||
@staticmethod
|
||||
def calculate_crc(data: bytes) -> bytes:
|
||||
"""
|
||||
计算Modbus CRC校验码
|
||||
|
||||
Args:
|
||||
data: 待校验的数据
|
||||
|
||||
Returns:
|
||||
CRC校验码 (2字节)
|
||||
"""
|
||||
crc = 0xFFFF
|
||||
for byte in data:
|
||||
crc ^= byte
|
||||
for _ in range(8):
|
||||
if crc & 0x0001:
|
||||
crc >>= 1
|
||||
crc ^= 0xA001
|
||||
else:
|
||||
crc >>= 1
|
||||
return struct.pack('<H', crc)
|
||||
|
||||
def _send_command(self, slave_addr: int, data: bytes) -> bytes:
|
||||
"""
|
||||
发送Modbus命令并接收响应
|
||||
|
||||
Args:
|
||||
slave_addr: 从站地址
|
||||
data: 命令数据
|
||||
|
||||
Returns:
|
||||
响应数据
|
||||
|
||||
Raises:
|
||||
ModbusException: 通信异常
|
||||
"""
|
||||
if not self.serial_conn or not self.serial_conn.is_open:
|
||||
raise ModbusException("串口未连接")
|
||||
|
||||
# 构建完整命令
|
||||
command = bytes([slave_addr]) + data
|
||||
crc = self.calculate_crc(command)
|
||||
full_command = command + crc
|
||||
|
||||
# 清空接收缓冲区
|
||||
self.serial_conn.reset_input_buffer()
|
||||
|
||||
# 发送命令
|
||||
self.serial_conn.write(full_command)
|
||||
logger.debug(f"发送命令: {' '.join(f'{b:02X}' for b in full_command)}")
|
||||
|
||||
# 等待响应
|
||||
time.sleep(0.01) # 短暂延时
|
||||
|
||||
# 读取响应
|
||||
response = self.serial_conn.read(256) # 最大读取256字节
|
||||
if not response:
|
||||
raise ModbusException("未收到响应")
|
||||
|
||||
logger.debug(f"接收响应: {' '.join(f'{b:02X}' for b in response)}")
|
||||
|
||||
# 验证CRC
|
||||
if len(response) < 3:
|
||||
raise ModbusException("响应数据长度不足")
|
||||
|
||||
data_part = response[:-2]
|
||||
received_crc = response[-2:]
|
||||
calculated_crc = self.calculate_crc(data_part)
|
||||
|
||||
if received_crc != calculated_crc:
|
||||
raise ModbusException("CRC校验失败")
|
||||
|
||||
return response
|
||||
|
||||
def read_registers(self, slave_addr: int, start_addr: int, count: int) -> list:
|
||||
"""
|
||||
读取保持寄存器
|
||||
|
||||
Args:
|
||||
slave_addr: 从站地址
|
||||
start_addr: 起始地址
|
||||
count: 寄存器数量
|
||||
|
||||
Returns:
|
||||
寄存器值列表
|
||||
"""
|
||||
data = struct.pack('>BHH', ModbusFunction.READ_HOLDING_REGISTERS.value, start_addr, count)
|
||||
response = self._send_command(slave_addr, data)
|
||||
|
||||
if len(response) < 5:
|
||||
raise ModbusException("响应长度不足")
|
||||
|
||||
if response[1] != ModbusFunction.READ_HOLDING_REGISTERS.value:
|
||||
raise ModbusException(f"功能码错误: {response[1]:02X}")
|
||||
|
||||
byte_count = response[2]
|
||||
values = []
|
||||
for i in range(0, byte_count, 2):
|
||||
value = struct.unpack('>H', response[3+i:5+i])[0]
|
||||
values.append(value)
|
||||
|
||||
return values
|
||||
|
||||
def write_single_register(self, slave_addr: int, addr: int, value: int) -> bool:
|
||||
"""
|
||||
写入单个寄存器
|
||||
|
||||
Args:
|
||||
slave_addr: 从站地址
|
||||
addr: 寄存器地址
|
||||
value: 寄存器值
|
||||
|
||||
Returns:
|
||||
写入是否成功
|
||||
"""
|
||||
data = struct.pack('>BHH', ModbusFunction.WRITE_SINGLE_REGISTER.value, addr, value)
|
||||
response = self._send_command(slave_addr, data)
|
||||
|
||||
return len(response) >= 8 and response[1] == ModbusFunction.WRITE_SINGLE_REGISTER.value
|
||||
|
||||
def write_multiple_registers(self, slave_addr: int, start_addr: int, values: list) -> bool:
|
||||
"""
|
||||
写入多个寄存器
|
||||
|
||||
Args:
|
||||
slave_addr: 从站地址
|
||||
start_addr: 起始地址
|
||||
values: 寄存器值列表
|
||||
|
||||
Returns:
|
||||
写入是否成功
|
||||
"""
|
||||
byte_count = len(values) * 2
|
||||
data = struct.pack('>BHHB', ModbusFunction.WRITE_MULTIPLE_REGISTERS.value,
|
||||
start_addr, len(values), byte_count)
|
||||
|
||||
for value in values:
|
||||
data += struct.pack('>H', value)
|
||||
|
||||
response = self._send_command(slave_addr, data)
|
||||
|
||||
return len(response) >= 8 and response[1] == ModbusFunction.WRITE_MULTIPLE_REGISTERS.value
|
||||
|
||||
|
||||
class XYZStepperController(StepperMotorDriver):
|
||||
"""XYZ三轴步进电机控制器"""
|
||||
|
||||
# 电机配置常量
|
||||
STEPS_PER_REVOLUTION = 16384 # 每圈步数
|
||||
|
||||
def __init__(self, port: str, baudrate: int = 115200, timeout: float = 1.0):
|
||||
"""
|
||||
初始化XYZ三轴步进电机控制器
|
||||
|
||||
Args:
|
||||
port: 串口端口名
|
||||
baudrate: 波特率
|
||||
timeout: 通信超时时间
|
||||
"""
|
||||
super().__init__(port, baudrate, timeout)
|
||||
self.axis_addresses = {
|
||||
MotorAxis.X: 1,
|
||||
MotorAxis.Y: 2,
|
||||
MotorAxis.Z: 3
|
||||
}
|
||||
|
||||
def degrees_to_steps(self, degrees: float) -> int:
|
||||
"""
|
||||
将角度转换为步数
|
||||
|
||||
Args:
|
||||
degrees: 角度值
|
||||
|
||||
Returns:
|
||||
对应的步数
|
||||
"""
|
||||
return int(degrees * self.STEPS_PER_REVOLUTION / 360.0)
|
||||
|
||||
def steps_to_degrees(self, steps: int) -> float:
|
||||
"""
|
||||
将步数转换为角度
|
||||
|
||||
Args:
|
||||
steps: 步数
|
||||
|
||||
Returns:
|
||||
对应的角度值
|
||||
"""
|
||||
return steps * 360.0 / self.STEPS_PER_REVOLUTION
|
||||
|
||||
def revolutions_to_steps(self, revolutions: float) -> int:
|
||||
"""
|
||||
将圈数转换为步数
|
||||
|
||||
Args:
|
||||
revolutions: 圈数
|
||||
|
||||
Returns:
|
||||
对应的步数
|
||||
"""
|
||||
return int(revolutions * self.STEPS_PER_REVOLUTION)
|
||||
|
||||
def steps_to_revolutions(self, steps: int) -> float:
|
||||
"""
|
||||
将步数转换为圈数
|
||||
|
||||
Args:
|
||||
steps: 步数
|
||||
|
||||
Returns:
|
||||
对应的圈数
|
||||
"""
|
||||
return steps / self.STEPS_PER_REVOLUTION
|
||||
|
||||
def get_motor_status(self, axis: MotorAxis) -> MotorPosition:
|
||||
"""
|
||||
获取电机状态信息
|
||||
|
||||
Args:
|
||||
axis: 电机轴
|
||||
|
||||
Returns:
|
||||
电机位置信息
|
||||
"""
|
||||
addr = self.axis_addresses[axis]
|
||||
|
||||
# 读取状态、位置、速度、电流
|
||||
values = self.read_registers(addr, self.REG_STATUS, 6)
|
||||
|
||||
status = MotorStatus(values[0])
|
||||
position_high = values[1]
|
||||
position_low = values[2]
|
||||
speed = values[3]
|
||||
current = values[5]
|
||||
|
||||
# 合并32位位置
|
||||
position = (position_high << 16) | position_low
|
||||
# 处理有符号数
|
||||
if position > 0x7FFFFFFF:
|
||||
position -= 0x100000000
|
||||
|
||||
return MotorPosition(position, speed, current, status)
|
||||
|
||||
def emergency_stop(self, axis: MotorAxis) -> bool:
|
||||
"""
|
||||
紧急停止电机
|
||||
|
||||
Args:
|
||||
axis: 电机轴
|
||||
|
||||
Returns:
|
||||
操作是否成功
|
||||
"""
|
||||
addr = self.axis_addresses[axis]
|
||||
return self.write_single_register(addr, self.REG_EMERGENCY_STOP, 0x0000)
|
||||
|
||||
def enable_motor(self, axis: MotorAxis, enable: bool = True) -> bool:
|
||||
"""
|
||||
使能/失能电机
|
||||
|
||||
Args:
|
||||
axis: 电机轴
|
||||
enable: True为使能,False为失能
|
||||
|
||||
Returns:
|
||||
操作是否成功
|
||||
"""
|
||||
addr = self.axis_addresses[axis]
|
||||
value = 0x0001 if enable else 0x0000
|
||||
return self.write_single_register(addr, self.REG_ENABLE, value)
|
||||
|
||||
def move_to_position(self, axis: MotorAxis, position: int, speed: int = 5000,
|
||||
acceleration: int = 1000, precision: int = 100) -> bool:
|
||||
"""
|
||||
移动到指定位置
|
||||
|
||||
Args:
|
||||
axis: 电机轴
|
||||
position: 目标位置(步数)
|
||||
speed: 运行速度(rpm)
|
||||
acceleration: 加速度(rpm/s)
|
||||
precision: 到位精度
|
||||
|
||||
Returns:
|
||||
操作是否成功
|
||||
"""
|
||||
addr = self.axis_addresses[axis]
|
||||
|
||||
# 处理32位位置
|
||||
if position < 0:
|
||||
position += 0x100000000
|
||||
|
||||
position_high = (position >> 16) & 0xFFFF
|
||||
position_low = position & 0xFFFF
|
||||
|
||||
values = [
|
||||
position_high, # 目标位置高位
|
||||
position_low, # 目标位置低位
|
||||
0x0000, # 保留
|
||||
speed, # 速度
|
||||
acceleration, # 加速度
|
||||
precision # 精度
|
||||
]
|
||||
|
||||
return self.write_multiple_registers(addr, self.REG_TARGET_POSITION_HIGH, values)
|
||||
|
||||
def set_speed_mode(self, axis: MotorAxis, speed: int, acceleration: int = 1000) -> bool:
|
||||
"""
|
||||
设置速度模式运行
|
||||
|
||||
Args:
|
||||
axis: 电机轴
|
||||
speed: 运行速度(rpm),正值正转,负值反转
|
||||
acceleration: 加速度(rpm/s)
|
||||
|
||||
Returns:
|
||||
操作是否成功
|
||||
"""
|
||||
addr = self.axis_addresses[axis]
|
||||
|
||||
# 处理负数
|
||||
if speed < 0:
|
||||
speed = 0x10000 + speed # 补码表示
|
||||
|
||||
values = [0x0000, speed, acceleration, 0x0000]
|
||||
|
||||
return self.write_multiple_registers(addr, 0x60, values)
|
||||
|
||||
def home_axis(self, axis: MotorAxis) -> bool:
|
||||
"""
|
||||
轴归零操作
|
||||
|
||||
Args:
|
||||
axis: 电机轴
|
||||
|
||||
Returns:
|
||||
操作是否成功
|
||||
"""
|
||||
addr = self.axis_addresses[axis]
|
||||
return self.write_single_register(addr, self.REG_ZERO_SINGLE, 0x0001)
|
||||
|
||||
def wait_for_completion(self, axis: MotorAxis, timeout: float = 30.0) -> bool:
|
||||
"""
|
||||
等待电机运动完成
|
||||
|
||||
Args:
|
||||
axis: 电机轴
|
||||
timeout: 超时时间(秒)
|
||||
|
||||
Returns:
|
||||
是否在超时前完成
|
||||
"""
|
||||
start_time = time.time()
|
||||
|
||||
while time.time() - start_time < timeout:
|
||||
status = self.get_motor_status(axis)
|
||||
if status.status == MotorStatus.STANDBY:
|
||||
return True
|
||||
time.sleep(0.1)
|
||||
|
||||
logger.warning(f"{axis.name}轴运动超时")
|
||||
return False
|
||||
|
||||
def move_xyz(self, x: Optional[int] = None, y: Optional[int] = None, z: Optional[int] = None,
|
||||
speed: int = 5000, acceleration: int = 1000) -> Dict[MotorAxis, bool]:
|
||||
"""
|
||||
同时控制XYZ轴移动
|
||||
|
||||
Args:
|
||||
x: X轴目标位置
|
||||
y: Y轴目标位置
|
||||
z: Z轴目标位置
|
||||
speed: 运行速度
|
||||
acceleration: 加速度
|
||||
|
||||
Returns:
|
||||
各轴操作结果字典
|
||||
"""
|
||||
results = {}
|
||||
|
||||
if x is not None:
|
||||
results[MotorAxis.X] = self.move_to_position(MotorAxis.X, x, speed, acceleration)
|
||||
|
||||
if y is not None:
|
||||
results[MotorAxis.Y] = self.move_to_position(MotorAxis.Y, y, speed, acceleration)
|
||||
|
||||
if z is not None:
|
||||
results[MotorAxis.Z] = self.move_to_position(MotorAxis.Z, z, speed, acceleration)
|
||||
|
||||
return results
|
||||
|
||||
def move_xyz_degrees(self, x_deg: Optional[float] = None, y_deg: Optional[float] = None,
|
||||
z_deg: Optional[float] = None, speed: int = 5000,
|
||||
acceleration: int = 1000) -> Dict[MotorAxis, bool]:
|
||||
"""
|
||||
使用角度值同时移动多个轴到指定位置
|
||||
|
||||
Args:
|
||||
x_deg: X轴目标角度(度)
|
||||
y_deg: Y轴目标角度(度)
|
||||
z_deg: Z轴目标角度(度)
|
||||
speed: 移动速度
|
||||
acceleration: 加速度
|
||||
|
||||
Returns:
|
||||
各轴移动操作结果
|
||||
"""
|
||||
# 将角度转换为步数
|
||||
x_steps = self.degrees_to_steps(x_deg) if x_deg is not None else None
|
||||
y_steps = self.degrees_to_steps(y_deg) if y_deg is not None else None
|
||||
z_steps = self.degrees_to_steps(z_deg) if z_deg is not None else None
|
||||
|
||||
return self.move_xyz(x_steps, y_steps, z_steps, speed, acceleration)
|
||||
|
||||
def move_xyz_revolutions(self, x_rev: Optional[float] = None, y_rev: Optional[float] = None,
|
||||
z_rev: Optional[float] = None, speed: int = 5000,
|
||||
acceleration: int = 1000) -> Dict[MotorAxis, bool]:
|
||||
"""
|
||||
使用圈数值同时移动多个轴到指定位置
|
||||
|
||||
Args:
|
||||
x_rev: X轴目标圈数
|
||||
y_rev: Y轴目标圈数
|
||||
z_rev: Z轴目标圈数
|
||||
speed: 移动速度
|
||||
acceleration: 加速度
|
||||
|
||||
Returns:
|
||||
各轴移动操作结果
|
||||
"""
|
||||
# 将圈数转换为步数
|
||||
x_steps = self.revolutions_to_steps(x_rev) if x_rev is not None else None
|
||||
y_steps = self.revolutions_to_steps(y_rev) if y_rev is not None else None
|
||||
z_steps = self.revolutions_to_steps(z_rev) if z_rev is not None else None
|
||||
|
||||
return self.move_xyz(x_steps, y_steps, z_steps, speed, acceleration)
|
||||
|
||||
def move_to_position_degrees(self, axis: MotorAxis, degrees: float, speed: int = 5000,
|
||||
acceleration: int = 1000, precision: int = 100) -> bool:
|
||||
"""
|
||||
使用角度值移动单个轴到指定位置
|
||||
|
||||
Args:
|
||||
axis: 电机轴
|
||||
degrees: 目标角度(度)
|
||||
speed: 移动速度
|
||||
acceleration: 加速度
|
||||
precision: 精度
|
||||
|
||||
Returns:
|
||||
移动操作是否成功
|
||||
"""
|
||||
steps = self.degrees_to_steps(degrees)
|
||||
return self.move_to_position(axis, steps, speed, acceleration, precision)
|
||||
|
||||
def move_to_position_revolutions(self, axis: MotorAxis, revolutions: float, speed: int = 5000,
|
||||
acceleration: int = 1000, precision: int = 100) -> bool:
|
||||
"""
|
||||
使用圈数值移动单个轴到指定位置
|
||||
|
||||
Args:
|
||||
axis: 电机轴
|
||||
revolutions: 目标圈数
|
||||
speed: 移动速度
|
||||
acceleration: 加速度
|
||||
precision: 精度
|
||||
|
||||
Returns:
|
||||
移动操作是否成功
|
||||
"""
|
||||
steps = self.revolutions_to_steps(revolutions)
|
||||
return self.move_to_position(axis, steps, speed, acceleration, precision)
|
||||
|
||||
def stop_all_axes(self) -> Dict[MotorAxis, bool]:
|
||||
"""
|
||||
紧急停止所有轴
|
||||
|
||||
Returns:
|
||||
各轴停止结果字典
|
||||
"""
|
||||
results = {}
|
||||
for axis in MotorAxis:
|
||||
results[axis] = self.emergency_stop(axis)
|
||||
return results
|
||||
|
||||
def enable_all_axes(self, enable: bool = True) -> Dict[MotorAxis, bool]:
|
||||
"""
|
||||
使能/失能所有轴
|
||||
|
||||
Args:
|
||||
enable: True为使能,False为失能
|
||||
|
||||
Returns:
|
||||
各轴操作结果字典
|
||||
"""
|
||||
results = {}
|
||||
for axis in MotorAxis:
|
||||
results[axis] = self.enable_motor(axis, enable)
|
||||
return results
|
||||
|
||||
def get_all_positions(self) -> Dict[MotorAxis, MotorPosition]:
|
||||
"""
|
||||
获取所有轴的位置信息
|
||||
|
||||
Returns:
|
||||
各轴位置信息字典
|
||||
"""
|
||||
positions = {}
|
||||
for axis in MotorAxis:
|
||||
positions[axis] = self.get_motor_status(axis)
|
||||
return positions
|
||||
|
||||
def home_all_axes(self) -> Dict[MotorAxis, bool]:
|
||||
"""
|
||||
所有轴归零
|
||||
|
||||
Returns:
|
||||
各轴归零结果字典
|
||||
"""
|
||||
results = {}
|
||||
for axis in MotorAxis:
|
||||
results[axis] = self.home_axis(axis)
|
||||
return results
|
||||
@@ -1,13 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
LaiYu液体处理设备测试模块
|
||||
|
||||
该模块包含LaiYu液体处理设备的测试用例:
|
||||
- test_deck_config.py: 工作台配置测试
|
||||
|
||||
作者: UniLab团队
|
||||
版本: 2.0.0
|
||||
"""
|
||||
|
||||
__all__ = []
|
||||
@@ -1,315 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
测试脚本:验证更新后的deck配置是否正常工作
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
import json
|
||||
|
||||
# 添加项目根目录到Python路径
|
||||
project_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
sys.path.insert(0, project_root)
|
||||
|
||||
def test_config_loading():
|
||||
"""测试配置文件加载功能"""
|
||||
print("=" * 50)
|
||||
print("测试配置文件加载功能")
|
||||
print("=" * 50)
|
||||
|
||||
try:
|
||||
# 直接测试配置文件加载
|
||||
config_path = os.path.join(os.path.dirname(__file__), "controllers", "deckconfig.json")
|
||||
fallback_path = os.path.join(os.path.dirname(__file__), "config", "deck.json")
|
||||
|
||||
config = None
|
||||
config_source = ""
|
||||
|
||||
if os.path.exists(config_path):
|
||||
with open(config_path, 'r', encoding='utf-8') as f:
|
||||
config = json.load(f)
|
||||
config_source = "config/deckconfig.json"
|
||||
elif os.path.exists(fallback_path):
|
||||
with open(fallback_path, 'r', encoding='utf-8') as f:
|
||||
config = json.load(f)
|
||||
config_source = "config/deck.json"
|
||||
else:
|
||||
print("❌ 配置文件不存在")
|
||||
return False
|
||||
|
||||
print(f"✅ 配置文件加载成功: {config_source}")
|
||||
print(f" - 甲板尺寸: {config.get('size_x', 'N/A')} x {config.get('size_y', 'N/A')} x {config.get('size_z', 'N/A')}")
|
||||
print(f" - 子模块数量: {len(config.get('children', []))}")
|
||||
|
||||
# 检查各个模块是否存在
|
||||
modules = config.get('children', [])
|
||||
module_types = [module.get('type') for module in modules]
|
||||
module_names = [module.get('name') for module in modules]
|
||||
|
||||
print(f" - 模块类型: {', '.join(set(filter(None, module_types)))}")
|
||||
print(f" - 模块名称: {', '.join(filter(None, module_names))}")
|
||||
|
||||
return config
|
||||
except Exception as e:
|
||||
print(f"❌ 配置文件加载失败: {e}")
|
||||
return None
|
||||
|
||||
def test_module_coordinates(config):
|
||||
"""测试各模块的坐标信息"""
|
||||
print("\n" + "=" * 50)
|
||||
print("测试模块坐标信息")
|
||||
print("=" * 50)
|
||||
|
||||
if not config:
|
||||
print("❌ 配置为空,无法测试")
|
||||
return False
|
||||
|
||||
modules = config.get('children', [])
|
||||
|
||||
for module in modules:
|
||||
module_name = module.get('name', '未知模块')
|
||||
module_type = module.get('type', '未知类型')
|
||||
position = module.get('position', {})
|
||||
size = module.get('size', {})
|
||||
|
||||
print(f"\n模块: {module_name} ({module_type})")
|
||||
print(f" - 位置: ({position.get('x', 0)}, {position.get('y', 0)}, {position.get('z', 0)})")
|
||||
print(f" - 尺寸: {size.get('x', 0)} x {size.get('y', 0)} x {size.get('z', 0)}")
|
||||
|
||||
# 检查孔位信息
|
||||
wells = module.get('wells', [])
|
||||
if wells:
|
||||
print(f" - 孔位数量: {len(wells)}")
|
||||
|
||||
# 显示前几个和后几个孔位的坐标
|
||||
sample_wells = wells[:3] + wells[-3:] if len(wells) > 6 else wells
|
||||
for well in sample_wells:
|
||||
well_id = well.get('id', '未知')
|
||||
well_pos = well.get('position', {})
|
||||
print(f" {well_id}: ({well_pos.get('x', 0)}, {well_pos.get('y', 0)}, {well_pos.get('z', 0)})")
|
||||
else:
|
||||
print(f" - 无孔位信息")
|
||||
|
||||
return True
|
||||
|
||||
def test_coordinate_ranges(config):
|
||||
"""测试坐标范围的合理性"""
|
||||
print("\n" + "=" * 50)
|
||||
print("测试坐标范围合理性")
|
||||
print("=" * 50)
|
||||
|
||||
if not config:
|
||||
print("❌ 配置为空,无法测试")
|
||||
return False
|
||||
|
||||
deck_size = {
|
||||
'x': config.get('size_x', 340),
|
||||
'y': config.get('size_y', 250),
|
||||
'z': config.get('size_z', 160)
|
||||
}
|
||||
|
||||
print(f"甲板尺寸: {deck_size['x']} x {deck_size['y']} x {deck_size['z']}")
|
||||
|
||||
modules = config.get('children', [])
|
||||
all_coordinates = []
|
||||
|
||||
for module in modules:
|
||||
module_name = module.get('name', '未知模块')
|
||||
wells = module.get('wells', [])
|
||||
|
||||
for well in wells:
|
||||
well_pos = well.get('position', {})
|
||||
x, y, z = well_pos.get('x', 0), well_pos.get('y', 0), well_pos.get('z', 0)
|
||||
all_coordinates.append((x, y, z, f"{module_name}:{well.get('id', '未知')}"))
|
||||
|
||||
if not all_coordinates:
|
||||
print("❌ 没有找到任何坐标信息")
|
||||
return False
|
||||
|
||||
# 计算坐标范围
|
||||
x_coords = [coord[0] for coord in all_coordinates]
|
||||
y_coords = [coord[1] for coord in all_coordinates]
|
||||
z_coords = [coord[2] for coord in all_coordinates]
|
||||
|
||||
x_range = (min(x_coords), max(x_coords))
|
||||
y_range = (min(y_coords), max(y_coords))
|
||||
z_range = (min(z_coords), max(z_coords))
|
||||
|
||||
print(f"X坐标范围: {x_range[0]:.2f} ~ {x_range[1]:.2f}")
|
||||
print(f"Y坐标范围: {y_range[0]:.2f} ~ {y_range[1]:.2f}")
|
||||
print(f"Z坐标范围: {z_range[0]:.2f} ~ {z_range[1]:.2f}")
|
||||
|
||||
# 检查是否超出甲板范围
|
||||
issues = []
|
||||
if x_range[1] > deck_size['x']:
|
||||
issues.append(f"X坐标超出甲板范围: {x_range[1]} > {deck_size['x']}")
|
||||
if y_range[1] > deck_size['y']:
|
||||
issues.append(f"Y坐标超出甲板范围: {y_range[1]} > {deck_size['y']}")
|
||||
if z_range[1] > deck_size['z']:
|
||||
issues.append(f"Z坐标超出甲板范围: {z_range[1]} > {deck_size['z']}")
|
||||
|
||||
if x_range[0] < 0:
|
||||
issues.append(f"X坐标为负值: {x_range[0]}")
|
||||
if y_range[0] < 0:
|
||||
issues.append(f"Y坐标为负值: {y_range[0]}")
|
||||
if z_range[0] < 0:
|
||||
issues.append(f"Z坐标为负值: {z_range[0]}")
|
||||
|
||||
if issues:
|
||||
print("⚠️ 发现坐标问题:")
|
||||
for issue in issues:
|
||||
print(f" - {issue}")
|
||||
return False
|
||||
else:
|
||||
print("✅ 所有坐标都在合理范围内")
|
||||
return True
|
||||
|
||||
def test_well_spacing(config):
|
||||
"""测试孔位间距的一致性"""
|
||||
print("\n" + "=" * 50)
|
||||
print("测试孔位间距一致性")
|
||||
print("=" * 50)
|
||||
|
||||
if not config:
|
||||
print("❌ 配置为空,无法测试")
|
||||
return False
|
||||
|
||||
modules = config.get('children', [])
|
||||
|
||||
for module in modules:
|
||||
module_name = module.get('name', '未知模块')
|
||||
module_type = module.get('type', '未知类型')
|
||||
wells = module.get('wells', [])
|
||||
|
||||
if len(wells) < 2:
|
||||
continue
|
||||
|
||||
print(f"\n模块: {module_name} ({module_type})")
|
||||
|
||||
# 计算相邻孔位的间距
|
||||
spacings_x = []
|
||||
spacings_y = []
|
||||
|
||||
# 按行列排序孔位
|
||||
wells_by_row = {}
|
||||
for well in wells:
|
||||
well_id = well.get('id', '')
|
||||
if len(well_id) >= 3: # 如A01格式
|
||||
row = well_id[0]
|
||||
col = int(well_id[1:])
|
||||
if row not in wells_by_row:
|
||||
wells_by_row[row] = {}
|
||||
wells_by_row[row][col] = well
|
||||
|
||||
# 计算同行相邻孔位的X间距
|
||||
for row, cols in wells_by_row.items():
|
||||
sorted_cols = sorted(cols.keys())
|
||||
for i in range(len(sorted_cols) - 1):
|
||||
col1, col2 = sorted_cols[i], sorted_cols[i + 1]
|
||||
if col2 == col1 + 1: # 相邻列
|
||||
pos1 = cols[col1].get('position', {})
|
||||
pos2 = cols[col2].get('position', {})
|
||||
spacing = abs(pos2.get('x', 0) - pos1.get('x', 0))
|
||||
spacings_x.append(spacing)
|
||||
|
||||
# 计算同列相邻孔位的Y间距
|
||||
cols_by_row = {}
|
||||
for well in wells:
|
||||
well_id = well.get('id', '')
|
||||
if len(well_id) >= 3:
|
||||
row = ord(well_id[0]) - ord('A')
|
||||
col = int(well_id[1:])
|
||||
if col not in cols_by_row:
|
||||
cols_by_row[col] = {}
|
||||
cols_by_row[col][row] = well
|
||||
|
||||
for col, rows in cols_by_row.items():
|
||||
sorted_rows = sorted(rows.keys())
|
||||
for i in range(len(sorted_rows) - 1):
|
||||
row1, row2 = sorted_rows[i], sorted_rows[i + 1]
|
||||
if row2 == row1 + 1: # 相邻行
|
||||
pos1 = rows[row1].get('position', {})
|
||||
pos2 = rows[row2].get('position', {})
|
||||
spacing = abs(pos2.get('y', 0) - pos1.get('y', 0))
|
||||
spacings_y.append(spacing)
|
||||
|
||||
# 检查间距一致性
|
||||
if spacings_x:
|
||||
avg_x = sum(spacings_x) / len(spacings_x)
|
||||
max_diff_x = max(abs(s - avg_x) for s in spacings_x)
|
||||
print(f" - X方向平均间距: {avg_x:.2f}mm, 最大偏差: {max_diff_x:.2f}mm")
|
||||
|
||||
if spacings_y:
|
||||
avg_y = sum(spacings_y) / len(spacings_y)
|
||||
max_diff_y = max(abs(s - avg_y) for s in spacings_y)
|
||||
print(f" - Y方向平均间距: {avg_y:.2f}mm, 最大偏差: {max_diff_y:.2f}mm")
|
||||
|
||||
return True
|
||||
|
||||
def main():
|
||||
"""主测试函数"""
|
||||
print("LaiYu液体处理设备配置测试")
|
||||
print("测试时间:", os.popen('date').read().strip())
|
||||
|
||||
# 运行所有测试
|
||||
tests = [
|
||||
("配置文件加载", test_config_loading),
|
||||
]
|
||||
|
||||
config = None
|
||||
results = []
|
||||
|
||||
for test_name, test_func in tests:
|
||||
try:
|
||||
if test_name == "配置文件加载":
|
||||
result = test_func()
|
||||
config = result if result else None
|
||||
results.append((test_name, bool(result)))
|
||||
else:
|
||||
result = test_func(config)
|
||||
results.append((test_name, result))
|
||||
except Exception as e:
|
||||
print(f"❌ 测试 {test_name} 执行失败: {e}")
|
||||
results.append((test_name, False))
|
||||
|
||||
# 如果配置加载成功,运行其他测试
|
||||
if config:
|
||||
additional_tests = [
|
||||
("模块坐标信息", test_module_coordinates),
|
||||
("坐标范围合理性", test_coordinate_ranges),
|
||||
("孔位间距一致性", test_well_spacing)
|
||||
]
|
||||
|
||||
for test_name, test_func in additional_tests:
|
||||
try:
|
||||
result = test_func(config)
|
||||
results.append((test_name, result))
|
||||
except Exception as e:
|
||||
print(f"❌ 测试 {test_name} 执行失败: {e}")
|
||||
results.append((test_name, False))
|
||||
|
||||
# 输出测试总结
|
||||
print("\n" + "=" * 50)
|
||||
print("测试总结")
|
||||
print("=" * 50)
|
||||
|
||||
passed = sum(1 for _, result in results if result)
|
||||
total = len(results)
|
||||
|
||||
for test_name, result in results:
|
||||
status = "✅ 通过" if result else "❌ 失败"
|
||||
print(f" {test_name}: {status}")
|
||||
|
||||
print(f"\n总计: {passed}/{total} 个测试通过")
|
||||
|
||||
if passed == total:
|
||||
print("🎉 所有测试通过!配置更新成功。")
|
||||
return True
|
||||
else:
|
||||
print("⚠️ 部分测试失败,需要进一步检查。")
|
||||
return False
|
||||
|
||||
if __name__ == "__main__":
|
||||
success = main()
|
||||
sys.exit(0 if success else 1)
|
||||
@@ -1,138 +0,0 @@
|
||||
|
||||
import os
|
||||
import time
|
||||
import json
|
||||
import logging
|
||||
from xyz_stepper_driver import ModbusRTUTransport, ModbusClient, XYZStepperController, MotorStatus
|
||||
|
||||
# ========== 日志配置 ==========
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger("XYZ_Debug")
|
||||
|
||||
|
||||
def create_controller(port: str = "/dev/ttyUSB1", baudrate: int = 115200) -> XYZStepperController:
|
||||
"""
|
||||
初始化通信层与三轴控制器
|
||||
"""
|
||||
logger.info(f"🔧 初始化控制器: {port} @ {baudrate}bps")
|
||||
transport = ModbusRTUTransport(port=port, baudrate=baudrate)
|
||||
transport.open()
|
||||
client = ModbusClient(transport)
|
||||
return XYZStepperController(client=client, port=port, baudrate=baudrate)
|
||||
|
||||
|
||||
def load_existing_soft_zero(ctrl: XYZStepperController, path: str = "work_origin.json") -> bool:
|
||||
"""
|
||||
如果已存在软零点文件则加载,否则返回 False
|
||||
"""
|
||||
if not os.path.exists(path):
|
||||
logger.warning("⚠ 未找到已有软零点文件,将等待人工定义新零点。")
|
||||
return False
|
||||
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
origin = data.get("work_origin_steps", {})
|
||||
ctrl.work_origin_steps = origin
|
||||
ctrl.is_homed = True
|
||||
logger.info(f"✔ 已加载软零点文件:{path}")
|
||||
logger.info(f"当前软零点步数: {origin}")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"读取软零点文件失败: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def test_enable_axis(ctrl: XYZStepperController):
|
||||
"""
|
||||
依次使能 X / Y / Z 三轴
|
||||
"""
|
||||
logger.info("=== 测试各轴使能 ===")
|
||||
for axis in ["X", "Y", "Z"]:
|
||||
try:
|
||||
result = ctrl.enable(axis, True)
|
||||
if result:
|
||||
vals = ctrl.get_status(axis)
|
||||
st = MotorStatus(vals[3])
|
||||
logger.info(f"{axis} 轴使能成功,当前状态: {st.name}")
|
||||
else:
|
||||
logger.error(f"{axis} 轴使能失败")
|
||||
except Exception as e:
|
||||
logger.error(f"{axis} 轴使能异常: {e}")
|
||||
time.sleep(0.5)
|
||||
|
||||
|
||||
def test_status_read(ctrl: XYZStepperController):
|
||||
"""
|
||||
读取各轴当前状态(调试)
|
||||
"""
|
||||
logger.info("=== 当前各轴状态 ===")
|
||||
for axis in ["X", "Y", "Z"]:
|
||||
try:
|
||||
vals = ctrl.get_status(axis)
|
||||
st = MotorStatus(vals[3])
|
||||
logger.info(
|
||||
f"{axis}: steps={vals[0]}, speed={vals[1]}, "
|
||||
f"current={vals[2]}, status={st.name}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"获取 {axis} 状态失败: {e}")
|
||||
time.sleep(0.2)
|
||||
|
||||
|
||||
def redefine_soft_zero(ctrl: XYZStepperController):
|
||||
"""
|
||||
手动重新定义软零点
|
||||
"""
|
||||
logger.info("=== ⚙️ 重新定义软零点 ===")
|
||||
ctrl.define_current_as_zero("work_origin.json")
|
||||
logger.info("✅ 新软零点已写入 work_origin.json")
|
||||
|
||||
|
||||
def test_soft_zero_move(ctrl: XYZStepperController):
|
||||
"""
|
||||
以软零点为基准执行三轴运动测试
|
||||
"""
|
||||
logger.info("=== 测试软零点相对运动 ===")
|
||||
ctrl.move_xyz_work(x=100.0, y=100.0, z=40.0, speed=100, acc=800)
|
||||
|
||||
for axis in ["X", "Y", "Z"]:
|
||||
ctrl.wait_complete(axis)
|
||||
|
||||
test_status_read(ctrl)
|
||||
logger.info("✅ 软零点运动测试完成")
|
||||
|
||||
|
||||
def main():
|
||||
ctrl = create_controller(port="/dev/ttyUSB1", baudrate=115200)
|
||||
|
||||
try:
|
||||
test_enable_axis(ctrl)
|
||||
test_status_read(ctrl)
|
||||
|
||||
# === 初始化或加载软零点 ===
|
||||
loaded = load_existing_soft_zero(ctrl)
|
||||
if not loaded:
|
||||
logger.info("👣 首次运行,定义软零点并保存。")
|
||||
ctrl.define_current_as_zero("work_origin.json")
|
||||
|
||||
# === 软零点回归动作 ===
|
||||
ctrl.return_to_work_origin()
|
||||
|
||||
# === 可选软零点运动测试 ===
|
||||
# test_soft_zero_move(ctrl)
|
||||
|
||||
except KeyboardInterrupt:
|
||||
logger.info("🛑 手动中断退出")
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f"❌ 调试出错: {e}")
|
||||
|
||||
finally:
|
||||
if hasattr(ctrl.client, "transport"):
|
||||
ctrl.client.transport.close()
|
||||
logger.info("串口已安全关闭 ✅")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,58 +0,0 @@
|
||||
|
||||
import logging
|
||||
from xyz_stepper_driver import (
|
||||
ModbusRTUTransport,
|
||||
ModbusClient,
|
||||
XYZStepperController,
|
||||
MotorAxis,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("XYZStepperCommTest")
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
|
||||
|
||||
|
||||
def test_xyz_stepper_comm():
|
||||
"""仅测试 Modbus 通信是否正常(并输出寄存器数据,不做电机运动)"""
|
||||
port = "/dev/ttyUSB1"
|
||||
baudrate = 115200
|
||||
timeout = 1.2 # 略长避免响应被截断
|
||||
|
||||
logger.info(f"尝试连接 Modbus 设备 {port} ...")
|
||||
transport = ModbusRTUTransport(port, baudrate=baudrate, timeout=timeout)
|
||||
transport.open()
|
||||
|
||||
client = ModbusClient(transport)
|
||||
ctrl = XYZStepperController(client)
|
||||
|
||||
try:
|
||||
logger.info("✅ 串口已打开,开始读取三个轴状态(打印寄存器内容) ...")
|
||||
for axis in [MotorAxis.X, MotorAxis.Y, MotorAxis.Z]:
|
||||
addr = ctrl.axis_addr[axis]
|
||||
|
||||
try:
|
||||
# # 在 get_status 前打印原始寄存器内容
|
||||
# regs = client.read_registers(addr, ctrl.REG_STATUS, 6)
|
||||
# hex_regs = [f"0x{val:04X}" for val in regs]
|
||||
# logger.info(f"[{axis.name}] 原始寄存器 ({len(regs)} 个): {regs} -> {hex_regs}")
|
||||
|
||||
# 调用 get_status() 正常解析
|
||||
status = ctrl.get_status(axis)
|
||||
logger.info(
|
||||
f"[{axis.name}] ✅ 通信正常: steps={status.steps}, speed={status.speed}, "
|
||||
f"current={status.current}, status={status.status.name}"
|
||||
)
|
||||
|
||||
except Exception as e_axis:
|
||||
logger.error(f"[{axis.name}] ❌ 通信失败: {e_axis}")
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ 通讯测试失败: {e}")
|
||||
|
||||
finally:
|
||||
transport.close()
|
||||
logger.info("🔌 串口已关闭")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
test_xyz_stepper_comm()
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"work_origin_steps": {
|
||||
"x": 11799,
|
||||
"y": 11476,
|
||||
"z": 3312
|
||||
},
|
||||
"timestamp": "2025-11-04T15:31:09.802155"
|
||||
}
|
||||
@@ -1,336 +0,0 @@
|
||||
|
||||
"""
|
||||
XYZ 三轴步进电机驱动(统一字符串参数版)
|
||||
基于 Modbus RTU 协议
|
||||
Author: Xiuyu Chen (Modified by Assistant)
|
||||
"""
|
||||
|
||||
import serial # type: ignore
|
||||
import struct
|
||||
import time
|
||||
import logging
|
||||
from enum import Enum
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional, List, Dict
|
||||
|
||||
# ========== 日志配置 ==========
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger("XYZStepper")
|
||||
|
||||
|
||||
# ========== 层 1:Modbus RTU ==========
|
||||
class ModbusException(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class ModbusRTUTransport:
|
||||
"""底层串口通信层"""
|
||||
|
||||
def __init__(self, port: str, baudrate: int = 115200, timeout: float = 1.2):
|
||||
self.port = port
|
||||
self.baudrate = baudrate
|
||||
self.timeout = timeout
|
||||
self.ser: Optional[serial.Serial] = None
|
||||
|
||||
def open(self):
|
||||
try:
|
||||
self.ser = serial.Serial(
|
||||
port=self.port,
|
||||
baudrate=self.baudrate,
|
||||
bytesize=serial.EIGHTBITS,
|
||||
parity=serial.PARITY_NONE,
|
||||
stopbits=serial.STOPBITS_ONE,
|
||||
timeout=0.02,
|
||||
write_timeout=0.5,
|
||||
)
|
||||
logger.info(f"[RTU] 串口连接成功: {self.port}")
|
||||
except Exception as e:
|
||||
raise ModbusException(f"无法打开串口 {self.port}: {e}")
|
||||
|
||||
def close(self):
|
||||
if self.ser and self.ser.is_open:
|
||||
self.ser.close()
|
||||
logger.info("[RTU] 串口已关闭")
|
||||
|
||||
def send(self, frame: bytes):
|
||||
if not self.ser or not self.ser.is_open:
|
||||
raise ModbusException("串口未连接")
|
||||
|
||||
self.ser.reset_input_buffer()
|
||||
self.ser.write(frame)
|
||||
self.ser.flush()
|
||||
logger.debug(f"[TX] {frame.hex(' ').upper()}")
|
||||
|
||||
def receive(self, expected_len: int) -> bytes:
|
||||
if not self.ser or not self.ser.is_open:
|
||||
raise ModbusException("串口未连接")
|
||||
|
||||
start = time.time()
|
||||
buf = bytearray()
|
||||
while len(buf) < expected_len and (time.time() - start) < self.timeout:
|
||||
chunk = self.ser.read(expected_len - len(buf))
|
||||
if chunk:
|
||||
buf.extend(chunk)
|
||||
else:
|
||||
time.sleep(0.01)
|
||||
return bytes(buf)
|
||||
|
||||
|
||||
# ========== 层 2:Modbus 协议 ==========
|
||||
class ModbusFunction(Enum):
|
||||
READ_HOLDING_REGISTERS = 0x03
|
||||
WRITE_SINGLE_REGISTER = 0x06
|
||||
WRITE_MULTIPLE_REGISTERS = 0x10
|
||||
|
||||
|
||||
class ModbusClient:
|
||||
"""Modbus RTU 客户端"""
|
||||
|
||||
def __init__(self, transport: ModbusRTUTransport):
|
||||
self.transport = transport
|
||||
|
||||
@staticmethod
|
||||
def calc_crc(data: bytes) -> bytes:
|
||||
crc = 0xFFFF
|
||||
for b in data:
|
||||
crc ^= b
|
||||
for _ in range(8):
|
||||
crc = (crc >> 1) ^ 0xA001 if crc & 1 else crc >> 1
|
||||
return struct.pack("<H", crc)
|
||||
|
||||
def send_request(self, addr: int, func: int, payload: bytes) -> bytes:
|
||||
frame = bytes([addr, func]) + payload
|
||||
full = frame + self.calc_crc(frame)
|
||||
self.transport.send(full)
|
||||
time.sleep(0.01)
|
||||
resp = self.transport.ser.read(256)
|
||||
if not resp:
|
||||
raise ModbusException("未收到响应")
|
||||
|
||||
start = resp.find(bytes([addr, func]))
|
||||
if start > 0:
|
||||
resp = resp[start:]
|
||||
if len(resp) < 5:
|
||||
raise ModbusException(f"响应长度不足: {resp.hex(' ').upper()}")
|
||||
if self.calc_crc(resp[:-2]) != resp[-2:]:
|
||||
raise ModbusException("CRC 校验失败")
|
||||
return resp
|
||||
|
||||
def read_registers(self, addr: int, start: int, count: int) -> List[int]:
|
||||
payload = struct.pack(">HH", start, count)
|
||||
resp = self.send_request(addr, ModbusFunction.READ_HOLDING_REGISTERS.value, payload)
|
||||
byte_count = resp[2]
|
||||
regs = [struct.unpack(">H", resp[3 + i:5 + i])[0] for i in range(0, byte_count, 2)]
|
||||
return regs
|
||||
|
||||
def write_single_register(self, addr: int, reg: int, val: int) -> bool:
|
||||
payload = struct.pack(">HH", reg, val)
|
||||
resp = self.send_request(addr, ModbusFunction.WRITE_SINGLE_REGISTER.value, payload)
|
||||
return resp[1] == ModbusFunction.WRITE_SINGLE_REGISTER.value
|
||||
|
||||
def write_multiple_registers(self, addr: int, start: int, values: List[int]) -> bool:
|
||||
byte_count = len(values) * 2
|
||||
payload = struct.pack(">HHB", start, len(values), byte_count)
|
||||
payload += b"".join(struct.pack(">H", v & 0xFFFF) for v in values)
|
||||
resp = self.send_request(addr, ModbusFunction.WRITE_MULTIPLE_REGISTERS.value, payload)
|
||||
return resp[1] == ModbusFunction.WRITE_MULTIPLE_REGISTERS.value
|
||||
|
||||
|
||||
# ========== 层 3:业务逻辑 ==========
|
||||
class MotorAxis(Enum):
|
||||
X = 1
|
||||
Y = 2
|
||||
Z = 3
|
||||
|
||||
|
||||
class MotorStatus(Enum):
|
||||
STANDBY = 0
|
||||
RUNNING = 1
|
||||
COLLISION_STOP = 2
|
||||
FORWARD_LIMIT_STOP = 3
|
||||
REVERSE_LIMIT_STOP = 4
|
||||
|
||||
|
||||
@dataclass
|
||||
class MotorPosition:
|
||||
steps: int
|
||||
speed: int
|
||||
current: int
|
||||
status: MotorStatus
|
||||
|
||||
|
||||
class XYZStepperController:
|
||||
"""XYZ 三轴步进控制器(字符串接口版)"""
|
||||
|
||||
STEPS_PER_REV = 16384
|
||||
LEAD_MM_X, LEAD_MM_Y, LEAD_MM_Z = 80.0, 80.0, 5.0
|
||||
STEPS_PER_MM_X = STEPS_PER_REV / LEAD_MM_X
|
||||
STEPS_PER_MM_Y = STEPS_PER_REV / LEAD_MM_Y
|
||||
STEPS_PER_MM_Z = STEPS_PER_REV / LEAD_MM_Z
|
||||
|
||||
REG_STATUS, REG_POS_HIGH, REG_POS_LOW = 0x00, 0x01, 0x02
|
||||
REG_ACTUAL_SPEED, REG_CURRENT, REG_ENABLE = 0x03, 0x05, 0x06
|
||||
REG_ZERO_CMD, REG_TARGET_HIGH, REG_TARGET_LOW = 0x0F, 0x10, 0x11
|
||||
REG_SPEED, REG_ACCEL, REG_PRECISION, REG_START = 0x13, 0x14, 0x15, 0x16
|
||||
REG_COMMAND = 0x60
|
||||
|
||||
def __init__(self, client: Optional[ModbusClient] = None,
|
||||
port="/dev/ttyUSB0", baudrate=115200,
|
||||
origin_path="unilabos/devices/laiyu_liquid_test/work_origin.json"):
|
||||
if client is None:
|
||||
transport = ModbusRTUTransport(port, baudrate)
|
||||
transport.open()
|
||||
self.client = ModbusClient(transport)
|
||||
else:
|
||||
self.client = client
|
||||
|
||||
self.axis_addr = {MotorAxis.X: 1, MotorAxis.Y: 2, MotorAxis.Z: 3}
|
||||
self.work_origin_steps = {"x": 0, "y": 0, "z": 0}
|
||||
self.is_homed = False
|
||||
self._load_work_origin(origin_path)
|
||||
|
||||
# ========== 基础工具 ==========
|
||||
@staticmethod
|
||||
def s16(v: int) -> int:
|
||||
return v - 0x10000 if v & 0x8000 else v
|
||||
|
||||
@staticmethod
|
||||
def s32(h: int, l: int) -> int:
|
||||
v = (h << 16) | l
|
||||
return v - 0x100000000 if v & 0x80000000 else v
|
||||
|
||||
@classmethod
|
||||
def mm_to_steps(cls, axis: str, mm: float = 0.0) -> int:
|
||||
axis = axis.upper()
|
||||
if axis == "X":
|
||||
return int(mm * cls.STEPS_PER_MM_X)
|
||||
elif axis == "Y":
|
||||
return int(mm * cls.STEPS_PER_MM_Y)
|
||||
elif axis == "Z":
|
||||
return int(mm * cls.STEPS_PER_MM_Z)
|
||||
raise ValueError(f"未知轴: {axis}")
|
||||
|
||||
@classmethod
|
||||
def steps_to_mm(cls, axis: str, steps: int) -> float:
|
||||
axis = axis.upper()
|
||||
if axis == "X":
|
||||
return steps / cls.STEPS_PER_MM_X
|
||||
elif axis == "Y":
|
||||
return steps / cls.STEPS_PER_MM_Y
|
||||
elif axis == "Z":
|
||||
return steps / cls.STEPS_PER_MM_Z
|
||||
raise ValueError(f"未知轴: {axis}")
|
||||
|
||||
# ========== 状态与控制 ==========
|
||||
def get_status(self, axis: str = "Z") -> list:
|
||||
"""返回简化数组格式: [steps, speed, current, status_value]"""
|
||||
if isinstance(axis, MotorAxis):
|
||||
axis_enum = axis
|
||||
elif isinstance(axis, str):
|
||||
axis_enum = MotorAxis[axis.upper()]
|
||||
else:
|
||||
raise TypeError("axis 参数必须为 str 或 MotorAxis")
|
||||
|
||||
vals = self.client.read_registers(self.axis_addr[axis_enum], self.REG_STATUS, 6)
|
||||
return [
|
||||
self.s32(vals[1], vals[2]),
|
||||
self.s16(vals[3]),
|
||||
vals[4],
|
||||
int(MotorStatus(vals[0]).value)
|
||||
]
|
||||
|
||||
def enable(self, axis: str, state: bool) -> bool:
|
||||
a = MotorAxis[axis.upper()]
|
||||
return self.client.write_single_register(self.axis_addr[a], self.REG_ENABLE, 1 if state else 0)
|
||||
|
||||
def wait_complete(self, axis: str, timeout=30.0) -> bool:
|
||||
a = axis.upper()
|
||||
start = time.time()
|
||||
while time.time() - start < timeout:
|
||||
vals = self.get_status(a)
|
||||
st = MotorStatus(vals[3]) # 第4个元素是状态值
|
||||
if st == MotorStatus.STANDBY:
|
||||
return True
|
||||
if st in (MotorStatus.COLLISION_STOP, MotorStatus.FORWARD_LIMIT_STOP, MotorStatus.REVERSE_LIMIT_STOP):
|
||||
logger.warning(f"{a} 轴异常停止: {st.name}")
|
||||
return False
|
||||
time.sleep(0.1)
|
||||
logger.warning(f"{a} 轴运动超时")
|
||||
return False
|
||||
|
||||
# ========== 控制命令 ==========
|
||||
def move_to(self, axis: str, steps: int, speed: int = 2000, acc: int = 500, precision: int = 50) -> bool:
|
||||
a = MotorAxis[axis.upper()]
|
||||
addr = self.axis_addr[a]
|
||||
hi, lo = (steps >> 16) & 0xFFFF, steps & 0xFFFF
|
||||
values = [hi, lo, speed, acc, precision]
|
||||
ok = self.client.write_multiple_registers(addr, self.REG_TARGET_HIGH, values)
|
||||
if ok:
|
||||
self.client.write_single_register(addr, self.REG_START, 1)
|
||||
return ok
|
||||
|
||||
def move_xyz_work(self, x: float = 0.0, y: float = 0.0, z: float = 0.0, speed: int = 100, acc: int = 1500):
|
||||
logger.info("🧭 执行安全多轴运动:Z→XY→Z")
|
||||
if z is not None:
|
||||
safe_z = self._to_machine_steps("Z", 0.0)
|
||||
self.move_to("Z", safe_z, speed, acc)
|
||||
self.wait_complete("Z")
|
||||
|
||||
if x is not None or y is not None:
|
||||
if x is not None:
|
||||
self.move_to("X", self._to_machine_steps("X", x), speed, acc)
|
||||
if y is not None:
|
||||
self.move_to("Y", self._to_machine_steps("Y", y), speed, acc)
|
||||
if x is not None:
|
||||
self.wait_complete("X")
|
||||
if y is not None:
|
||||
self.wait_complete("Y")
|
||||
|
||||
if z is not None:
|
||||
self.move_to("Z", self._to_machine_steps("Z", z), speed, acc)
|
||||
self.wait_complete("Z")
|
||||
logger.info("✅ 多轴顺序运动完成")
|
||||
|
||||
# ========== 坐标与零点 ==========
|
||||
def _to_machine_steps(self, axis: str, mm: float) -> int:
|
||||
base = self.work_origin_steps.get(axis.lower(), 0)
|
||||
return base + self.mm_to_steps(axis, mm)
|
||||
|
||||
def define_current_as_zero(self, save_path="work_origin.json"):
|
||||
import json
|
||||
from datetime import datetime
|
||||
|
||||
origin = {}
|
||||
for axis in ["X", "Y", "Z"]:
|
||||
vals = self.get_status(axis)
|
||||
origin[axis.lower()] = int(vals[0]) # 第1个是步数
|
||||
with open(save_path, "w", encoding="utf-8") as f:
|
||||
json.dump({"work_origin_steps": origin, "timestamp": datetime.now().isoformat()}, f, indent=2)
|
||||
self.work_origin_steps = origin
|
||||
self.is_homed = True
|
||||
logger.info(f"✅ 零点已定义并保存至 {save_path}")
|
||||
|
||||
def _load_work_origin(self, path: str) -> bool:
|
||||
import json, os
|
||||
|
||||
if not os.path.exists(path):
|
||||
logger.warning("⚠️ 未找到软零点文件")
|
||||
return False
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
self.work_origin_steps = data.get("work_origin_steps", {"x": 0, "y": 0, "z": 0})
|
||||
self.is_homed = True
|
||||
logger.info(f"📂 软零点已加载: {self.work_origin_steps}")
|
||||
return True
|
||||
|
||||
def return_to_work_origin(self, speed: int = 200, acc: int = 800):
|
||||
logger.info("🏁 回工件软零点")
|
||||
self.move_to("Z", self._to_machine_steps("Z", 0.0), speed, acc)
|
||||
self.wait_complete("Z")
|
||||
self.move_to("X", self.work_origin_steps.get("x", 0), speed, acc)
|
||||
self.move_to("Y", self.work_origin_steps.get("y", 0), speed, acc)
|
||||
self.wait_complete("X")
|
||||
self.wait_complete("Y")
|
||||
self.move_to("Z", self.work_origin_steps.get("z", 0), speed, acc)
|
||||
self.wait_complete("Z")
|
||||
logger.info("🎯 回软零点完成 ✅")
|
||||
@@ -153,7 +153,7 @@ class UniLiquidHandlerLaiyuBackend(LiquidHandlerBackend):
|
||||
if self.hardware_interface.tip_status == TipStatus.TIP_ATTACHED:
|
||||
print("已有枪头,无需重复拾取")
|
||||
return
|
||||
self.hardware_interface.xyz_controller.move_to_work_coord_safe(x=x, y=-y, z=z,speed=100)
|
||||
self.hardware_interface.xyz_controller.move_to_work_coord_safe(x=x, y=-y, z=z,speed=200)
|
||||
self.hardware_interface.xyz_controller.move_to_work_coord_safe(z=self.hardware_interface.xyz_controller.machine_config.safe_z_height,speed=100)
|
||||
# self.joint_state_publisher.send_resource_action(ops[0].resource.name, x, y, z, "pick",channels=use_channels)
|
||||
# goback()
|
||||
@@ -202,7 +202,7 @@ class UniLiquidHandlerLaiyuBackend(LiquidHandlerBackend):
|
||||
if self.hardware_interface.tip_status == TipStatus.NO_TIP:
|
||||
print("无枪头,无需丢弃")
|
||||
return
|
||||
self.hardware_interface.xyz_controller.move_to_work_coord_safe(x=x, y=-y, z=z)
|
||||
self.hardware_interface.xyz_controller.move_to_work_coord_safe(x=x, y=-y, z=z,speed=200)
|
||||
self.hardware_interface.eject_tip
|
||||
self.hardware_interface.xyz_controller.move_to_work_coord_safe(z=self.hardware_interface.xyz_controller.machine_config.safe_z_height)
|
||||
|
||||
@@ -267,7 +267,7 @@ class UniLiquidHandlerLaiyuBackend(LiquidHandlerBackend):
|
||||
return
|
||||
|
||||
# 移动到吸液位置
|
||||
self.hardware_interface.xyz_controller.move_to_work_coord_safe(x=x, y=-y, z=z)
|
||||
self.hardware_interface.xyz_controller.move_to_work_coord_safe(x=x, y=-y, z=z,speed=200)
|
||||
self.pipette_aspirate(volume=ops[0].volume, flow_rate=flow_rate)
|
||||
|
||||
|
||||
@@ -340,7 +340,7 @@ class UniLiquidHandlerLaiyuBackend(LiquidHandlerBackend):
|
||||
|
||||
|
||||
# 移动到排液位置
|
||||
self.hardware_interface.xyz_controller.move_to_work_coord_safe(x=x, y=-y, z=z)
|
||||
self.hardware_interface.xyz_controller.move_to_work_coord_safe(x=x, y=-y, z=z,speed=200)
|
||||
self.pipette_dispense(volume=ops[0].volume, flow_rate=flow_rate)
|
||||
|
||||
|
||||
|
||||
@@ -128,6 +128,7 @@ class PipetteController:
|
||||
baudrate=115200
|
||||
)
|
||||
self.pipette = SOPAPipette(self.config)
|
||||
self.pipette_port = port
|
||||
self.tip_status = TipStatus.NO_TIP
|
||||
self.current_volume = 0.0
|
||||
self.max_volume = 1000.0 # 默认1000ul
|
||||
@@ -154,7 +155,7 @@ class PipetteController:
|
||||
logger.info("移液器连接成功")
|
||||
|
||||
# 连接XYZ步进电机控制器(如果提供了端口)
|
||||
if self.xyz_port:
|
||||
if self.xyz_port != self.pipette_port:
|
||||
try:
|
||||
self.xyz_controller = XYZController(self.xyz_port)
|
||||
if self.xyz_controller.connect():
|
||||
@@ -168,6 +169,11 @@ class PipetteController:
|
||||
self.xyz_controller = None
|
||||
self.xyz_connected = False
|
||||
else:
|
||||
try:
|
||||
self.xyz_controller = XYZController(self.xyz_port, auto_connect=False)
|
||||
self.xyz_controller.serial_conn = self.pipette.serial_port
|
||||
self.xyz_controller.is_connected = True
|
||||
except Exception as e:
|
||||
logger.info("未配置XYZ步进电机端口,跳过运动控制器连接")
|
||||
|
||||
return True
|
||||
|
||||
@@ -6,6 +6,7 @@ import traceback
|
||||
from collections import Counter
|
||||
from typing import List, Sequence, Optional, Literal, Union, Iterator, Dict, Any, Callable, Set, cast
|
||||
|
||||
from typing_extensions import TypedDict
|
||||
from pylabrobot.liquid_handling import LiquidHandler, LiquidHandlerBackend, LiquidHandlerChatterboxBackend, Strictness
|
||||
from unilabos.devices.liquid_handling.rviz_backend import UniLiquidHandlerRvizBackend
|
||||
from unilabos.devices.liquid_handling.laiyu.backend.laiyu_v_backend import UniLiquidHandlerLaiyuBackend
|
||||
@@ -28,12 +29,15 @@ from pylabrobot.resources import (
|
||||
)
|
||||
|
||||
from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode
|
||||
|
||||
class SimpleReturn(TypedDict):
|
||||
samples: list
|
||||
volumes: list
|
||||
|
||||
class LiquidHandlerMiddleware(LiquidHandler):
|
||||
def __init__(self, backend: LiquidHandlerBackend, deck: Deck, simulator: bool = False, channel_num: int = 8, **kwargs):
|
||||
self._simulator = simulator
|
||||
self.channel_num = channel_num
|
||||
self.pending_liquids_dict = {}
|
||||
joint_config = kwargs.get("joint_config", None)
|
||||
if simulator:
|
||||
if joint_config:
|
||||
@@ -131,7 +135,9 @@ class LiquidHandlerMiddleware(LiquidHandler):
|
||||
return await self._simulate_handler.drop_tips(
|
||||
tip_spots, use_channels, offsets, allow_nonzero_volume, **backend_kwargs
|
||||
)
|
||||
return await super().drop_tips(tip_spots, use_channels, offsets, allow_nonzero_volume, **backend_kwargs)
|
||||
await super().drop_tips(tip_spots, use_channels, offsets, allow_nonzero_volume, **backend_kwargs)
|
||||
self.pending_liquids_dict = {}
|
||||
return
|
||||
|
||||
async def return_tips(
|
||||
self, use_channels: Optional[list[int]] = None, allow_nonzero_volume: bool = False, **backend_kwargs
|
||||
@@ -154,7 +160,9 @@ class LiquidHandlerMiddleware(LiquidHandler):
|
||||
offsets = [Coordinate.zero()] * len(use_channels)
|
||||
if self._simulator:
|
||||
return await self._simulate_handler.discard_tips(use_channels, allow_nonzero_volume, offsets, **backend_kwargs)
|
||||
return await super().discard_tips(use_channels, allow_nonzero_volume, offsets, **backend_kwargs)
|
||||
await super().discard_tips(use_channels, allow_nonzero_volume, offsets, **backend_kwargs)
|
||||
self.pending_liquids_dict = {}
|
||||
return
|
||||
|
||||
def _check_containers(self, resources: Sequence[Resource]):
|
||||
super()._check_containers(resources)
|
||||
@@ -171,6 +179,8 @@ class LiquidHandlerMiddleware(LiquidHandler):
|
||||
spread: Literal["wide", "tight", "custom"] = "wide",
|
||||
**backend_kwargs,
|
||||
):
|
||||
|
||||
|
||||
if self._simulator:
|
||||
return await self._simulate_handler.aspirate(
|
||||
resources,
|
||||
@@ -183,7 +193,7 @@ class LiquidHandlerMiddleware(LiquidHandler):
|
||||
spread,
|
||||
**backend_kwargs,
|
||||
)
|
||||
return await super().aspirate(
|
||||
await super().aspirate(
|
||||
resources,
|
||||
vols,
|
||||
use_channels,
|
||||
@@ -195,6 +205,18 @@ class LiquidHandlerMiddleware(LiquidHandler):
|
||||
**backend_kwargs,
|
||||
)
|
||||
|
||||
res_samples = []
|
||||
res_volumes = []
|
||||
for resource, volume, channel in zip(resources, vols, use_channels):
|
||||
res_samples.append({"name": resource.name, "sample_uuid": resource.unilabos_extra.get("sample_uuid", None)})
|
||||
res_volumes.append(volume)
|
||||
self.pending_liquids_dict[channel] = {
|
||||
"sample_uuid": resource.unilabos_extra.get("sample_uuid", None),
|
||||
"volume": volume
|
||||
}
|
||||
return SimpleReturn(samples=res_samples, volumes=res_volumes)
|
||||
|
||||
|
||||
async def dispense(
|
||||
self,
|
||||
resources: Sequence[Container],
|
||||
@@ -206,7 +228,7 @@ class LiquidHandlerMiddleware(LiquidHandler):
|
||||
blow_out_air_volume: Optional[List[Optional[float]]] = None,
|
||||
spread: Literal["wide", "tight", "custom"] = "wide",
|
||||
**backend_kwargs,
|
||||
):
|
||||
) -> SimpleReturn:
|
||||
if self._simulator:
|
||||
return await self._simulate_handler.dispense(
|
||||
resources,
|
||||
@@ -219,7 +241,7 @@ class LiquidHandlerMiddleware(LiquidHandler):
|
||||
spread,
|
||||
**backend_kwargs,
|
||||
)
|
||||
return await super().dispense(
|
||||
await super().dispense(
|
||||
resources,
|
||||
vols,
|
||||
use_channels,
|
||||
@@ -229,6 +251,16 @@ class LiquidHandlerMiddleware(LiquidHandler):
|
||||
blow_out_air_volume,
|
||||
**backend_kwargs,
|
||||
)
|
||||
res_samples = []
|
||||
res_volumes = []
|
||||
for resource, volume, channel in zip(resources, vols, use_channels):
|
||||
res_uuid = self.pending_liquids_dict[channel]["sample_uuid"]
|
||||
self.pending_liquids_dict[channel]["volume"] -= volume
|
||||
resource.unilabos_extra["sample_uuid"] = res_uuid
|
||||
res_samples.append({"name": resource.name, "sample_uuid": res_uuid})
|
||||
res_volumes.append(volume)
|
||||
|
||||
return SimpleReturn(samples=res_samples, volumes=res_volumes)
|
||||
|
||||
async def transfer(
|
||||
self,
|
||||
@@ -549,25 +581,66 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
|
||||
support_touch_tip = True
|
||||
_ros_node: BaseROS2DeviceNode
|
||||
|
||||
def __init__(self, backend: LiquidHandlerBackend, deck: Deck, simulator: bool=False, channel_num:int = 8):
|
||||
def __init__(self, backend: LiquidHandlerBackend, deck: Deck, simulator: bool=False, channel_num:int = 8, total_height:float = 310):
|
||||
"""Initialize a LiquidHandler.
|
||||
|
||||
Args:
|
||||
backend: Backend to use.
|
||||
deck: Deck to use.
|
||||
"""
|
||||
backend_type = None
|
||||
if isinstance(backend, dict) and "type" in backend:
|
||||
backend_dict = backend.copy()
|
||||
type_str = backend_dict.pop("type")
|
||||
try:
|
||||
# Try to get class from string using globals (current module), or fallback to pylabrobot or unilabos namespaces
|
||||
backend_cls = None
|
||||
if type_str in globals():
|
||||
backend_cls = globals()[type_str]
|
||||
else:
|
||||
# Try resolving dotted notation, e.g. "xxx.yyy.ClassName"
|
||||
components = type_str.split(".")
|
||||
mod = None
|
||||
if len(components) > 1:
|
||||
module_name = ".".join(components[:-1])
|
||||
try:
|
||||
import importlib
|
||||
mod = importlib.import_module(module_name)
|
||||
except ImportError:
|
||||
mod = None
|
||||
if mod is not None:
|
||||
backend_cls = getattr(mod, components[-1], None)
|
||||
if backend_cls is None:
|
||||
# Try pylabrobot style import (if available)
|
||||
try:
|
||||
import pylabrobot
|
||||
backend_cls = getattr(pylabrobot, type_str, None)
|
||||
except Exception:
|
||||
backend_cls = None
|
||||
if backend_cls is not None and isinstance(backend_cls, type):
|
||||
backend_type = backend_cls(**backend_dict) # pass the rest of dict as kwargs
|
||||
except Exception as exc:
|
||||
raise RuntimeError(f"Failed to convert backend type '{type_str}' to class: {exc}")
|
||||
else:
|
||||
backend_type = backend
|
||||
self._simulator = simulator
|
||||
self.group_info = dict()
|
||||
super().__init__(backend, deck, simulator, channel_num)
|
||||
super().__init__(backend_type, deck, simulator, channel_num)
|
||||
|
||||
def post_init(self, ros_node: BaseROS2DeviceNode):
|
||||
self._ros_node = ros_node
|
||||
|
||||
@classmethod
|
||||
def set_liquid(cls, wells: list[Well], liquid_names: list[str], volumes: list[float]):
|
||||
def set_liquid(cls, wells: list[Well], liquid_names: list[str], volumes: list[float]) -> SimpleReturn:
|
||||
"""Set the liquid in a well."""
|
||||
res_samples = []
|
||||
res_volumes = []
|
||||
for well, liquid_name, volume in zip(wells, liquid_names, volumes):
|
||||
well.set_liquids([(liquid_name, volume)]) # type: ignore
|
||||
res_samples.append({"name": well.name, "sample_uuid": well.unilabos_extra.get("sample_uuid", None)})
|
||||
res_volumes.append(volume)
|
||||
|
||||
return SimpleReturn(samples=res_samples, volumes=res_volumes)
|
||||
# ---------------------------------------------------------------
|
||||
# REMOVE LIQUID --------------------------------------------------
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
954
unilabos/devices/liquid_handling/prcxi/base_material.json
Normal file
954
unilabos/devices/liquid_handling/prcxi/base_material.json
Normal file
@@ -0,0 +1,954 @@
|
||||
[
|
||||
{
|
||||
"uuid": "3b6f33ffbf734014bcc20e3c63e124d4",
|
||||
"Code": "ZX-58-1250",
|
||||
"Name": "Tip头适配器 1250uL",
|
||||
"SummaryName": "Tip头适配器 1250uL",
|
||||
"SupplyType": 2,
|
||||
"Factory": "宁静致远",
|
||||
"LengthNum": 128,
|
||||
"WidthNum": 85,
|
||||
"HeightNum": 20,
|
||||
"DepthNum": 4,
|
||||
"StandardHeight": 0,
|
||||
"PipetteHeight": null,
|
||||
"HoleColum": 1,
|
||||
"HoleRow": 1,
|
||||
"HoleDiameter": 0,
|
||||
"Volume": 1250,
|
||||
"ImagePath": "/images/20220624015044.jpg",
|
||||
"QRCode": null,
|
||||
"Qty": 10,
|
||||
"CreateName": null,
|
||||
"CreateTime": "2021-12-30 16:03:52.6583727",
|
||||
"UpdateName": null,
|
||||
"UpdateTime": "2022-06-24 13:50:44.8123474",
|
||||
"IsStright": 0,
|
||||
"IsGeneral": 0,
|
||||
"IsControl": 1,
|
||||
"ArmCode": null,
|
||||
"XSpacing": null,
|
||||
"YSpacing": null,
|
||||
"materialEnum": null,
|
||||
"Margins_X": 0,
|
||||
"Margins_Y": 0
|
||||
},
|
||||
{
|
||||
"uuid": "7c822592b360451fb59690e49ac6b181",
|
||||
"Code": "ZX-58-300",
|
||||
"Name": "ZHONGXI 适配器 300uL",
|
||||
"SummaryName": "ZHONGXI 适配器 300uL",
|
||||
"SupplyType": 2,
|
||||
"Factory": "宁静致远",
|
||||
"LengthNum": 127,
|
||||
"WidthNum": 85,
|
||||
"HeightNum": 81,
|
||||
"DepthNum": 4,
|
||||
"StandardHeight": 0,
|
||||
"PipetteHeight": null,
|
||||
"HoleColum": 1,
|
||||
"HoleRow": 1,
|
||||
"HoleDiameter": 0,
|
||||
"Volume": 300,
|
||||
"ImagePath": "/images/20220623102838.jpg",
|
||||
"QRCode": null,
|
||||
"Qty": 10,
|
||||
"CreateName": null,
|
||||
"CreateTime": "2021-12-30 16:07:53.7453351",
|
||||
"UpdateName": null,
|
||||
"UpdateTime": "2022-06-23 10:28:38.6190575",
|
||||
"IsStright": 0,
|
||||
"IsGeneral": 0,
|
||||
"IsControl": 1,
|
||||
"ArmCode": null,
|
||||
"XSpacing": null,
|
||||
"YSpacing": null,
|
||||
"materialEnum": null,
|
||||
"Margins_X": 0,
|
||||
"Margins_Y": 0
|
||||
},
|
||||
{
|
||||
"uuid": "8cc3dce884ac41c09f4570d0bcbfb01c",
|
||||
"Code": "ZX-58-10",
|
||||
"Name": "吸头10ul 适配器",
|
||||
"SummaryName": "吸头10ul 适配器",
|
||||
"SupplyType": 2,
|
||||
"Factory": "宁静致远",
|
||||
"LengthNum": 128,
|
||||
"WidthNum": 85,
|
||||
"HeightNum": 72.3,
|
||||
"DepthNum": 0,
|
||||
"StandardHeight": 0,
|
||||
"PipetteHeight": 0,
|
||||
"HoleColum": 1,
|
||||
"HoleRow": 1,
|
||||
"HoleDiameter": 127,
|
||||
"Volume": 1000,
|
||||
"ImagePath": "",
|
||||
"QRCode": null,
|
||||
"Qty": 10,
|
||||
"CreateName": null,
|
||||
"CreateTime": "2021-12-30 16:37:40.7073733",
|
||||
"UpdateName": null,
|
||||
"UpdateTime": "2025-05-30 15:17:01.8231737",
|
||||
"IsStright": 0,
|
||||
"IsGeneral": 1,
|
||||
"IsControl": 1,
|
||||
"ArmCode": null,
|
||||
"XSpacing": 0,
|
||||
"YSpacing": 0,
|
||||
"materialEnum": 0,
|
||||
"Margins_X": 0,
|
||||
"Margins_Y": 0
|
||||
},
|
||||
{
|
||||
"uuid": "7960f49ddfe9448abadda89bd1556936",
|
||||
"Code": "ZX-001-1250",
|
||||
"Name": "1250μL Tip头",
|
||||
"SummaryName": "1250μL Tip头",
|
||||
"SupplyType": 1,
|
||||
"Factory": "宁静致远",
|
||||
"LengthNum": 118.09,
|
||||
"WidthNum": 80.7,
|
||||
"HeightNum": 107.67,
|
||||
"DepthNum": 100,
|
||||
"StandardHeight": 0,
|
||||
"PipetteHeight": null,
|
||||
"HoleColum": 12,
|
||||
"HoleRow": 8,
|
||||
"HoleDiameter": 7.95,
|
||||
"Volume": 1250,
|
||||
"ImagePath": "/images/20220623102536.jpg",
|
||||
"QRCode": null,
|
||||
"Qty": 96,
|
||||
"CreateName": null,
|
||||
"CreateTime": "2021-12-30 20:53:27.8591195",
|
||||
"UpdateName": null,
|
||||
"UpdateTime": "2022-06-23 10:25:36.2592442",
|
||||
"IsStright": 0,
|
||||
"IsGeneral": 0,
|
||||
"IsControl": 1,
|
||||
"ArmCode": null,
|
||||
"XSpacing": 9,
|
||||
"YSpacing": 9,
|
||||
"materialEnum": null,
|
||||
"Margins_X": 0,
|
||||
"Margins_Y": 0
|
||||
},
|
||||
{
|
||||
"uuid": "45f2ed3ad925484d96463d675a0ebf66",
|
||||
"Code": "ZX-001-10",
|
||||
"Name": "10μL Tip头",
|
||||
"SummaryName": "10μL Tip头",
|
||||
"SupplyType": 1,
|
||||
"Factory": "宁静致远",
|
||||
"LengthNum": 120.98,
|
||||
"WidthNum": 82.12,
|
||||
"HeightNum": 67,
|
||||
"DepthNum": 39.1,
|
||||
"StandardHeight": 0,
|
||||
"PipetteHeight": null,
|
||||
"HoleColum": 12,
|
||||
"HoleRow": 8,
|
||||
"HoleDiameter": 5,
|
||||
"Volume": 10,
|
||||
"ImagePath": "/images/20221119041031.jpg",
|
||||
"QRCode": null,
|
||||
"Qty": -21,
|
||||
"CreateName": null,
|
||||
"CreateTime": "2021-12-30 20:56:53.462015",
|
||||
"UpdateName": null,
|
||||
"UpdateTime": "2022-11-19 16:10:31.126801",
|
||||
"IsStright": 0,
|
||||
"IsGeneral": 1,
|
||||
"IsControl": 1,
|
||||
"ArmCode": null,
|
||||
"XSpacing": 9,
|
||||
"YSpacing": 9,
|
||||
"materialEnum": null,
|
||||
"Margins_X": 0,
|
||||
"Margins_Y": 0
|
||||
},
|
||||
{
|
||||
"uuid": "068b3815e36b4a72a59bae017011b29f",
|
||||
"Code": "ZX-001-10+",
|
||||
"Name": "10μL加长 Tip头",
|
||||
"SummaryName": "10μL加长 Tip头",
|
||||
"SupplyType": 1,
|
||||
"Factory": "宁静致远",
|
||||
"LengthNum": 122.11,
|
||||
"WidthNum": 80.05,
|
||||
"HeightNum": 58.23,
|
||||
"DepthNum": 45.1,
|
||||
"StandardHeight": 0,
|
||||
"PipetteHeight": 60,
|
||||
"HoleColum": 12,
|
||||
"HoleRow": 8,
|
||||
"HoleDiameter": 7,
|
||||
"Volume": 10,
|
||||
"ImagePath": "",
|
||||
"QRCode": null,
|
||||
"Qty": 42,
|
||||
"CreateName": null,
|
||||
"CreateTime": "2021-12-30 20:57:57.331211",
|
||||
"UpdateName": null,
|
||||
"UpdateTime": "2025-09-17 17:02:51.2070383",
|
||||
"IsStright": 0,
|
||||
"IsGeneral": 0,
|
||||
"IsControl": 1,
|
||||
"ArmCode": null,
|
||||
"XSpacing": 9,
|
||||
"YSpacing": 9,
|
||||
"materialEnum": 1,
|
||||
"Margins_X": 7.97,
|
||||
"Margins_Y": 5
|
||||
},
|
||||
{
|
||||
"uuid": "80652665f6a54402b2408d50b40398df",
|
||||
"Code": "ZX-001-1000",
|
||||
"Name": "1000μL Tip头",
|
||||
"SummaryName": "1000μL Tip头",
|
||||
"SupplyType": 1,
|
||||
"Factory": "宁静致远",
|
||||
"LengthNum": 128.09,
|
||||
"WidthNum": 85.8,
|
||||
"HeightNum": 98,
|
||||
"DepthNum": 88,
|
||||
"StandardHeight": 0,
|
||||
"PipetteHeight": 100,
|
||||
"HoleColum": 12,
|
||||
"HoleRow": 8,
|
||||
"HoleDiameter": 7.95,
|
||||
"Volume": 1000,
|
||||
"ImagePath": "",
|
||||
"QRCode": null,
|
||||
"Qty": 47,
|
||||
"CreateName": null,
|
||||
"CreateTime": "2021-12-30 20:59:20.5534915",
|
||||
"UpdateName": null,
|
||||
"UpdateTime": "2025-05-30 14:49:53.639727",
|
||||
"IsStright": 0,
|
||||
"IsGeneral": 0,
|
||||
"IsControl": 1,
|
||||
"ArmCode": null,
|
||||
"XSpacing": 9,
|
||||
"YSpacing": 9,
|
||||
"materialEnum": 1,
|
||||
"Margins_X": 14.5,
|
||||
"Margins_Y": 11.4
|
||||
},
|
||||
{
|
||||
"uuid": "076250742950465b9d6ea29a225dfb00",
|
||||
"Code": "ZX-001-300",
|
||||
"Name": "300μL Tip头",
|
||||
"SummaryName": "300μL Tip头",
|
||||
"SupplyType": 1,
|
||||
"Factory": "宁静致远",
|
||||
"LengthNum": 122.11,
|
||||
"WidthNum": 80.05,
|
||||
"HeightNum": 58.23,
|
||||
"DepthNum": 45.1,
|
||||
"StandardHeight": 0,
|
||||
"PipetteHeight": 60,
|
||||
"HoleColum": 12,
|
||||
"HoleRow": 8,
|
||||
"HoleDiameter": 7,
|
||||
"Volume": 300,
|
||||
"ImagePath": "",
|
||||
"QRCode": null,
|
||||
"Qty": 11,
|
||||
"CreateName": null,
|
||||
"CreateTime": "2021-12-30 21:00:24.7266192",
|
||||
"UpdateName": null,
|
||||
"UpdateTime": "2025-09-17 17:02:40.6676947",
|
||||
"IsStright": 0,
|
||||
"IsGeneral": 0,
|
||||
"IsControl": 1,
|
||||
"ArmCode": null,
|
||||
"XSpacing": 9,
|
||||
"YSpacing": 9,
|
||||
"materialEnum": 1,
|
||||
"Margins_X": 7.97,
|
||||
"Margins_Y": 5
|
||||
},
|
||||
{
|
||||
"uuid": "7a73bb9e5c264515a8fcbe88aed0e6f7",
|
||||
"Code": "ZX-001-200",
|
||||
"Name": "200μL Tip头",
|
||||
"SummaryName": "200μL Tip头",
|
||||
"SupplyType": 1,
|
||||
"Factory": "宁静致远",
|
||||
"LengthNum": 120.98,
|
||||
"WidthNum": 82.12,
|
||||
"HeightNum": 66.9,
|
||||
"DepthNum": 52,
|
||||
"StandardHeight": 0,
|
||||
"PipetteHeight": 30,
|
||||
"HoleColum": 12,
|
||||
"HoleRow": 8,
|
||||
"HoleDiameter": 5.5,
|
||||
"Volume": 200,
|
||||
"ImagePath": "",
|
||||
"QRCode": null,
|
||||
"Qty": 19,
|
||||
"CreateName": null,
|
||||
"CreateTime": "2021-12-30 21:01:17.626704",
|
||||
"UpdateName": null,
|
||||
"UpdateTime": "2025-05-27 11:42:24.6021522",
|
||||
"IsStright": 0,
|
||||
"IsGeneral": 0,
|
||||
"IsControl": 1,
|
||||
"ArmCode": null,
|
||||
"XSpacing": 9,
|
||||
"YSpacing": 9,
|
||||
"materialEnum": 0,
|
||||
"Margins_X": 0,
|
||||
"Margins_Y": 0
|
||||
},
|
||||
{
|
||||
"uuid": "73bb9b10bc394978b70e027bf45ce2d3",
|
||||
"Code": "ZX-023-0.2",
|
||||
"Name": "0.2ml PCR板",
|
||||
"SummaryName": "0.2ml PCR板",
|
||||
"SupplyType": 1,
|
||||
"Factory": "中析",
|
||||
"LengthNum": 126,
|
||||
"WidthNum": 86,
|
||||
"HeightNum": 21.2,
|
||||
"DepthNum": 15.17,
|
||||
"StandardHeight": 0,
|
||||
"PipetteHeight": null,
|
||||
"HoleColum": 12,
|
||||
"HoleRow": 8,
|
||||
"HoleDiameter": 6,
|
||||
"Volume": 1000,
|
||||
"ImagePath": "",
|
||||
"QRCode": null,
|
||||
"Qty": -12,
|
||||
"CreateName": null,
|
||||
"CreateTime": "2021-12-30 21:06:02.7746392",
|
||||
"UpdateName": null,
|
||||
"UpdateTime": "2024-02-20 16:17:16.7921748",
|
||||
"IsStright": 0,
|
||||
"IsGeneral": 1,
|
||||
"IsControl": 1,
|
||||
"ArmCode": null,
|
||||
"XSpacing": 9,
|
||||
"YSpacing": 9,
|
||||
"materialEnum": null,
|
||||
"Margins_X": 0,
|
||||
"Margins_Y": 0
|
||||
},
|
||||
{
|
||||
"uuid": "ca877b8b114a4310b429d1de4aae96ee",
|
||||
"Code": "ZX-019-2.2",
|
||||
"Name": "2.2ml 深孔板",
|
||||
"SummaryName": "2.2ml 深孔板",
|
||||
"SupplyType": 1,
|
||||
"Factory": "宁静致远",
|
||||
"LengthNum": 127.3,
|
||||
"WidthNum": 85.35,
|
||||
"HeightNum": 44,
|
||||
"DepthNum": 42,
|
||||
"StandardHeight": 0,
|
||||
"PipetteHeight": null,
|
||||
"HoleColum": 12,
|
||||
"HoleRow": 8,
|
||||
"HoleDiameter": 8.2,
|
||||
"Volume": 2200,
|
||||
"ImagePath": "",
|
||||
"QRCode": null,
|
||||
"Qty": 34,
|
||||
"CreateName": null,
|
||||
"CreateTime": "2021-12-30 21:07:16.4538022",
|
||||
"UpdateName": null,
|
||||
"UpdateTime": "2023-08-12 13:11:26.3993472",
|
||||
"IsStright": 0,
|
||||
"IsGeneral": 1,
|
||||
"IsControl": 1,
|
||||
"ArmCode": null,
|
||||
"XSpacing": 9,
|
||||
"YSpacing": 9,
|
||||
"materialEnum": null,
|
||||
"Margins_X": 0,
|
||||
"Margins_Y": 0
|
||||
},
|
||||
{
|
||||
"uuid": "04211a2dc93547fe9bf6121eac533650",
|
||||
"Code": "ZX-58-10000",
|
||||
"Name": "储液槽",
|
||||
"SummaryName": "储液槽",
|
||||
"SupplyType": 1,
|
||||
"Factory": "宁静致远",
|
||||
"LengthNum": 125.02,
|
||||
"WidthNum": 82.97,
|
||||
"HeightNum": 31.2,
|
||||
"DepthNum": 24,
|
||||
"StandardHeight": 0,
|
||||
"PipetteHeight": 0,
|
||||
"HoleColum": 1,
|
||||
"HoleRow": 1,
|
||||
"HoleDiameter": 99.33,
|
||||
"Volume": 1250,
|
||||
"ImagePath": "",
|
||||
"QRCode": null,
|
||||
"Qty": -172,
|
||||
"CreateName": null,
|
||||
"CreateTime": "2021-12-31 18:37:56.7949909",
|
||||
"UpdateName": null,
|
||||
"UpdateTime": "2025-09-17 17:22:22.8543991",
|
||||
"IsStright": 0,
|
||||
"IsGeneral": 0,
|
||||
"IsControl": 1,
|
||||
"ArmCode": null,
|
||||
"XSpacing": 9,
|
||||
"YSpacing": 9,
|
||||
"materialEnum": 0,
|
||||
"Margins_X": 8.5,
|
||||
"Margins_Y": 5.5
|
||||
},
|
||||
{
|
||||
"uuid": "4a043a07c65a4f9bb97745e1f129b165",
|
||||
"Code": "ZX-58-0001",
|
||||
"Name": "全裙边 PCR适配器",
|
||||
"SummaryName": "全裙边 PCR适配器",
|
||||
"SupplyType": 2,
|
||||
"Factory": "宁静致远",
|
||||
"LengthNum": 125.42,
|
||||
"WidthNum": 83.13,
|
||||
"HeightNum": 15.69,
|
||||
"DepthNum": 13.41,
|
||||
"StandardHeight": 0,
|
||||
"PipetteHeight": 0,
|
||||
"HoleColum": 12,
|
||||
"HoleRow": 8,
|
||||
"HoleDiameter": 5.1,
|
||||
"Volume": 1250,
|
||||
"ImagePath": "",
|
||||
"QRCode": null,
|
||||
"Qty": 100,
|
||||
"CreateName": null,
|
||||
"CreateTime": "2022-01-02 19:21:35.8664843",
|
||||
"UpdateName": null,
|
||||
"UpdateTime": "2025-09-17 17:14:36.1210193",
|
||||
"IsStright": 1,
|
||||
"IsGeneral": 1,
|
||||
"IsControl": 1,
|
||||
"ArmCode": null,
|
||||
"XSpacing": 9,
|
||||
"YSpacing": 9,
|
||||
"materialEnum": 3,
|
||||
"Margins_X": 9.78,
|
||||
"Margins_Y": 7.72
|
||||
},
|
||||
{
|
||||
"uuid": "6bdfdd7069df453896b0806df50f2f4d",
|
||||
"Code": "ZX-ADP-001",
|
||||
"Name": "储液槽 适配器",
|
||||
"SummaryName": "储液槽 适配器",
|
||||
"SupplyType": 2,
|
||||
"Factory": "宁静致远",
|
||||
"LengthNum": 133,
|
||||
"WidthNum": 91.8,
|
||||
"HeightNum": 70,
|
||||
"DepthNum": 4,
|
||||
"StandardHeight": 0,
|
||||
"PipetteHeight": null,
|
||||
"HoleColum": 1,
|
||||
"HoleRow": 1,
|
||||
"HoleDiameter": 1,
|
||||
"Volume": 1250,
|
||||
"ImagePath": "",
|
||||
"QRCode": null,
|
||||
"Qty": null,
|
||||
"CreateName": null,
|
||||
"CreateTime": "2022-02-16 17:31:26.413594",
|
||||
"UpdateName": null,
|
||||
"UpdateTime": "2023-08-12 13:10:58.786996",
|
||||
"IsStright": 0,
|
||||
"IsGeneral": 1,
|
||||
"IsControl": 0,
|
||||
"ArmCode": null,
|
||||
"XSpacing": 0,
|
||||
"YSpacing": 0,
|
||||
"materialEnum": null,
|
||||
"Margins_X": 0,
|
||||
"Margins_Y": 0
|
||||
},
|
||||
{
|
||||
"uuid": "9a439bed8f3344549643d6b3bc5a5eb4",
|
||||
"Code": "ZX-002-300",
|
||||
"Name": "300ul深孔板适配器",
|
||||
"SummaryName": "300ul深孔板适配器",
|
||||
"SupplyType": 2,
|
||||
"Factory": "宁静致远",
|
||||
"LengthNum": 136.4,
|
||||
"WidthNum": 93.8,
|
||||
"HeightNum": 96,
|
||||
"DepthNum": 7,
|
||||
"StandardHeight": 0,
|
||||
"PipetteHeight": null,
|
||||
"HoleColum": 12,
|
||||
"HoleRow": 8,
|
||||
"HoleDiameter": 8.1,
|
||||
"Volume": 300,
|
||||
"ImagePath": "",
|
||||
"QRCode": null,
|
||||
"Qty": null,
|
||||
"CreateName": null,
|
||||
"CreateTime": "2022-06-18 15:17:42.7917763",
|
||||
"UpdateName": null,
|
||||
"UpdateTime": "2023-08-12 13:10:46.1526635",
|
||||
"IsStright": 0,
|
||||
"IsGeneral": 1,
|
||||
"IsControl": 0,
|
||||
"ArmCode": null,
|
||||
"XSpacing": 9,
|
||||
"YSpacing": 9,
|
||||
"materialEnum": null,
|
||||
"Margins_X": 0,
|
||||
"Margins_Y": 0
|
||||
},
|
||||
{
|
||||
"uuid": "4dc8d6ecfd0449549683b8ef815a861b",
|
||||
"Code": "ZX-002-10",
|
||||
"Name": "10ul专用深孔板适配器",
|
||||
"SummaryName": "10ul专用深孔板适配器",
|
||||
"SupplyType": 2,
|
||||
"Factory": "宁静致远",
|
||||
"LengthNum": 136.5,
|
||||
"WidthNum": 93.8,
|
||||
"HeightNum": 121.5,
|
||||
"DepthNum": 7,
|
||||
"StandardHeight": 0,
|
||||
"PipetteHeight": null,
|
||||
"HoleColum": 12,
|
||||
"HoleRow": 8,
|
||||
"HoleDiameter": 8.1,
|
||||
"Volume": 10,
|
||||
"ImagePath": "",
|
||||
"QRCode": null,
|
||||
"Qty": null,
|
||||
"CreateName": null,
|
||||
"CreateTime": "2022-06-30 09:37:31.0451435",
|
||||
"UpdateName": null,
|
||||
"UpdateTime": "2023-08-12 13:10:38.5409878",
|
||||
"IsStright": 0,
|
||||
"IsGeneral": 0,
|
||||
"IsControl": 0,
|
||||
"ArmCode": null,
|
||||
"XSpacing": 9,
|
||||
"YSpacing": 9,
|
||||
"materialEnum": null,
|
||||
"Margins_X": 0,
|
||||
"Margins_Y": 0
|
||||
},
|
||||
{
|
||||
"uuid": "b01627718d3341aba649baa81c2c083c",
|
||||
"Code": "Sd155",
|
||||
"Name": "爱津",
|
||||
"SummaryName": "爱津",
|
||||
"SupplyType": 1,
|
||||
"Factory": "中析",
|
||||
"LengthNum": 125,
|
||||
"WidthNum": 85,
|
||||
"HeightNum": 64,
|
||||
"DepthNum": 45.5,
|
||||
"StandardHeight": 0,
|
||||
"PipetteHeight": null,
|
||||
"HoleColum": 12,
|
||||
"HoleRow": 8,
|
||||
"HoleDiameter": 4,
|
||||
"Volume": 20,
|
||||
"ImagePath": "",
|
||||
"QRCode": null,
|
||||
"Qty": null,
|
||||
"CreateName": null,
|
||||
"CreateTime": "2022-11-07 08:56:30.1794274",
|
||||
"UpdateName": null,
|
||||
"UpdateTime": "2022-11-07 09:00:29.5496845",
|
||||
"IsStright": 0,
|
||||
"IsGeneral": 1,
|
||||
"IsControl": 0,
|
||||
"ArmCode": null,
|
||||
"XSpacing": null,
|
||||
"YSpacing": null,
|
||||
"materialEnum": null,
|
||||
"Margins_X": 0,
|
||||
"Margins_Y": 0
|
||||
},
|
||||
{
|
||||
"uuid": "adfabfffa8f24af5abfbba67b8d0f973",
|
||||
"Code": "Fhh478",
|
||||
"Name": "适配器",
|
||||
"SummaryName": "适配器",
|
||||
"SupplyType": 2,
|
||||
"Factory": "中析",
|
||||
"LengthNum": 120,
|
||||
"WidthNum": 90,
|
||||
"HeightNum": 86,
|
||||
"DepthNum": 4,
|
||||
"StandardHeight": 0,
|
||||
"PipetteHeight": null,
|
||||
"HoleColum": 1,
|
||||
"HoleRow": 1,
|
||||
"HoleDiameter": 4,
|
||||
"Volume": 1000,
|
||||
"ImagePath": null,
|
||||
"QRCode": null,
|
||||
"Qty": null,
|
||||
"CreateName": null,
|
||||
"CreateTime": "2022-11-07 09:00:10.7579131",
|
||||
"UpdateName": null,
|
||||
"UpdateTime": "2022-11-07 09:00:10.7579134",
|
||||
"IsStright": 0,
|
||||
"IsGeneral": 1,
|
||||
"IsControl": 0,
|
||||
"ArmCode": null,
|
||||
"XSpacing": null,
|
||||
"YSpacing": null,
|
||||
"materialEnum": null,
|
||||
"Margins_X": 0,
|
||||
"Margins_Y": 0
|
||||
},
|
||||
{
|
||||
"uuid": "730067cf07ae43849ddf4034299030e9",
|
||||
"Code": "q1",
|
||||
"Name": "废弃槽",
|
||||
"SummaryName": "废弃槽",
|
||||
"SupplyType": 1,
|
||||
"Factory": "中析",
|
||||
"LengthNum": 126.59,
|
||||
"WidthNum": 84.87,
|
||||
"HeightNum": 103.17,
|
||||
"DepthNum": 80,
|
||||
"StandardHeight": 0,
|
||||
"PipetteHeight": 0,
|
||||
"HoleColum": 1,
|
||||
"HoleRow": 1,
|
||||
"HoleDiameter": 1,
|
||||
"Volume": 1250,
|
||||
"ImagePath": "",
|
||||
"QRCode": null,
|
||||
"Qty": null,
|
||||
"CreateName": null,
|
||||
"CreateTime": "2023-10-14 13:15:45.8172852",
|
||||
"UpdateName": null,
|
||||
"UpdateTime": "2025-09-17 17:06:18.3331101",
|
||||
"IsStright": 0,
|
||||
"IsGeneral": 1,
|
||||
"IsControl": 0,
|
||||
"ArmCode": null,
|
||||
"XSpacing": 1,
|
||||
"YSpacing": 1,
|
||||
"materialEnum": 0,
|
||||
"Margins_X": 2.29,
|
||||
"Margins_Y": 2.64
|
||||
},
|
||||
{
|
||||
"uuid": "57b1e4711e9e4a32b529f3132fc5931f",
|
||||
"Code": "q2",
|
||||
"Name": "96深孔板",
|
||||
"SummaryName": "96深孔板",
|
||||
"SupplyType": 1,
|
||||
"Factory": "中析",
|
||||
"LengthNum": 127.3,
|
||||
"WidthNum": 85.35,
|
||||
"HeightNum": 44,
|
||||
"DepthNum": 42,
|
||||
"StandardHeight": 0,
|
||||
"PipetteHeight": 1,
|
||||
"HoleColum": 12,
|
||||
"HoleRow": 8,
|
||||
"HoleDiameter": 8.2,
|
||||
"Volume": 1250,
|
||||
"ImagePath": "",
|
||||
"QRCode": null,
|
||||
"Qty": null,
|
||||
"CreateName": null,
|
||||
"CreateTime": "2023-10-14 13:19:55.7225524",
|
||||
"UpdateName": null,
|
||||
"UpdateTime": "2025-07-03 17:28:59.0082394",
|
||||
"IsStright": 0,
|
||||
"IsGeneral": 1,
|
||||
"IsControl": 0,
|
||||
"ArmCode": null,
|
||||
"XSpacing": 9,
|
||||
"YSpacing": 9,
|
||||
"materialEnum": 0,
|
||||
"Margins_X": 15,
|
||||
"Margins_Y": 10
|
||||
},
|
||||
{
|
||||
"uuid": "853dcfb6226f476e8b23c250217dc7da",
|
||||
"Code": "q3",
|
||||
"Name": "384板",
|
||||
"SummaryName": "384板",
|
||||
"SupplyType": 1,
|
||||
"Factory": "中析",
|
||||
"LengthNum": 126.6,
|
||||
"WidthNum": 84,
|
||||
"HeightNum": 9.4,
|
||||
"DepthNum": 8,
|
||||
"StandardHeight": 0,
|
||||
"PipetteHeight": null,
|
||||
"HoleColum": 24,
|
||||
"HoleRow": 16,
|
||||
"HoleDiameter": 3,
|
||||
"Volume": 1250,
|
||||
"ImagePath": null,
|
||||
"QRCode": null,
|
||||
"Qty": null,
|
||||
"CreateName": null,
|
||||
"CreateTime": "2023-10-14 13:22:34.779818",
|
||||
"UpdateName": null,
|
||||
"UpdateTime": "2023-10-14 13:22:34.7798181",
|
||||
"IsStright": 0,
|
||||
"IsGeneral": 1,
|
||||
"IsControl": 0,
|
||||
"ArmCode": null,
|
||||
"XSpacing": 4.5,
|
||||
"YSpacing": 4.5,
|
||||
"materialEnum": null,
|
||||
"Margins_X": 0,
|
||||
"Margins_Y": 0
|
||||
},
|
||||
{
|
||||
"uuid": "01953864f6f140ccaa8ddffd4f3e46f5",
|
||||
"Code": "sdfrth654",
|
||||
"Name": "4道储液槽",
|
||||
"SummaryName": "4道储液槽",
|
||||
"SupplyType": 1,
|
||||
"Factory": "中析",
|
||||
"LengthNum": 100,
|
||||
"WidthNum": 40,
|
||||
"HeightNum": 30,
|
||||
"DepthNum": 10,
|
||||
"StandardHeight": 0,
|
||||
"PipetteHeight": null,
|
||||
"HoleColum": 4,
|
||||
"HoleRow": 8,
|
||||
"HoleDiameter": 4,
|
||||
"Volume": 1000,
|
||||
"ImagePath": "",
|
||||
"QRCode": null,
|
||||
"Qty": null,
|
||||
"CreateName": null,
|
||||
"CreateTime": "2024-02-20 14:44:25.0021372",
|
||||
"UpdateName": null,
|
||||
"UpdateTime": "2025-03-31 15:09:30.7392062",
|
||||
"IsStright": 0,
|
||||
"IsGeneral": 1,
|
||||
"IsControl": 0,
|
||||
"ArmCode": null,
|
||||
"XSpacing": 27,
|
||||
"YSpacing": 9,
|
||||
"materialEnum": 0,
|
||||
"Margins_X": 0,
|
||||
"Margins_Y": 0
|
||||
},
|
||||
{
|
||||
"uuid": "026c5d5cf3d94e56b4e16b7fb53a995b",
|
||||
"Code": "22",
|
||||
"Name": "48孔深孔板",
|
||||
"SummaryName": "48孔深孔板",
|
||||
"SupplyType": 1,
|
||||
"Factory": "",
|
||||
"LengthNum": null,
|
||||
"WidthNum": null,
|
||||
"HeightNum": null,
|
||||
"DepthNum": null,
|
||||
"StandardHeight": null,
|
||||
"PipetteHeight": null,
|
||||
"HoleColum": 6,
|
||||
"HoleRow": 8,
|
||||
"HoleDiameter": null,
|
||||
"Volume": 23,
|
||||
"ImagePath": null,
|
||||
"QRCode": null,
|
||||
"Qty": null,
|
||||
"CreateName": null,
|
||||
"CreateTime": "2025-03-19 09:38:09.8535874",
|
||||
"UpdateName": null,
|
||||
"UpdateTime": "2025-03-19 09:38:09.8536386",
|
||||
"IsStright": null,
|
||||
"IsGeneral": null,
|
||||
"IsControl": null,
|
||||
"ArmCode": null,
|
||||
"XSpacing": 18.5,
|
||||
"YSpacing": 9,
|
||||
"materialEnum": 2,
|
||||
"Margins_X": 0,
|
||||
"Margins_Y": 0
|
||||
},
|
||||
{
|
||||
"uuid": "0f1639987b154e1fac78f4fb29a1f7c1",
|
||||
"Code": "12道储液槽",
|
||||
"Name": "12道储液槽",
|
||||
"SummaryName": "12道储液槽",
|
||||
"SupplyType": 1,
|
||||
"Factory": "",
|
||||
"LengthNum": 129.5,
|
||||
"WidthNum": 83.047,
|
||||
"HeightNum": 30.6,
|
||||
"DepthNum": 26.7,
|
||||
"StandardHeight": null,
|
||||
"PipetteHeight": 0,
|
||||
"HoleColum": 12,
|
||||
"HoleRow": 8,
|
||||
"HoleDiameter": 8.04,
|
||||
"Volume": 12,
|
||||
"ImagePath": "",
|
||||
"QRCode": null,
|
||||
"Qty": null,
|
||||
"CreateName": null,
|
||||
"CreateTime": "2025-05-21 13:10:53.2735971",
|
||||
"UpdateName": null,
|
||||
"UpdateTime": "2025-09-17 17:20:40.4460256",
|
||||
"IsStright": null,
|
||||
"IsGeneral": null,
|
||||
"IsControl": null,
|
||||
"ArmCode": null,
|
||||
"XSpacing": 9,
|
||||
"YSpacing": 9,
|
||||
"materialEnum": 0,
|
||||
"Margins_X": 8.7,
|
||||
"Margins_Y": 5.35
|
||||
},
|
||||
{
|
||||
"uuid": "548bbc3df0d4447586f2c19d2c0c0c55",
|
||||
"Code": "HPLC01",
|
||||
"Name": "HPLC料盘",
|
||||
"SummaryName": "HPLC料盘",
|
||||
"SupplyType": 1,
|
||||
"Factory": "",
|
||||
"LengthNum": 0,
|
||||
"WidthNum": 0,
|
||||
"HeightNum": 0,
|
||||
"DepthNum": 0,
|
||||
"StandardHeight": null,
|
||||
"PipetteHeight": 0,
|
||||
"HoleColum": 7,
|
||||
"HoleRow": 15,
|
||||
"HoleDiameter": 0,
|
||||
"Volume": 1,
|
||||
"ImagePath": null,
|
||||
"QRCode": null,
|
||||
"Qty": null,
|
||||
"CreateName": null,
|
||||
"CreateTime": "2025-07-12 17:10:43.2660127",
|
||||
"UpdateName": null,
|
||||
"UpdateTime": "2025-07-12 17:10:43.2660131",
|
||||
"IsStright": null,
|
||||
"IsGeneral": null,
|
||||
"IsControl": null,
|
||||
"ArmCode": null,
|
||||
"XSpacing": 12.5,
|
||||
"YSpacing": 16.5,
|
||||
"materialEnum": 0,
|
||||
"Margins_X": 0,
|
||||
"Margins_Y": 0
|
||||
},
|
||||
{
|
||||
"uuid": "e146697c395e4eabb3d6b74f0dd6aaf7",
|
||||
"Code": "1",
|
||||
"Name": "ep适配器",
|
||||
"SummaryName": "ep适配器",
|
||||
"SupplyType": 1,
|
||||
"Factory": "",
|
||||
"LengthNum": 128.04,
|
||||
"WidthNum": 85.8,
|
||||
"HeightNum": 42.66,
|
||||
"DepthNum": 38.08,
|
||||
"StandardHeight": null,
|
||||
"PipetteHeight": 0,
|
||||
"HoleColum": 6,
|
||||
"HoleRow": 4,
|
||||
"HoleDiameter": 10.6,
|
||||
"Volume": 1,
|
||||
"ImagePath": "",
|
||||
"QRCode": null,
|
||||
"Qty": null,
|
||||
"CreateName": null,
|
||||
"CreateTime": "2025-09-03 13:31:54.1541015",
|
||||
"UpdateName": null,
|
||||
"UpdateTime": "2025-09-17 17:18:03.8051993",
|
||||
"IsStright": null,
|
||||
"IsGeneral": null,
|
||||
"IsControl": null,
|
||||
"ArmCode": null,
|
||||
"XSpacing": 21,
|
||||
"YSpacing": 18,
|
||||
"materialEnum": 0,
|
||||
"Margins_X": 3.54,
|
||||
"Margins_Y": 10.5
|
||||
},
|
||||
{
|
||||
"uuid": "a0757a90d8e44e81a68f306a608694f2",
|
||||
"Code": "ZX-58-30",
|
||||
"Name": "30mm适配器",
|
||||
"SummaryName": "30mm适配器",
|
||||
"SupplyType": 2,
|
||||
"Factory": "",
|
||||
"LengthNum": 132,
|
||||
"WidthNum": 93.5,
|
||||
"HeightNum": 30,
|
||||
"DepthNum": 7,
|
||||
"StandardHeight": null,
|
||||
"PipetteHeight": 0,
|
||||
"HoleColum": 12,
|
||||
"HoleRow": 8,
|
||||
"HoleDiameter": 8.1,
|
||||
"Volume": 30,
|
||||
"ImagePath": null,
|
||||
"QRCode": null,
|
||||
"Qty": null,
|
||||
"CreateName": null,
|
||||
"CreateTime": "2025-09-15 14:02:30.8094658",
|
||||
"UpdateName": null,
|
||||
"UpdateTime": "2025-09-15 14:02:30.8098183",
|
||||
"IsStright": null,
|
||||
"IsGeneral": null,
|
||||
"IsControl": null,
|
||||
"ArmCode": null,
|
||||
"XSpacing": 9,
|
||||
"YSpacing": 9,
|
||||
"materialEnum": 0,
|
||||
"Margins_X": 0,
|
||||
"Margins_Y": 0
|
||||
},
|
||||
{
|
||||
"uuid": "b05b3b2aafd94ec38ea0cd3215ecea8f",
|
||||
"Code": "ZX-78-096",
|
||||
"Name": "细菌培养皿",
|
||||
"SummaryName": "细菌培养皿",
|
||||
"SupplyType": 1,
|
||||
"Factory": "",
|
||||
"LengthNum": 124.09,
|
||||
"WidthNum": 81.89,
|
||||
"HeightNum": 13.67,
|
||||
"DepthNum": 11.2,
|
||||
"StandardHeight": null,
|
||||
"PipetteHeight": 0,
|
||||
"HoleColum": 12,
|
||||
"HoleRow": 8,
|
||||
"HoleDiameter": 6.58,
|
||||
"Volume": 78,
|
||||
"ImagePath": null,
|
||||
"QRCode": null,
|
||||
"Qty": null,
|
||||
"CreateName": null,
|
||||
"CreateTime": "2025-09-17 17:10:54.1859566",
|
||||
"UpdateName": null,
|
||||
"UpdateTime": "2025-09-17 17:10:54.1859568",
|
||||
"IsStright": null,
|
||||
"IsGeneral": null,
|
||||
"IsControl": null,
|
||||
"ArmCode": null,
|
||||
"XSpacing": 9,
|
||||
"YSpacing": 9,
|
||||
"materialEnum": 4,
|
||||
"Margins_X": 9.28,
|
||||
"Margins_Y": 6.19
|
||||
}
|
||||
]
|
||||
@@ -156,7 +156,7 @@
|
||||
"z": 0
|
||||
},
|
||||
"config": {
|
||||
"type": "PRCXI9300Container",
|
||||
"type": "PRCXI9300TipRack",
|
||||
"size_x": 50,
|
||||
"size_y": 50,
|
||||
"size_z": 10,
|
||||
@@ -4323,7 +4323,7 @@
|
||||
"z": 0
|
||||
},
|
||||
"config": {
|
||||
"type": "PRCXI9300Container",
|
||||
"type": "PRCXI9300Plate",
|
||||
"size_x": 50,
|
||||
"size_y": 50,
|
||||
"size_z": 10,
|
||||
@@ -8297,7 +8297,7 @@
|
||||
"z": 0
|
||||
},
|
||||
"config": {
|
||||
"type": "PRCXI9300Container",
|
||||
"type": "PRCXI9300Plate",
|
||||
"size_x": 50,
|
||||
"size_y": 50,
|
||||
"size_z": 10,
|
||||
@@ -8425,7 +8425,7 @@
|
||||
"z": 0
|
||||
},
|
||||
"config": {
|
||||
"type": "PRCXI9300Container",
|
||||
"type": "PRCXI9300Plate",
|
||||
"size_x": 50,
|
||||
"size_y": 50,
|
||||
"size_z": 10,
|
||||
@@ -12496,7 +12496,7 @@
|
||||
"z": 0
|
||||
},
|
||||
"config": {
|
||||
"type": "PRCXI9300Container",
|
||||
"type": "PRCXI9300TipRack",
|
||||
"size_x": 50,
|
||||
"size_y": 50,
|
||||
"size_z": 10,
|
||||
@@ -16664,7 +16664,7 @@
|
||||
"z": 0
|
||||
},
|
||||
"config": {
|
||||
"type": "PRCXI9300Container",
|
||||
"type": "PRCXI9300Plate",
|
||||
"size_x": 50,
|
||||
"size_y": 50,
|
||||
"size_z": 10,
|
||||
@@ -20640,7 +20640,7 @@
|
||||
"z": 0
|
||||
},
|
||||
"config": {
|
||||
"type": "PRCXI9300Container",
|
||||
"type": "PRCXI9300Plate",
|
||||
"size_x": 50,
|
||||
"size_y": 50,
|
||||
"size_z": 10,
|
||||
@@ -20671,7 +20671,7 @@
|
||||
"z": 0
|
||||
},
|
||||
"config": {
|
||||
"type": "PRCXI9300Container",
|
||||
"type": "PRCXI9300Plate",
|
||||
"size_x": 50,
|
||||
"size_y": 50,
|
||||
"size_z": 10,
|
||||
@@ -20799,7 +20799,7 @@
|
||||
"z": 0
|
||||
},
|
||||
"config": {
|
||||
"type": "PRCXI9300Container",
|
||||
"type": "PRCXI9300Plate",
|
||||
"size_x": 50,
|
||||
"size_y": 50,
|
||||
"size_z": 10,
|
||||
@@ -24872,7 +24872,7 @@
|
||||
"z": 0
|
||||
},
|
||||
"config": {
|
||||
"type": "PRCXI9300Container",
|
||||
"type": "PRCXI9300Plate",
|
||||
"size_x": 50,
|
||||
"size_y": 50,
|
||||
"size_z": 10,
|
||||
@@ -28848,7 +28848,7 @@
|
||||
"z": 0
|
||||
},
|
||||
"config": {
|
||||
"type": "PRCXI9300Container",
|
||||
"type": "PRCXI9300Plate",
|
||||
"size_x": 50,
|
||||
"size_y": 50,
|
||||
"size_z": 10,
|
||||
@@ -28879,7 +28879,7 @@
|
||||
"z": 0
|
||||
},
|
||||
"config": {
|
||||
"type": "PRCXI9300Container",
|
||||
"type": "PRCXI9300Plate",
|
||||
"size_x": 50,
|
||||
"size_y": 50,
|
||||
"size_z": 10,
|
||||
@@ -29007,7 +29007,7 @@
|
||||
"z": 0
|
||||
},
|
||||
"config": {
|
||||
"type": "PRCXI9300Container",
|
||||
"type": "PRCXI9300Plate",
|
||||
"size_x": 50,
|
||||
"size_y": 50,
|
||||
"size_z": 10,
|
||||
@@ -33080,7 +33080,7 @@
|
||||
"z": 0
|
||||
},
|
||||
"config": {
|
||||
"type": "PRCXI9300Container",
|
||||
"type": "PRCXI9300Plate",
|
||||
"size_x": 50,
|
||||
"size_y": 50,
|
||||
"size_z": 10,
|
||||
@@ -37153,7 +37153,7 @@
|
||||
"z": 0
|
||||
},
|
||||
"config": {
|
||||
"type": "PRCXI9300Container",
|
||||
"type": "PRCXI9300Plate",
|
||||
"size_x": 50,
|
||||
"size_y": 50,
|
||||
"size_z": 10,
|
||||
@@ -41151,6 +41151,5 @@
|
||||
"uuid": "730067cf07ae43849ddf4034299030e9"
|
||||
}
|
||||
}
|
||||
],
|
||||
"links": []
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1 @@
|
||||
[]
|
||||
@@ -0,0 +1,607 @@
|
||||
[
|
||||
{
|
||||
"Id": "1853794d-8cc1-4268-94b8-fc83e8be3ecc",
|
||||
"StartDosage": 1.0,
|
||||
"EndDosage": 55.0,
|
||||
"Aspiration": 0.0,
|
||||
"Dispensing": 0.0,
|
||||
"K": 2126.89990234375,
|
||||
"B": 2085.300048828125,
|
||||
"compensateEnum": 7,
|
||||
"materialVolume": 10
|
||||
},
|
||||
{
|
||||
"Id": "37a31398-499c-4df3-9bfe-ff92e6bc1427",
|
||||
"StartDosage": 1.0,
|
||||
"EndDosage": 303.0,
|
||||
"Aspiration": -1.0,
|
||||
"Dispensing": 0.0,
|
||||
"K": 2229.6,
|
||||
"B": 3082.7,
|
||||
"compensateEnum": 7,
|
||||
"materialVolume": 1000
|
||||
},
|
||||
{
|
||||
"Id": "e602c693-e51c-4485-8788-beb3560e0599",
|
||||
"StartDosage": 303.0,
|
||||
"EndDosage": 400.0,
|
||||
"Aspiration": -0.8,
|
||||
"Dispensing": 0.0,
|
||||
"K": 2156.6,
|
||||
"B": 9582.1,
|
||||
"compensateEnum": 7,
|
||||
"materialVolume": 1000
|
||||
},
|
||||
{
|
||||
"Id": "d7cdf777-ae58-46ab-b1ec-a5e59496bb8a",
|
||||
"StartDosage": 400.0,
|
||||
"EndDosage": 501.0,
|
||||
"Aspiration": -1.5,
|
||||
"Dispensing": 0.0,
|
||||
"K": 2087.9,
|
||||
"B": 37256.0,
|
||||
"compensateEnum": 7,
|
||||
"materialVolume": 1000
|
||||
},
|
||||
{
|
||||
"Id": "6149a3a7-98fb-4270-83b4-4f21b5c4e8d8",
|
||||
"StartDosage": 501.0,
|
||||
"EndDosage": 600.0,
|
||||
"Aspiration": -1.5,
|
||||
"Dispensing": 0.0,
|
||||
"K": 2185.0,
|
||||
"B": -12375.0,
|
||||
"compensateEnum": 7,
|
||||
"materialVolume": 1000
|
||||
},
|
||||
{
|
||||
"Id": "039f5735-a598-482d-b21d-b265d5e7436a",
|
||||
"StartDosage": 600.0,
|
||||
"EndDosage": 700.0,
|
||||
"Aspiration": -6.0,
|
||||
"Dispensing": 0.0,
|
||||
"K": 2222.0,
|
||||
"B": -30370.0,
|
||||
"compensateEnum": 7,
|
||||
"materialVolume": 1000
|
||||
},
|
||||
{
|
||||
"Id": "80875977-ee0f-49f4-b10d-de429e57c5b8",
|
||||
"StartDosage": 700.0,
|
||||
"EndDosage": 800.0,
|
||||
"Aspiration": -6.0,
|
||||
"Dispensing": 0.0,
|
||||
"K": 1705.0,
|
||||
"B": 324436.0,
|
||||
"compensateEnum": 7,
|
||||
"materialVolume": 1000
|
||||
},
|
||||
{
|
||||
"Id": "a38afc7c-9c86-4014-a669-a7d159fb0c70",
|
||||
"StartDosage": 800.0,
|
||||
"EndDosage": 900.0,
|
||||
"Aspiration": 0.0,
|
||||
"Dispensing": 0.0,
|
||||
"K": 2068.0,
|
||||
"B": 61331.0,
|
||||
"compensateEnum": 7,
|
||||
"materialVolume": 1000
|
||||
},
|
||||
{
|
||||
"Id": "a5ce0671-8767-4752-a04c-fdbdc3c7dc91",
|
||||
"StartDosage": 900.0,
|
||||
"EndDosage": 1001.0,
|
||||
"Aspiration": 3.0,
|
||||
"Dispensing": 0.0,
|
||||
"K": 2047.2,
|
||||
"B": 78417.0,
|
||||
"compensateEnum": 7,
|
||||
"materialVolume": 1000
|
||||
},
|
||||
{
|
||||
"Id": "14daba17-0a35-474f-9f8a-e9ea6c355eb0",
|
||||
"StartDosage": 1.0,
|
||||
"EndDosage": 303.0,
|
||||
"Aspiration": -1.0,
|
||||
"Dispensing": 0.0,
|
||||
"K": 2229.6,
|
||||
"B": 3082.7,
|
||||
"compensateEnum": 6,
|
||||
"materialVolume": 1000
|
||||
},
|
||||
{
|
||||
"Id": "82c2439c-79f6-4f61-9518-1b1205e44027",
|
||||
"StartDosage": 303.0,
|
||||
"EndDosage": 400.0,
|
||||
"Aspiration": -0.8,
|
||||
"Dispensing": 0.0,
|
||||
"K": 2156.6,
|
||||
"B": 9582.1,
|
||||
"compensateEnum": 6,
|
||||
"materialVolume": 1000
|
||||
},
|
||||
{
|
||||
"Id": "7981db10-4005-4c62-a22d-fac90875e91c",
|
||||
"StartDosage": 400.0,
|
||||
"EndDosage": 501.0,
|
||||
"Aspiration": -1.5,
|
||||
"Dispensing": 0.0,
|
||||
"K": 2087.9,
|
||||
"B": 37256.0,
|
||||
"compensateEnum": 6,
|
||||
"materialVolume": 1000
|
||||
},
|
||||
{
|
||||
"Id": "ae7606fd-98fa-4236-bec4-a4d60018dbea",
|
||||
"StartDosage": 501.0,
|
||||
"EndDosage": 600.0,
|
||||
"Aspiration": -1.5,
|
||||
"Dispensing": 0.0,
|
||||
"K": 2185.0,
|
||||
"B": -12375.0,
|
||||
"compensateEnum": 6,
|
||||
"materialVolume": 1000
|
||||
},
|
||||
{
|
||||
"Id": "ed2a2db0-77b6-4a0a-ac36-7184f0b2c2c8",
|
||||
"StartDosage": 600.0,
|
||||
"EndDosage": 700.0,
|
||||
"Aspiration": -6.0,
|
||||
"Dispensing": 0.0,
|
||||
"K": 2222.0,
|
||||
"B": -30370.0,
|
||||
"compensateEnum": 6,
|
||||
"materialVolume": 1000
|
||||
},
|
||||
{
|
||||
"Id": "ed639da4-b02f-4d2a-825d-b47cebdfbf1b",
|
||||
"StartDosage": 700.0,
|
||||
"EndDosage": 800.0,
|
||||
"Aspiration": -6.0,
|
||||
"Dispensing": 0.0,
|
||||
"K": 1705.0,
|
||||
"B": 324436.0,
|
||||
"compensateEnum": 6,
|
||||
"materialVolume": 1000
|
||||
},
|
||||
{
|
||||
"Id": "7e740c8a-1043-4db1-820f-2e6e77386d7f",
|
||||
"StartDosage": 800.0,
|
||||
"EndDosage": 900.0,
|
||||
"Aspiration": 0.0,
|
||||
"Dispensing": 0.0,
|
||||
"K": 2068.0,
|
||||
"B": 61331.0,
|
||||
"compensateEnum": 6,
|
||||
"materialVolume": 1000
|
||||
},
|
||||
{
|
||||
"Id": "49b6c4fe-e11a-4056-8de7-fd9a2b81bc90",
|
||||
"StartDosage": 900.0,
|
||||
"EndDosage": 1001.0,
|
||||
"Aspiration": 3.0,
|
||||
"Dispensing": 0.0,
|
||||
"K": 2047.2,
|
||||
"B": 78417.0,
|
||||
"compensateEnum": 6,
|
||||
"materialVolume": 1000
|
||||
},
|
||||
{
|
||||
"Id": "67dee69d-a2a9-4598-8d8d-98b211a58821",
|
||||
"StartDosage": 1.0,
|
||||
"EndDosage": 6.0,
|
||||
"Aspiration": 0.0,
|
||||
"Dispensing": 0.0,
|
||||
"K": 20211.0,
|
||||
"B": 10779.0,
|
||||
"compensateEnum": 5,
|
||||
"materialVolume": 50
|
||||
},
|
||||
{
|
||||
"Id": "d5c1b2b0-f897-4873-86bf-0ce5f443dfd3",
|
||||
"StartDosage": 6.0,
|
||||
"EndDosage": 25.0,
|
||||
"Aspiration": 0.0,
|
||||
"Dispensing": 0.0,
|
||||
"K": 20211.0,
|
||||
"B": 10779.0,
|
||||
"compensateEnum": 5,
|
||||
"materialVolume": 50
|
||||
},
|
||||
{
|
||||
"Id": "b2789b53-6e0e-4b83-9932-f41c83d10da8",
|
||||
"StartDosage": 25.0,
|
||||
"EndDosage": 50.0,
|
||||
"Aspiration": 0.0,
|
||||
"Dispensing": 0.0,
|
||||
"K": 20015.0,
|
||||
"B": 17507.0,
|
||||
"compensateEnum": 5,
|
||||
"materialVolume": 50
|
||||
},
|
||||
{
|
||||
"Id": "1f0d0bbb-6ea2-4d19-8452-6824fa1f474c",
|
||||
"StartDosage": 0.1,
|
||||
"EndDosage": 5.0,
|
||||
"Aspiration": -1.1,
|
||||
"Dispensing": 0.0,
|
||||
"K": 1981.1,
|
||||
"B": 3498.1,
|
||||
"compensateEnum": 5,
|
||||
"materialVolume": 300
|
||||
},
|
||||
{
|
||||
"Id": "c58111db-dadc-43bd-97b3-a596f441d704",
|
||||
"StartDosage": 5.0,
|
||||
"EndDosage": 10.0,
|
||||
"Aspiration": -1.1,
|
||||
"Dispensing": 0.0,
|
||||
"K": 2113.3,
|
||||
"B": 2810.8,
|
||||
"compensateEnum": 5,
|
||||
"materialVolume": 300
|
||||
},
|
||||
{
|
||||
"Id": "a15fd33d-28cd-4bca-bd6c-018e3bafcb65",
|
||||
"StartDosage": 10.0,
|
||||
"EndDosage": 50.0,
|
||||
"Aspiration": -0.8,
|
||||
"Dispensing": 0.0,
|
||||
"K": 2113.3,
|
||||
"B": 2810.8,
|
||||
"compensateEnum": 5,
|
||||
"materialVolume": 300
|
||||
},
|
||||
{
|
||||
"Id": "ab957383-d83d-4fcc-8373-9d8f415c3023",
|
||||
"StartDosage": 50.0,
|
||||
"EndDosage": 100.0,
|
||||
"Aspiration": -0.1,
|
||||
"Dispensing": 0.0,
|
||||
"K": 2093.7,
|
||||
"B": 2969.2,
|
||||
"compensateEnum": 5,
|
||||
"materialVolume": 300
|
||||
},
|
||||
{
|
||||
"Id": "be6b6f79-222f-4f6f-ae73-e537f397a11e",
|
||||
"StartDosage": 100.0,
|
||||
"EndDosage": 150.0,
|
||||
"Aspiration": 1.7,
|
||||
"Dispensing": 0.0,
|
||||
"K": 2093.7,
|
||||
"B": 2969.2,
|
||||
"compensateEnum": 5,
|
||||
"materialVolume": 300
|
||||
},
|
||||
{
|
||||
"Id": "0ab3fc05-8f9f-4dc0-a2ce-918ade17810c",
|
||||
"StartDosage": 150.0,
|
||||
"EndDosage": 200.0,
|
||||
"Aspiration": 0.0,
|
||||
"Dispensing": 0.0,
|
||||
"K": 2085.0,
|
||||
"B": 3548.3,
|
||||
"compensateEnum": 5,
|
||||
"materialVolume": 300
|
||||
},
|
||||
{
|
||||
"Id": "43b82710-37df-4039-9513-aa49bc5bc607",
|
||||
"StartDosage": 200.0,
|
||||
"EndDosage": 250.0,
|
||||
"Aspiration": 4.0,
|
||||
"Dispensing": 0.0,
|
||||
"K": 2085.0,
|
||||
"B": 3548.3,
|
||||
"compensateEnum": 5,
|
||||
"materialVolume": 300
|
||||
},
|
||||
{
|
||||
"Id": "2f208ffc-808f-4bf9-b443-14dbf0338d83",
|
||||
"StartDosage": 250.0,
|
||||
"EndDosage": 310.0,
|
||||
"Aspiration": 5.3,
|
||||
"Dispensing": 0.0,
|
||||
"K": 2085.0,
|
||||
"B": 3548.3,
|
||||
"compensateEnum": 5,
|
||||
"materialVolume": 300
|
||||
},
|
||||
{
|
||||
"Id": "84bb5356-481d-41b9-a563-917e64b5e20c",
|
||||
"StartDosage": 1.0,
|
||||
"EndDosage": 10.0,
|
||||
"Aspiration": 0.0,
|
||||
"Dispensing": 0.0,
|
||||
"K": 964.19,
|
||||
"B": 1207.7,
|
||||
"compensateEnum": 5,
|
||||
"materialVolume": 1000
|
||||
},
|
||||
{
|
||||
"Id": "67463c2c-a520-4d33-831f-e0c3cdcdec60",
|
||||
"StartDosage": 10.0,
|
||||
"EndDosage": 50.0,
|
||||
"Aspiration": 0.5,
|
||||
"Dispensing": 0.0,
|
||||
"K": 964.19,
|
||||
"B": 1207.7,
|
||||
"compensateEnum": 5,
|
||||
"materialVolume": 1000
|
||||
},
|
||||
{
|
||||
"Id": "a752d77e-7c5d-450a-8b54-e87513facda0",
|
||||
"StartDosage": 50.0,
|
||||
"EndDosage": 100.0,
|
||||
"Aspiration": 0.0,
|
||||
"Dispensing": 0.0,
|
||||
"K": 964.19,
|
||||
"B": 1207.7,
|
||||
"compensateEnum": 5,
|
||||
"materialVolume": 1000
|
||||
},
|
||||
{
|
||||
"Id": "d30f522a-5992-4be4-984d-0c27b9e8f410",
|
||||
"StartDosage": 100.0,
|
||||
"EndDosage": 300.0,
|
||||
"Aspiration": 1.8,
|
||||
"Dispensing": 0.0,
|
||||
"K": 937.8,
|
||||
"B": 3550.1,
|
||||
"compensateEnum": 5,
|
||||
"materialVolume": 1000
|
||||
},
|
||||
{
|
||||
"Id": "29914cbe-ad35-4712-80b1-8c4e54f9fc15",
|
||||
"StartDosage": 300.0,
|
||||
"EndDosage": 500.0,
|
||||
"Aspiration": 2.5,
|
||||
"Dispensing": 0.0,
|
||||
"K": 937.8,
|
||||
"B": 3550.1,
|
||||
"compensateEnum": 5,
|
||||
"materialVolume": 1000
|
||||
},
|
||||
{
|
||||
"Id": "b75b1d6d-9b53-4b5c-b6ab-640cb23491d8",
|
||||
"StartDosage": 500.0,
|
||||
"EndDosage": 800.0,
|
||||
"Aspiration": 50.0,
|
||||
"Dispensing": 0.0,
|
||||
"K": 928.69,
|
||||
"B": 8253.7,
|
||||
"compensateEnum": 5,
|
||||
"materialVolume": 1000
|
||||
},
|
||||
{
|
||||
"Id": "1658a9de-bb62-4dd6-9715-0e8e71b27f97",
|
||||
"StartDosage": 800.0,
|
||||
"EndDosage": 900.0,
|
||||
"Aspiration": 4.0,
|
||||
"Dispensing": 0.0,
|
||||
"K": 928.69,
|
||||
"B": 8253.7,
|
||||
"compensateEnum": 5,
|
||||
"materialVolume": 1000
|
||||
},
|
||||
{
|
||||
"Id": "4d0fec65-983d-47f6-82fe-723bb9efd42a",
|
||||
"StartDosage": 900.0,
|
||||
"EndDosage": 1050.0,
|
||||
"Aspiration": 5.0,
|
||||
"Dispensing": 0.0,
|
||||
"K": 928.69,
|
||||
"B": 8253.7,
|
||||
"compensateEnum": 5,
|
||||
"materialVolume": 1000
|
||||
},
|
||||
{
|
||||
"Id": "f194ad17-3be3-4684-bf21-d458693e640c",
|
||||
"StartDosage": 1.0,
|
||||
"EndDosage": 2.0,
|
||||
"Aspiration": 0.0,
|
||||
"Dispensing": 0.0,
|
||||
"K": 62616.0,
|
||||
"B": 106.49,
|
||||
"compensateEnum": 5,
|
||||
"materialVolume": 10
|
||||
},
|
||||
{
|
||||
"Id": "fa43155c-8220-4ead-bc8f-6984a25711bf",
|
||||
"StartDosage": 2.0,
|
||||
"EndDosage": 7.0,
|
||||
"Aspiration": -0.1,
|
||||
"Dispensing": 0.0,
|
||||
"K": 52421.0,
|
||||
"B": 20977.0,
|
||||
"compensateEnum": 5,
|
||||
"materialVolume": 10
|
||||
},
|
||||
{
|
||||
"Id": "9b05eebb-ba5d-427c-bd4f-1b6745bab932",
|
||||
"StartDosage": 7.0,
|
||||
"EndDosage": 11.0,
|
||||
"Aspiration": 0.1,
|
||||
"Dispensing": 0.0,
|
||||
"K": 51942.0,
|
||||
"B": 21434.0,
|
||||
"compensateEnum": 5,
|
||||
"materialVolume": 10
|
||||
},
|
||||
{
|
||||
"Id": "d4715f09-e24a-4ed2-b784-09256640bcf7",
|
||||
"StartDosage": 0.5,
|
||||
"EndDosage": 5.0,
|
||||
"Aspiration": -1.1,
|
||||
"Dispensing": 0.0,
|
||||
"K": 1981.1,
|
||||
"B": 3498.1,
|
||||
"compensateEnum": 7,
|
||||
"materialVolume": 300
|
||||
},
|
||||
{
|
||||
"Id": "e37e2fad-954d-4a17-8312-e08bbde00902",
|
||||
"StartDosage": 5.0,
|
||||
"EndDosage": 10.0,
|
||||
"Aspiration": -1.1,
|
||||
"Dispensing": -0.8,
|
||||
"K": 2113.3,
|
||||
"B": 2810.8,
|
||||
"compensateEnum": 7,
|
||||
"materialVolume": 300
|
||||
},
|
||||
{
|
||||
"Id": "642714bd-22c6-46b5-9a48-2f0bcd91d555",
|
||||
"StartDosage": 10.0,
|
||||
"EndDosage": 50.0,
|
||||
"Aspiration": -0.8,
|
||||
"Dispensing": -2.0,
|
||||
"K": 2113.3,
|
||||
"B": 2810.8,
|
||||
"compensateEnum": 7,
|
||||
"materialVolume": 300
|
||||
},
|
||||
{
|
||||
"Id": "2fccf79f-52e5-4b6c-be6e-bdac167dd40c",
|
||||
"StartDosage": 50.0,
|
||||
"EndDosage": 100.0,
|
||||
"Aspiration": -0.1,
|
||||
"Dispensing": 0.0,
|
||||
"K": 2093.7,
|
||||
"B": 2969.2,
|
||||
"compensateEnum": 7,
|
||||
"materialVolume": 300
|
||||
},
|
||||
{
|
||||
"Id": "34555f2c-2e11-4c45-b733-83a8185727da",
|
||||
"StartDosage": 100.0,
|
||||
"EndDosage": 150.0,
|
||||
"Aspiration": 1.7,
|
||||
"Dispensing": 0.0,
|
||||
"K": 2093.7,
|
||||
"B": 2969.2,
|
||||
"compensateEnum": 7,
|
||||
"materialVolume": 300
|
||||
},
|
||||
{
|
||||
"Id": "9353ac79-b710-49da-a423-4bfe651ac16a",
|
||||
"StartDosage": 150.0,
|
||||
"EndDosage": 200.0,
|
||||
"Aspiration": 0.0,
|
||||
"Dispensing": 0.0,
|
||||
"K": 2085.0,
|
||||
"B": 3548.3,
|
||||
"compensateEnum": 7,
|
||||
"materialVolume": 300
|
||||
},
|
||||
{
|
||||
"Id": "1628da53-8c86-4eff-b119-07cb7a859bb6",
|
||||
"StartDosage": 200.0,
|
||||
"EndDosage": 250.0,
|
||||
"Aspiration": 4.0,
|
||||
"Dispensing": 0.0,
|
||||
"K": 2085.0,
|
||||
"B": 3548.3,
|
||||
"compensateEnum": 7,
|
||||
"materialVolume": 300
|
||||
},
|
||||
{
|
||||
"Id": "658913c3-2c3e-4e14-9eb3-0489b5fdee7f",
|
||||
"StartDosage": 250.0,
|
||||
"EndDosage": 310.0,
|
||||
"Aspiration": -11.0,
|
||||
"Dispensing": 0.0,
|
||||
"K": 2085.0,
|
||||
"B": 3548.3,
|
||||
"compensateEnum": 7,
|
||||
"materialVolume": 300
|
||||
},
|
||||
{
|
||||
"Id": "f736e716-ec13-432c-ac2e-4905753ac6f9",
|
||||
"StartDosage": 0.1,
|
||||
"EndDosage": 5.0,
|
||||
"Aspiration": -1.1,
|
||||
"Dispensing": 0.0,
|
||||
"K": 1981.1,
|
||||
"B": 3498.1,
|
||||
"compensateEnum": 6,
|
||||
"materialVolume": 300
|
||||
},
|
||||
{
|
||||
"Id": "7595eda8-f2d8-491f-bdac-69d169308ab5",
|
||||
"StartDosage": 5.0,
|
||||
"EndDosage": 10.0,
|
||||
"Aspiration": -1.1,
|
||||
"Dispensing": 0.0,
|
||||
"K": 2113.3,
|
||||
"B": 2810.8,
|
||||
"compensateEnum": 6,
|
||||
"materialVolume": 300
|
||||
},
|
||||
{
|
||||
"Id": "42eddd0a-8394-4245-8ad3-49573b25286e",
|
||||
"StartDosage": 10.0,
|
||||
"EndDosage": 50.0,
|
||||
"Aspiration": -0.8,
|
||||
"Dispensing": 0.0,
|
||||
"K": 2113.3,
|
||||
"B": 2810.8,
|
||||
"compensateEnum": 6,
|
||||
"materialVolume": 300
|
||||
},
|
||||
{
|
||||
"Id": "713eadfe-25c0-4ec0-acfd-900df9e12396",
|
||||
"StartDosage": 50.0,
|
||||
"EndDosage": 100.0,
|
||||
"Aspiration": -0.1,
|
||||
"Dispensing": 0.0,
|
||||
"K": 2093.7,
|
||||
"B": 2969.2,
|
||||
"compensateEnum": 6,
|
||||
"materialVolume": 300
|
||||
},
|
||||
{
|
||||
"Id": "f602c7bd-bdcf-4be0-9d77-a16d409bc64b",
|
||||
"StartDosage": 100.0,
|
||||
"EndDosage": 150.0,
|
||||
"Aspiration": 1.7,
|
||||
"Dispensing": 0.0,
|
||||
"K": 2093.7,
|
||||
"B": 2969.2,
|
||||
"compensateEnum": 6,
|
||||
"materialVolume": 300
|
||||
},
|
||||
{
|
||||
"Id": "b91867e5-f0a2-4bbe-b37e-aec9837b019e",
|
||||
"StartDosage": 150.0,
|
||||
"EndDosage": 200.0,
|
||||
"Aspiration": 0.0,
|
||||
"Dispensing": 0.0,
|
||||
"K": 2085.0,
|
||||
"B": 3548.3,
|
||||
"compensateEnum": 6,
|
||||
"materialVolume": 300
|
||||
},
|
||||
{
|
||||
"Id": "bd2e39d7-eb93-4d40-b0b4-2aac6b5678f3",
|
||||
"StartDosage": 200.0,
|
||||
"EndDosage": 250.0,
|
||||
"Aspiration": 4.0,
|
||||
"Dispensing": 0.0,
|
||||
"K": 2085.0,
|
||||
"B": 3548.3,
|
||||
"compensateEnum": 6,
|
||||
"materialVolume": 300
|
||||
},
|
||||
{
|
||||
"Id": "52e20b7f-f519-434f-86bb-a48238c290d1",
|
||||
"StartDosage": 250.0,
|
||||
"EndDosage": 310.0,
|
||||
"Aspiration": 5.3,
|
||||
"Dispensing": 0.0,
|
||||
"K": 2085.0,
|
||||
"B": 3548.3,
|
||||
"compensateEnum": 6,
|
||||
"materialVolume": 300
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,794 @@
|
||||
[
|
||||
{
|
||||
"uuid": "3b6f33ffbf734014bcc20e3c63e124d4",
|
||||
"Code": "ZX-58-1250",
|
||||
"Name": "Tip头适配器 1250uL",
|
||||
"SummaryName": "Tip头适配器 1250uL",
|
||||
"SupplyType": 2,
|
||||
"Factory": "宁静致远",
|
||||
"LengthNum": 128,
|
||||
"WidthNum": 85,
|
||||
"HeightNum": 20,
|
||||
"DepthNum": 4,
|
||||
"StandardHeight": 0,
|
||||
"PipetteHeight": null,
|
||||
"HoleColum": 1,
|
||||
"HoleRow": 1,
|
||||
"ChannelNum": 1,
|
||||
"HoleDiameter": 0,
|
||||
"Volume": 1250,
|
||||
"ImagePath": "/images/20220624015044.jpg",
|
||||
"QRCode": null,
|
||||
"Qty": 10,
|
||||
"CreateName": null,
|
||||
"CreateTime": "2021-12-30 16:03:52.6583727",
|
||||
"UpdateName": null,
|
||||
"UpdateTime": "2022-06-24 13:50:44.8123474",
|
||||
"IsStright": 0,
|
||||
"IsGeneral": 0,
|
||||
"IsControl": 1,
|
||||
"ArmCode": null,
|
||||
"XSpacing": null,
|
||||
"YSpacing": null,
|
||||
"materialEnum": null
|
||||
},
|
||||
{
|
||||
"uuid": "7c822592b360451fb59690e49ac6b181",
|
||||
"Code": "ZX-58-300",
|
||||
"Name": "ZHONGXI 适配器 300uL",
|
||||
"SummaryName": "ZHONGXI 适配器 300uL",
|
||||
"SupplyType": 2,
|
||||
"Factory": "宁静致远",
|
||||
"LengthNum": 127,
|
||||
"WidthNum": 85,
|
||||
"HeightNum": 81,
|
||||
"DepthNum": 4,
|
||||
"StandardHeight": 0,
|
||||
"PipetteHeight": null,
|
||||
"HoleColum": 1,
|
||||
"HoleRow": 1,
|
||||
"ChannelNum": 1,
|
||||
"HoleDiameter": 0,
|
||||
"Volume": 300,
|
||||
"ImagePath": "/images/20220623102838.jpg",
|
||||
"QRCode": null,
|
||||
"Qty": 10,
|
||||
"CreateName": null,
|
||||
"CreateTime": "2021-12-30 16:07:53.7453351",
|
||||
"UpdateName": null,
|
||||
"UpdateTime": "2022-06-23 10:28:38.6190575",
|
||||
"IsStright": 0,
|
||||
"IsGeneral": 0,
|
||||
"IsControl": 1,
|
||||
"ArmCode": null,
|
||||
"XSpacing": null,
|
||||
"YSpacing": null,
|
||||
"materialEnum": null
|
||||
},
|
||||
{
|
||||
"uuid": "8cc3dce884ac41c09f4570d0bcbfb01c",
|
||||
"Code": "ZX-58-10",
|
||||
"Name": "吸头10ul 适配器",
|
||||
"SummaryName": "吸头10ul 适配器",
|
||||
"SupplyType": 2,
|
||||
"Factory": "宁静致远",
|
||||
"LengthNum": 128,
|
||||
"WidthNum": 85,
|
||||
"HeightNum": 81,
|
||||
"DepthNum": 4,
|
||||
"StandardHeight": 0,
|
||||
"PipetteHeight": null,
|
||||
"HoleColum": 1,
|
||||
"HoleRow": 1,
|
||||
"ChannelNum": 1,
|
||||
"HoleDiameter": 127,
|
||||
"Volume": 1000,
|
||||
"ImagePath": "/images/20221115010348.jpg",
|
||||
"QRCode": null,
|
||||
"Qty": 10,
|
||||
"CreateName": null,
|
||||
"CreateTime": "2021-12-30 16:37:40.7073733",
|
||||
"UpdateName": null,
|
||||
"UpdateTime": "2022-11-15 13:03:48.1679642",
|
||||
"IsStright": 0,
|
||||
"IsGeneral": 1,
|
||||
"IsControl": 1,
|
||||
"ArmCode": null,
|
||||
"XSpacing": null,
|
||||
"YSpacing": null,
|
||||
"materialEnum": null
|
||||
},
|
||||
{
|
||||
"uuid": "7960f49ddfe9448abadda89bd1556936",
|
||||
"Code": "ZX-001-1250",
|
||||
"Name": "1250μL Tip头",
|
||||
"SummaryName": "1250μL Tip头",
|
||||
"SupplyType": 1,
|
||||
"Factory": "宁静致远",
|
||||
"LengthNum": 118.09,
|
||||
"WidthNum": 80.7,
|
||||
"HeightNum": 107.67,
|
||||
"DepthNum": 100,
|
||||
"StandardHeight": 0,
|
||||
"PipetteHeight": null,
|
||||
"HoleColum": 12,
|
||||
"HoleRow": 8,
|
||||
"ChannelNum": 8,
|
||||
"HoleDiameter": 7.95,
|
||||
"Volume": 1250,
|
||||
"ImagePath": "/images/20220623102536.jpg",
|
||||
"QRCode": null,
|
||||
"Qty": 96,
|
||||
"CreateName": null,
|
||||
"CreateTime": "2021-12-30 20:53:27.8591195",
|
||||
"UpdateName": null,
|
||||
"UpdateTime": "2022-06-23 10:25:36.2592442",
|
||||
"IsStright": 0,
|
||||
"IsGeneral": 0,
|
||||
"IsControl": 1,
|
||||
"ArmCode": null,
|
||||
"XSpacing": null,
|
||||
"YSpacing": null,
|
||||
"materialEnum": null
|
||||
},
|
||||
{
|
||||
"uuid": "45f2ed3ad925484d96463d675a0ebf66",
|
||||
"Code": "ZX-001-10",
|
||||
"Name": "10μL Tip头",
|
||||
"SummaryName": "10μL Tip头",
|
||||
"SupplyType": 1,
|
||||
"Factory": "宁静致远",
|
||||
"LengthNum": 120.98,
|
||||
"WidthNum": 82.12,
|
||||
"HeightNum": 67,
|
||||
"DepthNum": 39.1,
|
||||
"StandardHeight": 0,
|
||||
"PipetteHeight": null,
|
||||
"HoleColum": 12,
|
||||
"HoleRow": 8,
|
||||
"ChannelNum": 8,
|
||||
"HoleDiameter": 5,
|
||||
"Volume": 1000,
|
||||
"ImagePath": "/images/20221119041031.jpg",
|
||||
"QRCode": null,
|
||||
"Qty": -21,
|
||||
"CreateName": null,
|
||||
"CreateTime": "2021-12-30 20:56:53.462015",
|
||||
"UpdateName": null,
|
||||
"UpdateTime": "2022-11-19 16:10:31.126801",
|
||||
"IsStright": 0,
|
||||
"IsGeneral": 1,
|
||||
"IsControl": 1,
|
||||
"ArmCode": null,
|
||||
"XSpacing": null,
|
||||
"YSpacing": null,
|
||||
"materialEnum": null
|
||||
},
|
||||
{
|
||||
"uuid": "068b3815e36b4a72a59bae017011b29f",
|
||||
"Code": "ZX-001-10+",
|
||||
"Name": "10μL加长 Tip头",
|
||||
"SummaryName": "10μL加长 Tip头",
|
||||
"SupplyType": 1,
|
||||
"Factory": "宁静致远",
|
||||
"LengthNum": 120.98,
|
||||
"WidthNum": 82.12,
|
||||
"HeightNum": 50.3,
|
||||
"DepthNum": 45.8,
|
||||
"StandardHeight": 0,
|
||||
"PipetteHeight": null,
|
||||
"HoleColum": 12,
|
||||
"HoleRow": 8,
|
||||
"ChannelNum": 8,
|
||||
"HoleDiameter": 5,
|
||||
"Volume": 20,
|
||||
"ImagePath": "/images/20220718120113.jpg",
|
||||
"QRCode": null,
|
||||
"Qty": 42,
|
||||
"CreateName": null,
|
||||
"CreateTime": "2021-12-30 20:57:57.331211",
|
||||
"UpdateName": null,
|
||||
"UpdateTime": "2022-07-18 12:01:13.2131453",
|
||||
"IsStright": 0,
|
||||
"IsGeneral": 0,
|
||||
"IsControl": 1,
|
||||
"ArmCode": null,
|
||||
"XSpacing": null,
|
||||
"YSpacing": null,
|
||||
"materialEnum": null
|
||||
},
|
||||
{
|
||||
"uuid": "80652665f6a54402b2408d50b40398df",
|
||||
"Code": "ZX-001-1000",
|
||||
"Name": "1000μL Tip头",
|
||||
"SummaryName": "1000μL Tip头",
|
||||
"SupplyType": 1,
|
||||
"Factory": "宁静致远",
|
||||
"LengthNum": 118.09,
|
||||
"WidthNum": 80.7,
|
||||
"HeightNum": 107.67,
|
||||
"DepthNum": 88,
|
||||
"StandardHeight": 0,
|
||||
"PipetteHeight": null,
|
||||
"HoleColum": 12,
|
||||
"HoleRow": 8,
|
||||
"ChannelNum": 8,
|
||||
"HoleDiameter": 7.95,
|
||||
"Volume": 1000,
|
||||
"ImagePath": "",
|
||||
"QRCode": null,
|
||||
"Qty": 47,
|
||||
"CreateName": null,
|
||||
"CreateTime": "2021-12-30 20:59:20.5534915",
|
||||
"UpdateName": null,
|
||||
"UpdateTime": "2023-08-12 13:11:44.8670189",
|
||||
"IsStright": 0,
|
||||
"IsGeneral": 0,
|
||||
"IsControl": 1,
|
||||
"ArmCode": null,
|
||||
"XSpacing": 9,
|
||||
"YSpacing": 9,
|
||||
"materialEnum": null
|
||||
},
|
||||
{
|
||||
"uuid": "076250742950465b9d6ea29a225dfb00",
|
||||
"Code": "ZX-001-300",
|
||||
"Name": "300μL Tip头",
|
||||
"SummaryName": "300μL Tip头",
|
||||
"SupplyType": 1,
|
||||
"Factory": "宁静致远",
|
||||
"LengthNum": 120.98,
|
||||
"WidthNum": 82.12,
|
||||
"HeightNum": 40,
|
||||
"DepthNum": 59.3,
|
||||
"StandardHeight": 0,
|
||||
"PipetteHeight": null,
|
||||
"HoleColum": 12,
|
||||
"HoleRow": 8,
|
||||
"ChannelNum": 8,
|
||||
"HoleDiameter": 5.5,
|
||||
"Volume": 300,
|
||||
"ImagePath": "",
|
||||
"QRCode": null,
|
||||
"Qty": 11,
|
||||
"CreateName": null,
|
||||
"CreateTime": "2021-12-30 21:00:24.7266192",
|
||||
"UpdateName": null,
|
||||
"UpdateTime": "2024-02-01 15:48:02.1562734",
|
||||
"IsStright": 0,
|
||||
"IsGeneral": 0,
|
||||
"IsControl": 1,
|
||||
"ArmCode": null,
|
||||
"XSpacing": 9,
|
||||
"YSpacing": 9,
|
||||
"materialEnum": null
|
||||
},
|
||||
{
|
||||
"uuid": "7a73bb9e5c264515a8fcbe88aed0e6f7",
|
||||
"Code": "ZX-001-200",
|
||||
"Name": "200μL Tip头",
|
||||
"SummaryName": "200μL Tip头",
|
||||
"SupplyType": 1,
|
||||
"Factory": "宁静致远",
|
||||
"LengthNum": 120.98,
|
||||
"WidthNum": 82.12,
|
||||
"HeightNum": 66.9,
|
||||
"DepthNum": 52,
|
||||
"StandardHeight": 0,
|
||||
"PipetteHeight": null,
|
||||
"HoleColum": 12,
|
||||
"HoleRow": 8,
|
||||
"ChannelNum": 8,
|
||||
"HoleDiameter": 5.5,
|
||||
"Volume": 200,
|
||||
"ImagePath": "",
|
||||
"QRCode": null,
|
||||
"Qty": 19,
|
||||
"CreateName": null,
|
||||
"CreateTime": "2021-12-30 21:01:17.626704",
|
||||
"UpdateName": null,
|
||||
"UpdateTime": "2023-10-14 13:44:41.5428946",
|
||||
"IsStright": 0,
|
||||
"IsGeneral": 0,
|
||||
"IsControl": 1,
|
||||
"ArmCode": null,
|
||||
"XSpacing": 9,
|
||||
"YSpacing": 9,
|
||||
"materialEnum": null
|
||||
},
|
||||
{
|
||||
"uuid": "73bb9b10bc394978b70e027bf45ce2d3",
|
||||
"Code": "ZX-023-0.2",
|
||||
"Name": "0.2ml PCR板",
|
||||
"SummaryName": "0.2ml PCR板",
|
||||
"SupplyType": 1,
|
||||
"Factory": "中析",
|
||||
"LengthNum": 126,
|
||||
"WidthNum": 86,
|
||||
"HeightNum": 21.2,
|
||||
"DepthNum": 15.17,
|
||||
"StandardHeight": 0,
|
||||
"PipetteHeight": null,
|
||||
"HoleColum": 12,
|
||||
"HoleRow": 8,
|
||||
"ChannelNum": 96,
|
||||
"HoleDiameter": 6,
|
||||
"Volume": 1000,
|
||||
"ImagePath": "",
|
||||
"QRCode": null,
|
||||
"Qty": -12,
|
||||
"CreateName": null,
|
||||
"CreateTime": "2021-12-30 21:06:02.7746392",
|
||||
"UpdateName": null,
|
||||
"UpdateTime": "2024-02-20 16:17:16.7921748",
|
||||
"IsStright": 0,
|
||||
"IsGeneral": 1,
|
||||
"IsControl": 1,
|
||||
"ArmCode": null,
|
||||
"XSpacing": 9,
|
||||
"YSpacing": 9,
|
||||
"materialEnum": null
|
||||
},
|
||||
{
|
||||
"uuid": "ca877b8b114a4310b429d1de4aae96ee",
|
||||
"Code": "ZX-019-2.2",
|
||||
"Name": "2.2ml 深孔板",
|
||||
"SummaryName": "2.2ml 深孔板",
|
||||
"SupplyType": 1,
|
||||
"Factory": "宁静致远",
|
||||
"LengthNum": 127.3,
|
||||
"WidthNum": 85.35,
|
||||
"HeightNum": 44,
|
||||
"DepthNum": 42,
|
||||
"StandardHeight": 0,
|
||||
"PipetteHeight": null,
|
||||
"HoleColum": 12,
|
||||
"HoleRow": 8,
|
||||
"ChannelNum": 8,
|
||||
"HoleDiameter": 8.2,
|
||||
"Volume": 2200,
|
||||
"ImagePath": "",
|
||||
"QRCode": null,
|
||||
"Qty": 34,
|
||||
"CreateName": null,
|
||||
"CreateTime": "2021-12-30 21:07:16.4538022",
|
||||
"UpdateName": null,
|
||||
"UpdateTime": "2023-08-12 13:11:26.3993472",
|
||||
"IsStright": 0,
|
||||
"IsGeneral": 1,
|
||||
"IsControl": 1,
|
||||
"ArmCode": null,
|
||||
"XSpacing": 9,
|
||||
"YSpacing": 9,
|
||||
"materialEnum": null
|
||||
},
|
||||
{
|
||||
"uuid": "04211a2dc93547fe9bf6121eac533650",
|
||||
"Code": "ZX-58-10000",
|
||||
"Name": "储液槽",
|
||||
"SummaryName": "储液槽",
|
||||
"SupplyType": 1,
|
||||
"Factory": "宁静致远",
|
||||
"LengthNum": 127,
|
||||
"WidthNum": 85,
|
||||
"HeightNum": 31.2,
|
||||
"DepthNum": 24,
|
||||
"StandardHeight": 0,
|
||||
"PipetteHeight": null,
|
||||
"HoleColum": 1,
|
||||
"HoleRow": 1,
|
||||
"ChannelNum": 1,
|
||||
"HoleDiameter": 127,
|
||||
"Volume": 1250,
|
||||
"ImagePath": "/images/20220623103134.jpg",
|
||||
"QRCode": null,
|
||||
"Qty": -172,
|
||||
"CreateName": null,
|
||||
"CreateTime": "2021-12-31 18:37:56.7949909",
|
||||
"UpdateName": null,
|
||||
"UpdateTime": "2022-06-23 10:31:34.4261358",
|
||||
"IsStright": 0,
|
||||
"IsGeneral": 0,
|
||||
"IsControl": 1,
|
||||
"ArmCode": null,
|
||||
"XSpacing": null,
|
||||
"YSpacing": null,
|
||||
"materialEnum": null
|
||||
},
|
||||
{
|
||||
"uuid": "4a043a07c65a4f9bb97745e1f129b165",
|
||||
"Code": "ZX-58-0001",
|
||||
"Name": "半裙边 PCR适配器",
|
||||
"SummaryName": "半裙边 PCR适配器",
|
||||
"SupplyType": 2,
|
||||
"Factory": "宁静致远",
|
||||
"LengthNum": 127,
|
||||
"WidthNum": 85,
|
||||
"HeightNum": 88,
|
||||
"DepthNum": 5,
|
||||
"StandardHeight": 0,
|
||||
"PipetteHeight": null,
|
||||
"HoleColum": 12,
|
||||
"HoleRow": 8,
|
||||
"ChannelNum": 96,
|
||||
"HoleDiameter": 9,
|
||||
"Volume": 1250,
|
||||
"ImagePath": "/images/20221123051800.jpg",
|
||||
"QRCode": null,
|
||||
"Qty": 100,
|
||||
"CreateName": null,
|
||||
"CreateTime": "2022-01-02 19:21:35.8664843",
|
||||
"UpdateName": null,
|
||||
"UpdateTime": "2022-11-23 17:18:00.8826719",
|
||||
"IsStright": 1,
|
||||
"IsGeneral": 1,
|
||||
"IsControl": 1,
|
||||
"ArmCode": null,
|
||||
"XSpacing": null,
|
||||
"YSpacing": null,
|
||||
"materialEnum": null
|
||||
},
|
||||
{
|
||||
"uuid": "6bdfdd7069df453896b0806df50f2f4d",
|
||||
"Code": "ZX-ADP-001",
|
||||
"Name": "储液槽 适配器",
|
||||
"SummaryName": "储液槽 适配器",
|
||||
"SupplyType": 2,
|
||||
"Factory": "宁静致远",
|
||||
"LengthNum": 133,
|
||||
"WidthNum": 91.8,
|
||||
"HeightNum": 70,
|
||||
"DepthNum": 4,
|
||||
"StandardHeight": 0,
|
||||
"PipetteHeight": null,
|
||||
"HoleColum": 1,
|
||||
"HoleRow": 1,
|
||||
"ChannelNum": 8,
|
||||
"HoleDiameter": 1,
|
||||
"Volume": 1250,
|
||||
"ImagePath": "",
|
||||
"QRCode": null,
|
||||
"Qty": null,
|
||||
"CreateName": null,
|
||||
"CreateTime": "2022-02-16 17:31:26.413594",
|
||||
"UpdateName": null,
|
||||
"UpdateTime": "2023-08-12 13:10:58.786996",
|
||||
"IsStright": 0,
|
||||
"IsGeneral": 1,
|
||||
"IsControl": 0,
|
||||
"ArmCode": null,
|
||||
"XSpacing": 0,
|
||||
"YSpacing": 0,
|
||||
"materialEnum": null
|
||||
},
|
||||
{
|
||||
"uuid": "9a439bed8f3344549643d6b3bc5a5eb4",
|
||||
"Code": "ZX-002-300",
|
||||
"Name": "300ul深孔板适配器",
|
||||
"SummaryName": "300ul深孔板适配器",
|
||||
"SupplyType": 2,
|
||||
"Factory": "宁静致远",
|
||||
"LengthNum": 136.4,
|
||||
"WidthNum": 93.8,
|
||||
"HeightNum": 96,
|
||||
"DepthNum": 7,
|
||||
"StandardHeight": 0,
|
||||
"PipetteHeight": null,
|
||||
"HoleColum": 12,
|
||||
"HoleRow": 8,
|
||||
"ChannelNum": 96,
|
||||
"HoleDiameter": 8.1,
|
||||
"Volume": 300,
|
||||
"ImagePath": "",
|
||||
"QRCode": null,
|
||||
"Qty": null,
|
||||
"CreateName": null,
|
||||
"CreateTime": "2022-06-18 15:17:42.7917763",
|
||||
"UpdateName": null,
|
||||
"UpdateTime": "2023-08-12 13:10:46.1526635",
|
||||
"IsStright": 0,
|
||||
"IsGeneral": 1,
|
||||
"IsControl": 0,
|
||||
"ArmCode": null,
|
||||
"XSpacing": 9,
|
||||
"YSpacing": 9,
|
||||
"materialEnum": null
|
||||
},
|
||||
{
|
||||
"uuid": "4dc8d6ecfd0449549683b8ef815a861b",
|
||||
"Code": "ZX-002-10",
|
||||
"Name": "10ul专用深孔板适配器",
|
||||
"SummaryName": "10ul专用深孔板适配器",
|
||||
"SupplyType": 2,
|
||||
"Factory": "宁静致远",
|
||||
"LengthNum": 136.5,
|
||||
"WidthNum": 93.8,
|
||||
"HeightNum": 121.5,
|
||||
"DepthNum": 7,
|
||||
"StandardHeight": 0,
|
||||
"PipetteHeight": null,
|
||||
"HoleColum": 12,
|
||||
"HoleRow": 8,
|
||||
"ChannelNum": 96,
|
||||
"HoleDiameter": 8.1,
|
||||
"Volume": 10,
|
||||
"ImagePath": "",
|
||||
"QRCode": null,
|
||||
"Qty": null,
|
||||
"CreateName": null,
|
||||
"CreateTime": "2022-06-30 09:37:31.0451435",
|
||||
"UpdateName": null,
|
||||
"UpdateTime": "2023-08-12 13:10:38.5409878",
|
||||
"IsStright": 0,
|
||||
"IsGeneral": 0,
|
||||
"IsControl": 0,
|
||||
"ArmCode": null,
|
||||
"XSpacing": 9,
|
||||
"YSpacing": 9,
|
||||
"materialEnum": null
|
||||
},
|
||||
{
|
||||
"uuid": "b01627718d3341aba649baa81c2c083c",
|
||||
"Code": "Sd155",
|
||||
"Name": "爱津",
|
||||
"SummaryName": "爱津",
|
||||
"SupplyType": 1,
|
||||
"Factory": "中析",
|
||||
"LengthNum": 125,
|
||||
"WidthNum": 85,
|
||||
"HeightNum": 64,
|
||||
"DepthNum": 45.5,
|
||||
"StandardHeight": 0,
|
||||
"PipetteHeight": null,
|
||||
"HoleColum": 12,
|
||||
"HoleRow": 8,
|
||||
"ChannelNum": 1,
|
||||
"HoleDiameter": 4,
|
||||
"Volume": 20,
|
||||
"ImagePath": "",
|
||||
"QRCode": null,
|
||||
"Qty": null,
|
||||
"CreateName": null,
|
||||
"CreateTime": "2022-11-07 08:56:30.1794274",
|
||||
"UpdateName": null,
|
||||
"UpdateTime": "2022-11-07 09:00:29.5496845",
|
||||
"IsStright": 0,
|
||||
"IsGeneral": 1,
|
||||
"IsControl": 0,
|
||||
"ArmCode": null,
|
||||
"XSpacing": null,
|
||||
"YSpacing": null,
|
||||
"materialEnum": null
|
||||
},
|
||||
{
|
||||
"uuid": "adfabfffa8f24af5abfbba67b8d0f973",
|
||||
"Code": "Fhh478",
|
||||
"Name": "适配器",
|
||||
"SummaryName": "适配器",
|
||||
"SupplyType": 2,
|
||||
"Factory": "中析",
|
||||
"LengthNum": 120,
|
||||
"WidthNum": 90,
|
||||
"HeightNum": 86,
|
||||
"DepthNum": 4,
|
||||
"StandardHeight": 0,
|
||||
"PipetteHeight": null,
|
||||
"HoleColum": 1,
|
||||
"HoleRow": 1,
|
||||
"ChannelNum": 1,
|
||||
"HoleDiameter": 4,
|
||||
"Volume": 1000,
|
||||
"ImagePath": null,
|
||||
"QRCode": null,
|
||||
"Qty": null,
|
||||
"CreateName": null,
|
||||
"CreateTime": "2022-11-07 09:00:10.7579131",
|
||||
"UpdateName": null,
|
||||
"UpdateTime": "2022-11-07 09:00:10.7579134",
|
||||
"IsStright": 0,
|
||||
"IsGeneral": 1,
|
||||
"IsControl": 0,
|
||||
"ArmCode": null,
|
||||
"XSpacing": null,
|
||||
"YSpacing": null,
|
||||
"materialEnum": null
|
||||
},
|
||||
{
|
||||
"uuid": "1592e84a07f74668af155588867f2da7",
|
||||
"Code": "12",
|
||||
"Name": "12",
|
||||
"SummaryName": "12",
|
||||
"SupplyType": 1,
|
||||
"Factory": "12",
|
||||
"LengthNum": 1,
|
||||
"WidthNum": 1,
|
||||
"HeightNum": 1,
|
||||
"DepthNum": 100,
|
||||
"StandardHeight": 0,
|
||||
"PipetteHeight": null,
|
||||
"HoleColum": 8,
|
||||
"HoleRow": 12,
|
||||
"ChannelNum": 12,
|
||||
"HoleDiameter": 7,
|
||||
"Volume": 12,
|
||||
"ImagePath": null,
|
||||
"QRCode": null,
|
||||
"Qty": null,
|
||||
"CreateName": null,
|
||||
"CreateTime": "2023-10-08 09:35:19.281766",
|
||||
"UpdateName": null,
|
||||
"UpdateTime": "2023-10-08 09:35:19.2817667",
|
||||
"IsStright": 0,
|
||||
"IsGeneral": 0,
|
||||
"IsControl": 0,
|
||||
"ArmCode": null,
|
||||
"XSpacing": 9,
|
||||
"YSpacing": 9,
|
||||
"materialEnum": null
|
||||
},
|
||||
{
|
||||
"uuid": "730067cf07ae43849ddf4034299030e9",
|
||||
"Code": "q1",
|
||||
"Name": "废弃槽",
|
||||
"SummaryName": "废弃槽",
|
||||
"SupplyType": 1,
|
||||
"Factory": "中析",
|
||||
"LengthNum": 190,
|
||||
"WidthNum": 135,
|
||||
"HeightNum": 75,
|
||||
"DepthNum": 1,
|
||||
"StandardHeight": 0,
|
||||
"PipetteHeight": null,
|
||||
"HoleColum": 1,
|
||||
"HoleRow": 1,
|
||||
"ChannelNum": 1,
|
||||
"HoleDiameter": 1,
|
||||
"Volume": 1250,
|
||||
"ImagePath": null,
|
||||
"QRCode": null,
|
||||
"Qty": null,
|
||||
"CreateName": null,
|
||||
"CreateTime": "2023-10-14 13:15:45.8172852",
|
||||
"UpdateName": null,
|
||||
"UpdateTime": "2023-10-14 13:15:45.8172869",
|
||||
"IsStright": 0,
|
||||
"IsGeneral": 1,
|
||||
"IsControl": 0,
|
||||
"ArmCode": null,
|
||||
"XSpacing": 1,
|
||||
"YSpacing": 1,
|
||||
"materialEnum": null
|
||||
},
|
||||
{
|
||||
"uuid": "57b1e4711e9e4a32b529f3132fc5931f",
|
||||
"Code": "q2",
|
||||
"Name": "96深孔板",
|
||||
"SummaryName": "96深孔板",
|
||||
"SupplyType": 1,
|
||||
"Factory": "中析",
|
||||
"LengthNum": 126.5,
|
||||
"WidthNum": 84.5,
|
||||
"HeightNum": 41.4,
|
||||
"DepthNum": 38.4,
|
||||
"StandardHeight": 0,
|
||||
"PipetteHeight": null,
|
||||
"HoleColum": 12,
|
||||
"HoleRow": 8,
|
||||
"ChannelNum": 96,
|
||||
"HoleDiameter": 8.3,
|
||||
"Volume": 1250,
|
||||
"ImagePath": null,
|
||||
"QRCode": null,
|
||||
"Qty": null,
|
||||
"CreateName": null,
|
||||
"CreateTime": "2023-10-14 13:19:55.7225524",
|
||||
"UpdateName": null,
|
||||
"UpdateTime": "2023-10-14 13:19:55.7225525",
|
||||
"IsStright": 0,
|
||||
"IsGeneral": 1,
|
||||
"IsControl": 0,
|
||||
"ArmCode": null,
|
||||
"XSpacing": 9,
|
||||
"YSpacing": 9,
|
||||
"materialEnum": null
|
||||
},
|
||||
{
|
||||
"uuid": "853dcfb6226f476e8b23c250217dc7da",
|
||||
"Code": "q3",
|
||||
"Name": "384板",
|
||||
"SummaryName": "384板",
|
||||
"SupplyType": 1,
|
||||
"Factory": "中析",
|
||||
"LengthNum": 126.6,
|
||||
"WidthNum": 84,
|
||||
"HeightNum": 9.4,
|
||||
"DepthNum": 8,
|
||||
"StandardHeight": 0,
|
||||
"PipetteHeight": null,
|
||||
"HoleColum": 24,
|
||||
"HoleRow": 16,
|
||||
"ChannelNum": 384,
|
||||
"HoleDiameter": 3,
|
||||
"Volume": 1250,
|
||||
"ImagePath": null,
|
||||
"QRCode": null,
|
||||
"Qty": null,
|
||||
"CreateName": null,
|
||||
"CreateTime": "2023-10-14 13:22:34.779818",
|
||||
"UpdateName": null,
|
||||
"UpdateTime": "2023-10-14 13:22:34.7798181",
|
||||
"IsStright": 0,
|
||||
"IsGeneral": 1,
|
||||
"IsControl": 0,
|
||||
"ArmCode": null,
|
||||
"XSpacing": 4.5,
|
||||
"YSpacing": 4.5,
|
||||
"materialEnum": null
|
||||
},
|
||||
{
|
||||
"uuid": "e201e206fcfc4e8ab51946a22e8cd1bc",
|
||||
"Code": "1",
|
||||
"Name": "ep",
|
||||
"SummaryName": "ep",
|
||||
"SupplyType": 1,
|
||||
"Factory": "中析",
|
||||
"LengthNum": 504,
|
||||
"WidthNum": 337,
|
||||
"HeightNum": 160,
|
||||
"DepthNum": 163,
|
||||
"StandardHeight": 0,
|
||||
"PipetteHeight": null,
|
||||
"HoleColum": 6,
|
||||
"HoleRow": 4,
|
||||
"ChannelNum": 24,
|
||||
"HoleDiameter": 41.2,
|
||||
"Volume": 1,
|
||||
"ImagePath": "",
|
||||
"QRCode": null,
|
||||
"Qty": null,
|
||||
"CreateName": null,
|
||||
"CreateTime": "2024-01-20 13:14:38.0308919",
|
||||
"UpdateName": null,
|
||||
"UpdateTime": "2024-02-05 16:27:07.2582693",
|
||||
"IsStright": 0,
|
||||
"IsGeneral": 1,
|
||||
"IsControl": 0,
|
||||
"ArmCode": null,
|
||||
"XSpacing": 21,
|
||||
"YSpacing": 18,
|
||||
"materialEnum": null
|
||||
},
|
||||
{
|
||||
"uuid": "01953864f6f140ccaa8ddffd4f3e46f5",
|
||||
"Code": "sdfrth654",
|
||||
"Name": "4道储液槽",
|
||||
"SummaryName": "4道储液槽",
|
||||
"SupplyType": 1,
|
||||
"Factory": "中析",
|
||||
"LengthNum": 100,
|
||||
"WidthNum": 40,
|
||||
"HeightNum": 30,
|
||||
"DepthNum": 10,
|
||||
"StandardHeight": 0,
|
||||
"PipetteHeight": null,
|
||||
"HoleColum": 4,
|
||||
"HoleRow": 8,
|
||||
"ChannelNum": 4,
|
||||
"HoleDiameter": 4,
|
||||
"Volume": 1000,
|
||||
"ImagePath": "",
|
||||
"QRCode": null,
|
||||
"Qty": null,
|
||||
"CreateName": null,
|
||||
"CreateTime": "2024-02-20 14:44:25.0021372",
|
||||
"UpdateName": null,
|
||||
"UpdateTime": "2024-02-20 15:28:21.3881302",
|
||||
"IsStright": 0,
|
||||
"IsGeneral": 1,
|
||||
"IsControl": 0,
|
||||
"ArmCode": null,
|
||||
"XSpacing": 27,
|
||||
"YSpacing": 9,
|
||||
"materialEnum": null
|
||||
}
|
||||
]
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,602 @@
|
||||
[
|
||||
{
|
||||
"uuid": "87ea11eeb24b43648ce294654b561fe7",
|
||||
"PlanName": "2341",
|
||||
"PlanCode": "2980eb",
|
||||
"PlanTarget": "",
|
||||
"Annotate": "",
|
||||
"CreateName": "",
|
||||
"CreateDate": "2023-05-15 18:24:00.8445073",
|
||||
"MatrixId": "34ba3f02-6fcd-48e6-bb8e-3b0ce1d54ed5"
|
||||
},
|
||||
{
|
||||
"uuid": "0a977d6ebc4244739793b0b6f8b3f815",
|
||||
"PlanName": "384测试方案(300模块)",
|
||||
"PlanCode": "9336ee",
|
||||
"PlanTarget": "",
|
||||
"Annotate": "",
|
||||
"CreateName": "",
|
||||
"CreateDate": "2023-06-13 10:34:52.5310959",
|
||||
"MatrixId": "74ed84ea-0b5d-4307-a966-ceb83fcaefe7"
|
||||
},
|
||||
{
|
||||
"uuid": "aff2cd213ad34072b370f44acb5ab658",
|
||||
"PlanName": "96孔吸300方案(单放)",
|
||||
"PlanCode": "9932fc",
|
||||
"PlanTarget": "测试用",
|
||||
"Annotate": "",
|
||||
"CreateName": "",
|
||||
"CreateDate": "2023-06-13 09:57:38.422353",
|
||||
"MatrixId": "bacd78be-b86d-49d6-973a-dd522834e4c4"
|
||||
},
|
||||
{
|
||||
"uuid": "97816d94f99a48409379013d19f0ab66",
|
||||
"PlanName": "384测试方案(50模块)",
|
||||
"PlanCode": "3964de",
|
||||
"PlanTarget": "",
|
||||
"Annotate": "",
|
||||
"CreateName": "",
|
||||
"CreateDate": "2023-06-13 10:32:22.8918817",
|
||||
"MatrixId": "74ed84ea-0b5d-4307-a966-ceb83fcaefe7"
|
||||
},
|
||||
{
|
||||
"uuid": "c3d86e9d7eed4ddb8c32e9234da659de",
|
||||
"PlanName": "96吸50方案(单放)",
|
||||
"PlanCode": "6994aa",
|
||||
"PlanTarget": "测试用",
|
||||
"Annotate": "",
|
||||
"CreateName": "",
|
||||
"CreateDate": "2023-08-08 11:50:14.6850189",
|
||||
"MatrixId": "bacd78be-b86d-49d6-973a-dd522834e4c4"
|
||||
},
|
||||
{
|
||||
"uuid": "59a97f77718d4bbba6bed1ddbf959772",
|
||||
"PlanName": "test12",
|
||||
"PlanCode": "8630fa",
|
||||
"PlanTarget": "12通道",
|
||||
"Annotate": "",
|
||||
"CreateName": "",
|
||||
"CreateDate": "2023-10-08 09:36:14.2536629",
|
||||
"MatrixId": "517c836e-56c6-4c06-a897-7074886061bd"
|
||||
},
|
||||
{
|
||||
"uuid": "84d50e4cf3034aa6a3de505a92b30812",
|
||||
"PlanName": "test001",
|
||||
"PlanCode": "9013fe",
|
||||
"PlanTarget": "",
|
||||
"Annotate": "",
|
||||
"CreateName": "",
|
||||
"CreateDate": "2023-10-08 16:37:57.2302499",
|
||||
"MatrixId": "ed9b1ceb-b879-4b8c-a246-2d4f54fbe970"
|
||||
},
|
||||
{
|
||||
"uuid": "d052b893c6324ae38d301a58614a5663",
|
||||
"PlanName": "test01",
|
||||
"PlanCode": "8524cf",
|
||||
"PlanTarget": "",
|
||||
"Annotate": "",
|
||||
"CreateName": "",
|
||||
"CreateDate": "2023-10-09 11:00:21.4973895",
|
||||
"MatrixId": "bacd78be-b86d-49d6-973a-dd522834e4c4"
|
||||
},
|
||||
{
|
||||
"uuid": "875a6eaa00e548b99318fd0be310e879",
|
||||
"PlanName": "test002",
|
||||
"PlanCode": "2477fe",
|
||||
"PlanTarget": "",
|
||||
"Annotate": "",
|
||||
"CreateName": "",
|
||||
"CreateDate": "2023-10-09 11:02:01.2027308",
|
||||
"MatrixId": "7374dc89-d425-42aa-b252-1b1338d3c2f2"
|
||||
},
|
||||
{
|
||||
"uuid": "ecb3cb37f603495d95a93522a6b611e3",
|
||||
"PlanName": "test02",
|
||||
"PlanCode": "5126cb",
|
||||
"PlanTarget": "",
|
||||
"Annotate": "",
|
||||
"CreateName": "",
|
||||
"CreateDate": "2023-10-09 11:02:14.7987877",
|
||||
"MatrixId": "7374dc89-d425-42aa-b252-1b1338d3c2f2"
|
||||
},
|
||||
{
|
||||
"uuid": "705edabbcbd645d0925e4e581643247c",
|
||||
"PlanName": "test003",
|
||||
"PlanCode": "4994cc",
|
||||
"PlanTarget": "",
|
||||
"Annotate": "",
|
||||
"CreateName": "",
|
||||
"CreateDate": "2023-10-09 11:41:04.1715458",
|
||||
"MatrixId": "4c126841-5c37-49c7-b4e8-539983bc9cc4"
|
||||
},
|
||||
{
|
||||
"uuid": "6c58136d7de54a6abb7b51e6327eacac",
|
||||
"PlanName": "test04",
|
||||
"PlanCode": "9704dd",
|
||||
"PlanTarget": "",
|
||||
"Annotate": "",
|
||||
"CreateName": "",
|
||||
"CreateDate": "2023-10-09 11:51:59.1752071",
|
||||
"MatrixId": "4c126841-5c37-49c7-b4e8-539983bc9cc4"
|
||||
},
|
||||
{
|
||||
"uuid": "208f00a911b846d9922b2e72bdda978c",
|
||||
"PlanName": "96版位 50ul量程",
|
||||
"PlanCode": "7595be",
|
||||
"PlanTarget": "213213",
|
||||
"Annotate": "",
|
||||
"CreateName": "",
|
||||
"CreateDate": "2023-10-18 19:12:17.4641981",
|
||||
"MatrixId": "b3da2b21-875b-4ae6-8077-ec951730201b"
|
||||
},
|
||||
{
|
||||
"uuid": "40bd0ca25ffb4be6b246353db6ebefc9",
|
||||
"PlanName": "96版位 300ul量程",
|
||||
"PlanCode": "7421fc",
|
||||
"PlanTarget": "",
|
||||
"Annotate": "",
|
||||
"CreateName": "",
|
||||
"CreateDate": "2023-10-14 14:47:03.8105699",
|
||||
"MatrixId": "b3da2b21-875b-4ae6-8077-ec951730201b"
|
||||
},
|
||||
{
|
||||
"uuid": "30b838bb7d124ec885b506df29ee7860",
|
||||
"PlanName": "300版位 50ul量程",
|
||||
"PlanCode": "6364cc",
|
||||
"PlanTarget": "",
|
||||
"Annotate": "",
|
||||
"CreateName": "",
|
||||
"CreateDate": "2023-10-14 14:48:05.2235254",
|
||||
"MatrixId": "f8c70333-b717-4ca0-9306-c40fd5f156fb"
|
||||
},
|
||||
{
|
||||
"uuid": "e53c591c86334c6f92d3b1afa107bcf8",
|
||||
"PlanName": "384版位 300ul量程",
|
||||
"PlanCode": "4029be",
|
||||
"PlanTarget": "",
|
||||
"Annotate": "",
|
||||
"CreateName": "",
|
||||
"CreateDate": "2023-10-14 14:47:48.9478679",
|
||||
"MatrixId": "f8c70333-b717-4ca0-9306-c40fd5f156fb"
|
||||
},
|
||||
{
|
||||
"uuid": "1d26d1ab45c6431990ba0e00cc1f78d2",
|
||||
"PlanName": "96版位梯度稀释 50ul量程",
|
||||
"PlanCode": "3502cf",
|
||||
"PlanTarget": "",
|
||||
"Annotate": "",
|
||||
"CreateName": "",
|
||||
"CreateDate": "2023-10-14 14:48:12.8676989",
|
||||
"MatrixId": "916bbd00-e66c-4237-9843-e049b70b740a"
|
||||
},
|
||||
{
|
||||
"uuid": "7a0383b4fbb543339723513228365451",
|
||||
"PlanName": "96版位梯度稀释 300ul量程",
|
||||
"PlanCode": "9345fe",
|
||||
"PlanTarget": "",
|
||||
"Annotate": "",
|
||||
"CreateName": "",
|
||||
"CreateDate": "2023-10-14 14:50:02.0250566",
|
||||
"MatrixId": "916bbd00-e66c-4237-9843-e049b70b740a"
|
||||
},
|
||||
{
|
||||
"uuid": "69d4882f0f024fb5a3b91010f149ff89",
|
||||
"PlanName": "测试",
|
||||
"PlanCode": "3941bf",
|
||||
"PlanTarget": "",
|
||||
"Annotate": "",
|
||||
"CreateName": "",
|
||||
"CreateDate": "2023-12-11 15:24:30.1371824",
|
||||
"MatrixId": "b3da2b21-875b-4ae6-8077-ec951730201b"
|
||||
},
|
||||
{
|
||||
"uuid": "3603f89f4e0945f68353a33e8017ba6e",
|
||||
"PlanName": "测试111",
|
||||
"PlanCode": "8056eb",
|
||||
"PlanTarget": "",
|
||||
"Annotate": "",
|
||||
"CreateName": "",
|
||||
"CreateDate": "2024-01-16 09:29:12.1441631",
|
||||
"MatrixId": "b3da2b21-875b-4ae6-8077-ec951730201b"
|
||||
},
|
||||
{
|
||||
"uuid": "b44be8260740460598816c40f13fd6b4",
|
||||
"PlanName": "测试12",
|
||||
"PlanCode": "8272fb",
|
||||
"PlanTarget": "",
|
||||
"Annotate": "",
|
||||
"CreateName": "",
|
||||
"CreateDate": "2024-01-16 10:40:54.2543702",
|
||||
"MatrixId": "b3da2b21-875b-4ae6-8077-ec951730201b"
|
||||
},
|
||||
{
|
||||
"uuid": "f189a50122d54a568f3d39dc1f996167",
|
||||
"PlanName": "0.5",
|
||||
"PlanCode": "2093ec",
|
||||
"PlanTarget": "",
|
||||
"Annotate": "",
|
||||
"CreateName": "",
|
||||
"CreateDate": "2024-01-16 13:06:37.8280696",
|
||||
"MatrixId": "b3da2b21-875b-4ae6-8077-ec951730201b"
|
||||
},
|
||||
{
|
||||
"uuid": "b48218c8f2274b108e278d019c9b5126",
|
||||
"PlanName": "3",
|
||||
"PlanCode": "9493bb",
|
||||
"PlanTarget": "",
|
||||
"Annotate": "",
|
||||
"CreateName": "",
|
||||
"CreateDate": "2024-01-16 14:20:42.4761092",
|
||||
"MatrixId": "b3da2b21-875b-4ae6-8077-ec951730201b"
|
||||
},
|
||||
{
|
||||
"uuid": "41d2ebc5ab5b4b2da3e203937c5cbe70",
|
||||
"PlanName": "6",
|
||||
"PlanCode": "5586de",
|
||||
"PlanTarget": "",
|
||||
"Annotate": "",
|
||||
"CreateName": "",
|
||||
"CreateDate": "2024-01-16 15:21:03.4440875",
|
||||
"MatrixId": "b3da2b21-875b-4ae6-8077-ec951730201b"
|
||||
},
|
||||
{
|
||||
"uuid": "49ec03499aa646b9b8069a783dbeca1c",
|
||||
"PlanName": "7",
|
||||
"PlanCode": "1162bc",
|
||||
"PlanTarget": "",
|
||||
"Annotate": "",
|
||||
"CreateName": "",
|
||||
"CreateDate": "2024-01-16 15:31:33.7359724",
|
||||
"MatrixId": "b3da2b21-875b-4ae6-8077-ec951730201b"
|
||||
},
|
||||
{
|
||||
"uuid": "a9c6d149cdf04636ac43cfb7623e4e7f",
|
||||
"PlanName": "8",
|
||||
"PlanCode": "7354eb",
|
||||
"PlanTarget": "",
|
||||
"Annotate": "",
|
||||
"CreateName": "",
|
||||
"CreateDate": "2024-01-16 15:39:32.2399414",
|
||||
"MatrixId": "b3da2b21-875b-4ae6-8077-ec951730201b"
|
||||
},
|
||||
{
|
||||
"uuid": "0e3a36cabefa4f5497e35193db48b559",
|
||||
"PlanName": "9",
|
||||
"PlanCode": "4453ba",
|
||||
"PlanTarget": "",
|
||||
"Annotate": "",
|
||||
"CreateName": "",
|
||||
"CreateDate": "2024-01-16 15:49:31.5830134",
|
||||
"MatrixId": "b3da2b21-875b-4ae6-8077-ec951730201b"
|
||||
},
|
||||
{
|
||||
"uuid": "d0a0d926e2034abc94b4d883951a78f7",
|
||||
"PlanName": "10",
|
||||
"PlanCode": "5797ab",
|
||||
"PlanTarget": "",
|
||||
"Annotate": "",
|
||||
"CreateName": "",
|
||||
"CreateDate": "2024-01-16 16:00:25.4439315",
|
||||
"MatrixId": "b3da2b21-875b-4ae6-8077-ec951730201b"
|
||||
},
|
||||
{
|
||||
"uuid": "22ac523a47e7421e80f401baf1526daf",
|
||||
"PlanName": "50",
|
||||
"PlanCode": "2507ca",
|
||||
"PlanTarget": "",
|
||||
"Annotate": "",
|
||||
"CreateName": "",
|
||||
"CreateDate": "2024-01-16 16:23:13.8022807",
|
||||
"MatrixId": "b3da2b21-875b-4ae6-8077-ec951730201b"
|
||||
},
|
||||
{
|
||||
"uuid": "fdea60f535ee4bc39c02c602a64f46bd",
|
||||
"PlanName": "11",
|
||||
"PlanCode": "1574ae",
|
||||
"PlanTarget": "",
|
||||
"Annotate": "",
|
||||
"CreateName": "",
|
||||
"CreateDate": "2024-01-18 09:14:59.8230591",
|
||||
"MatrixId": "b3da2b21-875b-4ae6-8077-ec951730201b"
|
||||
},
|
||||
{
|
||||
"uuid": "6650f7df6b8944f98476da92ce81d688",
|
||||
"PlanName": "12",
|
||||
"PlanCode": "2145bd",
|
||||
"PlanTarget": "",
|
||||
"Annotate": "",
|
||||
"CreateName": "",
|
||||
"CreateDate": "2024-01-18 09:45:34.137906",
|
||||
"MatrixId": "b3da2b21-875b-4ae6-8077-ec951730201b"
|
||||
},
|
||||
{
|
||||
"uuid": "9415a69280c042a09d6836f5eeddf40f",
|
||||
"PlanName": "100",
|
||||
"PlanCode": "2073fd",
|
||||
"PlanTarget": "",
|
||||
"Annotate": "",
|
||||
"CreateName": "",
|
||||
"CreateDate": "2024-01-18 10:12:29.9998926",
|
||||
"MatrixId": "b3da2b21-875b-4ae6-8077-ec951730201b"
|
||||
},
|
||||
{
|
||||
"uuid": "d9740fea94a04c2db44b1364a336b338",
|
||||
"PlanName": "250",
|
||||
"PlanCode": "2601ea",
|
||||
"PlanTarget": "",
|
||||
"Annotate": "",
|
||||
"CreateName": "",
|
||||
"CreateDate": "2024-01-18 11:15:54.2583401",
|
||||
"MatrixId": "b3da2b21-875b-4ae6-8077-ec951730201b"
|
||||
},
|
||||
{
|
||||
"uuid": "1d80c1fff5af442595c21963e6ca9fee",
|
||||
"PlanName": "160",
|
||||
"PlanCode": "6612ea",
|
||||
"PlanTarget": "",
|
||||
"Annotate": "",
|
||||
"CreateName": "",
|
||||
"CreateDate": "2024-01-18 11:18:59.0457638",
|
||||
"MatrixId": "b3da2b21-875b-4ae6-8077-ec951730201b"
|
||||
},
|
||||
{
|
||||
"uuid": "36889fb926aa480cb42de97700522bbf",
|
||||
"PlanName": "200",
|
||||
"PlanCode": "3174dc",
|
||||
"PlanTarget": "",
|
||||
"Annotate": "",
|
||||
"CreateName": "",
|
||||
"CreateDate": "2024-01-18 11:20:15.7676326",
|
||||
"MatrixId": "b3da2b21-875b-4ae6-8077-ec951730201b"
|
||||
},
|
||||
{
|
||||
"uuid": "bd90ae2846c14e708854938158fd3443",
|
||||
"PlanName": "300",
|
||||
"PlanCode": "2665df",
|
||||
"PlanTarget": "",
|
||||
"Annotate": "",
|
||||
"CreateName": "",
|
||||
"CreateDate": "2024-01-18 13:00:16.9242256",
|
||||
"MatrixId": "b3da2b21-875b-4ae6-8077-ec951730201b"
|
||||
},
|
||||
{
|
||||
"uuid": "9df4857d2bef45bcad14cc13055e9f7b",
|
||||
"PlanName": "500",
|
||||
"PlanCode": "4771ab",
|
||||
"PlanTarget": "",
|
||||
"Annotate": "",
|
||||
"CreateName": "",
|
||||
"CreateDate": "2024-01-18 13:26:32.3910805",
|
||||
"MatrixId": "b3da2b21-875b-4ae6-8077-ec951730201b"
|
||||
},
|
||||
{
|
||||
"uuid": "d2f6e63cf1ff41a4a8d03f4444a2aeac",
|
||||
"PlanName": "800",
|
||||
"PlanCode": "4560bc",
|
||||
"PlanTarget": "",
|
||||
"Annotate": "",
|
||||
"CreateName": "",
|
||||
"CreateDate": "2024-01-18 13:42:35.5153947",
|
||||
"MatrixId": "b3da2b21-875b-4ae6-8077-ec951730201b"
|
||||
},
|
||||
{
|
||||
"uuid": "f40a6f4326a346d39d5a82f6262aba47",
|
||||
"PlanName": "测试12345",
|
||||
"PlanCode": "3402ab",
|
||||
"PlanTarget": "",
|
||||
"Annotate": "",
|
||||
"CreateName": "",
|
||||
"CreateDate": "2024-01-18 14:37:29.8890777",
|
||||
"MatrixId": "b3da2b21-875b-4ae6-8077-ec951730201b"
|
||||
},
|
||||
{
|
||||
"uuid": "4248035f01e943faa6d71697ed386e19",
|
||||
"PlanName": "995",
|
||||
"PlanCode": "2688dc",
|
||||
"PlanTarget": "",
|
||||
"Annotate": "",
|
||||
"CreateName": "",
|
||||
"CreateDate": "2024-01-18 14:39:23.5292196",
|
||||
"MatrixId": "b3da2b21-875b-4ae6-8077-ec951730201b"
|
||||
},
|
||||
{
|
||||
"uuid": "a73bc780e4d04099bf54c2b90fa7b974",
|
||||
"PlanName": "1000",
|
||||
"PlanCode": "2889bf",
|
||||
"PlanTarget": "",
|
||||
"Annotate": "",
|
||||
"CreateName": "",
|
||||
"CreateDate": "2024-01-19 09:16:37.7818522",
|
||||
"MatrixId": "b3da2b21-875b-4ae6-8077-ec951730201b"
|
||||
},
|
||||
{
|
||||
"uuid": "4d97363a0a334094a1ff24494a902d02",
|
||||
"PlanName": "2.。",
|
||||
"PlanCode": "6527ff",
|
||||
"PlanTarget": "",
|
||||
"Annotate": "",
|
||||
"CreateName": "",
|
||||
"CreateDate": "2024-01-19 11:38:00.0672017",
|
||||
"MatrixId": "b3da2b21-875b-4ae6-8077-ec951730201b"
|
||||
},
|
||||
{
|
||||
"uuid": "6eec360c74464769967ebefa43b7aec1",
|
||||
"PlanName": "2222222",
|
||||
"PlanCode": "8763ce",
|
||||
"PlanTarget": "",
|
||||
"Annotate": "",
|
||||
"CreateName": "",
|
||||
"CreateDate": "2024-01-19 11:40:42.7038484",
|
||||
"MatrixId": "b3da2b21-875b-4ae6-8077-ec951730201b"
|
||||
},
|
||||
{
|
||||
"uuid": "986049c83b054171a1b34dd49b3ca9cf",
|
||||
"PlanName": "9ul",
|
||||
"PlanCode": "1945fd",
|
||||
"PlanTarget": "",
|
||||
"Annotate": "",
|
||||
"CreateName": "",
|
||||
"CreateDate": "2024-01-19 13:33:06.6556398",
|
||||
"MatrixId": "b3da2b21-875b-4ae6-8077-ec951730201b"
|
||||
},
|
||||
{
|
||||
"uuid": "462eed73962142c2bd3b8fe717caceb6",
|
||||
"PlanName": "8ul",
|
||||
"PlanCode": "6912fc",
|
||||
"PlanTarget": "",
|
||||
"Annotate": "",
|
||||
"CreateName": "",
|
||||
"CreateDate": "2024-01-19 15:16:17.4254316",
|
||||
"MatrixId": "b3da2b21-875b-4ae6-8077-ec951730201b"
|
||||
},
|
||||
{
|
||||
"uuid": "b2f0c7ab462f4cf1bae56ee59a49a253",
|
||||
"PlanName": "11.",
|
||||
"PlanCode": "6190ba",
|
||||
"PlanTarget": "",
|
||||
"Annotate": "",
|
||||
"CreateName": "",
|
||||
"CreateDate": "2024-01-19 15:21:57.6729366",
|
||||
"MatrixId": "b3da2b21-875b-4ae6-8077-ec951730201b"
|
||||
},
|
||||
{
|
||||
"uuid": "b9768a1d91444d4a86b7a013467bee95",
|
||||
"PlanName": "8ulll",
|
||||
"PlanCode": "6899be",
|
||||
"PlanTarget": "",
|
||||
"Annotate": "",
|
||||
"CreateName": "",
|
||||
"CreateDate": "2024-01-19 15:29:03.2029069",
|
||||
"MatrixId": "b3da2b21-875b-4ae6-8077-ec951730201b"
|
||||
},
|
||||
{
|
||||
"uuid": "98621898cd514bc9a1ac0c92362284f4",
|
||||
"PlanName": "7u",
|
||||
"PlanCode": "7651fe",
|
||||
"PlanTarget": "",
|
||||
"Annotate": "",
|
||||
"CreateName": "",
|
||||
"CreateDate": "2024-01-19 15:57:16.4898686",
|
||||
"MatrixId": "b3da2b21-875b-4ae6-8077-ec951730201b"
|
||||
},
|
||||
{
|
||||
"uuid": "4d03142fd86844db8e23c19061b3d505",
|
||||
"PlanName": "55555",
|
||||
"PlanCode": "7963fe",
|
||||
"PlanTarget": "",
|
||||
"Annotate": "",
|
||||
"CreateName": "",
|
||||
"CreateDate": "2024-01-19 16:23:37.7271107",
|
||||
"MatrixId": "b3da2b21-875b-4ae6-8077-ec951730201b"
|
||||
},
|
||||
{
|
||||
"uuid": "c78c3f38a59748c3aef949405e434b05",
|
||||
"PlanName": "44443",
|
||||
"PlanCode": "4564dd",
|
||||
"PlanTarget": "",
|
||||
"Annotate": "",
|
||||
"CreateName": "",
|
||||
"CreateDate": "2024-01-19 16:29:26.6765074",
|
||||
"MatrixId": "b3da2b21-875b-4ae6-8077-ec951730201b"
|
||||
},
|
||||
{
|
||||
"uuid": "0fc4ffd86091451db26162af4f7b235e",
|
||||
"PlanName": "u",
|
||||
"PlanCode": "9246de",
|
||||
"PlanTarget": "",
|
||||
"Annotate": "",
|
||||
"CreateName": "",
|
||||
"CreateDate": "2024-01-19 16:34:15.4217796",
|
||||
"MatrixId": "b3da2b21-875b-4ae6-8077-ec951730201b"
|
||||
},
|
||||
{
|
||||
"uuid": "a08748982b934daab8752f55796e1b0c",
|
||||
"PlanName": "666y",
|
||||
"PlanCode": "5492ce",
|
||||
"PlanTarget": "",
|
||||
"Annotate": "",
|
||||
"CreateName": "",
|
||||
"CreateDate": "2024-01-19 16:38:55.6092122",
|
||||
"MatrixId": "b3da2b21-875b-4ae6-8077-ec951730201b"
|
||||
},
|
||||
{
|
||||
"uuid": "2317611bdb614e45b61a5118e58e3a2a",
|
||||
"PlanName": "8ull、",
|
||||
"PlanCode": "4641de",
|
||||
"PlanTarget": "",
|
||||
"Annotate": "",
|
||||
"CreateName": "",
|
||||
"CreateDate": "2024-01-19 16:46:26.6184295",
|
||||
"MatrixId": "b3da2b21-875b-4ae6-8077-ec951730201b"
|
||||
},
|
||||
{
|
||||
"uuid": "62cb45ac3af64a46aa6d450ba56963e7",
|
||||
"PlanName": "33333",
|
||||
"PlanCode": "1270aa",
|
||||
"PlanTarget": "",
|
||||
"Annotate": "",
|
||||
"CreateName": "",
|
||||
"CreateDate": "2024-01-19 16:49:19.6115492",
|
||||
"MatrixId": "b3da2b21-875b-4ae6-8077-ec951730201b"
|
||||
},
|
||||
{
|
||||
"uuid": "321f717a3a2640a3bfc9515aee7d1052",
|
||||
"PlanName": "999",
|
||||
"PlanCode": "7597ed",
|
||||
"PlanTarget": "",
|
||||
"Annotate": "",
|
||||
"CreateName": "",
|
||||
"CreateDate": "2024-01-19 16:58:22.6149002",
|
||||
"MatrixId": "b3da2b21-875b-4ae6-8077-ec951730201b"
|
||||
},
|
||||
{
|
||||
"uuid": "6c3246ac0f974a6abc24c83bf45e1cf4",
|
||||
"PlanName": "QPCR",
|
||||
"PlanCode": "7297ad",
|
||||
"PlanTarget": "",
|
||||
"Annotate": "",
|
||||
"CreateName": "",
|
||||
"CreateDate": "2024-02-19 13:03:44.3456134",
|
||||
"MatrixId": "f02830f3-ed67-49fb-9865-c31828ba3a48"
|
||||
},
|
||||
{
|
||||
"uuid": "1d307a2c095b461abeec6e8521565ad3",
|
||||
"PlanName": "绝对定量",
|
||||
"PlanCode": "8540af",
|
||||
"PlanTarget": "",
|
||||
"Annotate": "",
|
||||
"CreateName": "",
|
||||
"CreateDate": "2024-02-19 13:35:14.2243691",
|
||||
"MatrixId": "739ddf78-e04c-4d43-9293-c35d31f36f51"
|
||||
},
|
||||
{
|
||||
"uuid": "bbd6dc765867466ca2a415525f5bdbdd",
|
||||
"PlanName": "血凝",
|
||||
"PlanCode": "6513ee",
|
||||
"PlanTarget": "",
|
||||
"Annotate": "",
|
||||
"CreateName": "",
|
||||
"CreateDate": "2024-02-20 16:14:25.0364174",
|
||||
"MatrixId": "20e70dcb-63f6-4bac-82e3-29e88eb6a7ab"
|
||||
},
|
||||
{
|
||||
"uuid": "f7282ecbfee44e91b05cefbc1beac1ae",
|
||||
"PlanName": "血凝抑制",
|
||||
"PlanCode": "1431ba",
|
||||
"PlanTarget": "",
|
||||
"Annotate": "",
|
||||
"CreateName": "",
|
||||
"CreateDate": "2024-02-21 10:00:05.8661038",
|
||||
"MatrixId": "1c948beb-4c32-494f-b226-14bb84b3e144"
|
||||
},
|
||||
{
|
||||
"uuid": "196e0d757c574020932b64b69e88fac9",
|
||||
"PlanName": "测试杀杀杀",
|
||||
"PlanCode": "9833df",
|
||||
"PlanTarget": "",
|
||||
"Annotate": "",
|
||||
"CreateName": "",
|
||||
"CreateDate": "2024-02-21 10:54:19.3136491",
|
||||
"MatrixId": "3667ead7-9044-46ad-b73e-655b57c8c6b9"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,302 @@
|
||||
[
|
||||
{
|
||||
"id": "630a9ca9-dfbf-40f9-b90b-6df73e6a1d7f",
|
||||
"number": 1,
|
||||
"name": "T1",
|
||||
"row": 0,
|
||||
"col": 0,
|
||||
"row_span": 1,
|
||||
"col_span": 1,
|
||||
"plate_position_id": "ef121889-2724-4b3d-a786-bbf0bd213c3d"
|
||||
},
|
||||
{
|
||||
"id": "db955443-1397-4a7a-a0cc-185eb6422c27",
|
||||
"number": 2,
|
||||
"name": "T2",
|
||||
"row": 0,
|
||||
"col": 1,
|
||||
"row_span": 1,
|
||||
"col_span": 1,
|
||||
"plate_position_id": "ef121889-2724-4b3d-a786-bbf0bd213c3d"
|
||||
},
|
||||
{
|
||||
"id": "635e8265-e2b9-430e-8a4e-ddf94256266f",
|
||||
"number": 3,
|
||||
"name": "T3",
|
||||
"row": 0,
|
||||
"col": 2,
|
||||
"row_span": 2,
|
||||
"col_span": 1,
|
||||
"plate_position_id": "ef121889-2724-4b3d-a786-bbf0bd213c3d"
|
||||
},
|
||||
{
|
||||
"id": "6de1521d-a249-4a7e-800f-1d49b5c7b56f",
|
||||
"number": 4,
|
||||
"name": "T4",
|
||||
"row": 1,
|
||||
"col": 0,
|
||||
"row_span": 1,
|
||||
"col_span": 1,
|
||||
"plate_position_id": "ef121889-2724-4b3d-a786-bbf0bd213c3d"
|
||||
},
|
||||
{
|
||||
"id": "4f9f2527-0f71-4ec4-a0ac-e546407e2960",
|
||||
"number": 5,
|
||||
"name": "T5",
|
||||
"row": 1,
|
||||
"col": 1,
|
||||
"row_span": 1,
|
||||
"col_span": 1,
|
||||
"plate_position_id": "ef121889-2724-4b3d-a786-bbf0bd213c3d"
|
||||
},
|
||||
{
|
||||
"id": "55ecff40-453f-4a5f-9ed3-1267b0a03cae",
|
||||
"number": 1,
|
||||
"name": "T1",
|
||||
"row": 0,
|
||||
"col": 0,
|
||||
"row_span": 1,
|
||||
"col_span": 1,
|
||||
"plate_position_id": "9af15efc-29d2-4c44-8533-bbaf24913be6"
|
||||
},
|
||||
{
|
||||
"id": "7dcd9c87-6702-4659-b28a-f6565b27f8e3",
|
||||
"number": 2,
|
||||
"name": "T2",
|
||||
"row": 0,
|
||||
"col": 1,
|
||||
"row_span": 1,
|
||||
"col_span": 1,
|
||||
"plate_position_id": "9af15efc-29d2-4c44-8533-bbaf24913be6"
|
||||
},
|
||||
{
|
||||
"id": "67e51bd6-6eee-46e4-931c-73d9e07397eb",
|
||||
"number": 3,
|
||||
"name": "T3",
|
||||
"row": 0,
|
||||
"col": 2,
|
||||
"row_span": 1,
|
||||
"col_span": 1,
|
||||
"plate_position_id": "9af15efc-29d2-4c44-8533-bbaf24913be6"
|
||||
},
|
||||
{
|
||||
"id": "e1289406-4f5e-4966-a1e6-fb29be6cd4bd",
|
||||
"number": 4,
|
||||
"name": "T4",
|
||||
"row": 0,
|
||||
"col": 3,
|
||||
"row_span": 1,
|
||||
"col_span": 1,
|
||||
"plate_position_id": "9af15efc-29d2-4c44-8533-bbaf24913be6"
|
||||
},
|
||||
{
|
||||
"id": "4ecb9ef7-cbd4-44bc-a6a9-fdbbefdc01d6",
|
||||
"number": 5,
|
||||
"name": "T5",
|
||||
"row": 1,
|
||||
"col": 0,
|
||||
"row_span": 1,
|
||||
"col_span": 1,
|
||||
"plate_position_id": "9af15efc-29d2-4c44-8533-bbaf24913be6"
|
||||
},
|
||||
{
|
||||
"id": "c7bcaeeb-7ce7-479d-8dae-e82f4023a2b6",
|
||||
"number": 6,
|
||||
"name": "T6",
|
||||
"row": 1,
|
||||
"col": 1,
|
||||
"row_span": 1,
|
||||
"col_span": 1,
|
||||
"plate_position_id": "9af15efc-29d2-4c44-8533-bbaf24913be6"
|
||||
},
|
||||
{
|
||||
"id": "e502d5ee-3197-4f60-8ac4-3bc005349dfd",
|
||||
"number": 7,
|
||||
"name": "T7",
|
||||
"row": 1,
|
||||
"col": 2,
|
||||
"row_span": 1,
|
||||
"col_span": 1,
|
||||
"plate_position_id": "9af15efc-29d2-4c44-8533-bbaf24913be6"
|
||||
},
|
||||
{
|
||||
"id": "829c78b0-9e05-448f-9531-6d19c094c83f",
|
||||
"number": 8,
|
||||
"name": "T8",
|
||||
"row": 1,
|
||||
"col": 3,
|
||||
"row_span": 1,
|
||||
"col_span": 1,
|
||||
"plate_position_id": "9af15efc-29d2-4c44-8533-bbaf24913be6"
|
||||
},
|
||||
{
|
||||
"id": "d0fd64d6-360d-4f5e-9451-21a332e247f5",
|
||||
"number": 9,
|
||||
"name": "T9",
|
||||
"row": 2,
|
||||
"col": 0,
|
||||
"row_span": 1,
|
||||
"col_span": 1,
|
||||
"plate_position_id": "9af15efc-29d2-4c44-8533-bbaf24913be6"
|
||||
},
|
||||
{
|
||||
"id": "7f3da25d-0be0-4e07-885f-fbbbfa952f9f",
|
||||
"number": 10,
|
||||
"name": "T10",
|
||||
"row": 2,
|
||||
"col": 1,
|
||||
"row_span": 1,
|
||||
"col_span": 1,
|
||||
"plate_position_id": "9af15efc-29d2-4c44-8533-bbaf24913be6"
|
||||
},
|
||||
{
|
||||
"id": "491d396d-7264-43d6-9ad4-60bffbe66c26",
|
||||
"number": 11,
|
||||
"name": "T11",
|
||||
"row": 2,
|
||||
"col": 2,
|
||||
"row_span": 1,
|
||||
"col_span": 1,
|
||||
"plate_position_id": "9af15efc-29d2-4c44-8533-bbaf24913be6"
|
||||
},
|
||||
{
|
||||
"id": "a8853b6d-639d-46f9-a4bf-9153c0c22461",
|
||||
"number": 12,
|
||||
"name": "T12",
|
||||
"row": 2,
|
||||
"col": 3,
|
||||
"row_span": 1,
|
||||
"col_span": 1,
|
||||
"plate_position_id": "9af15efc-29d2-4c44-8533-bbaf24913be6"
|
||||
},
|
||||
{
|
||||
"id": "b7beb8d0-0003-471d-bd8d-a9c0e09b07d5",
|
||||
"number": 1,
|
||||
"name": "T1",
|
||||
"row": 0,
|
||||
"col": 0,
|
||||
"row_span": 1,
|
||||
"col_span": 1,
|
||||
"plate_position_id": "6ed12532-eeae-4c16-a9ae-18f0b0cfc546"
|
||||
},
|
||||
{
|
||||
"id": "306e3f96-a6d7-484a-83ef-722e3710d5c4",
|
||||
"number": 2,
|
||||
"name": "T2",
|
||||
"row": 0,
|
||||
"col": 1,
|
||||
"row_span": 1,
|
||||
"col_span": 1,
|
||||
"plate_position_id": "6ed12532-eeae-4c16-a9ae-18f0b0cfc546"
|
||||
},
|
||||
{
|
||||
"id": "4e7bb617-ac1a-4360-b379-7ac4197089c4",
|
||||
"number": 3,
|
||||
"name": "T3",
|
||||
"row": 0,
|
||||
"col": 2,
|
||||
"row_span": 1,
|
||||
"col_span": 1,
|
||||
"plate_position_id": "6ed12532-eeae-4c16-a9ae-18f0b0cfc546"
|
||||
},
|
||||
{
|
||||
"id": "af583180-c29d-418e-9061-9e030f77cf57",
|
||||
"number": 4,
|
||||
"name": "T4",
|
||||
"row": 0,
|
||||
"col": 3,
|
||||
"row_span": 2,
|
||||
"col_span": 1,
|
||||
"plate_position_id": "6ed12532-eeae-4c16-a9ae-18f0b0cfc546"
|
||||
},
|
||||
{
|
||||
"id": "24a85ce8-e9e3-44f5-9d08-25116173ba75",
|
||||
"number": 5,
|
||||
"name": "T5",
|
||||
"row": 1,
|
||||
"col": 0,
|
||||
"row_span": 1,
|
||||
"col_span": 1,
|
||||
"plate_position_id": "6ed12532-eeae-4c16-a9ae-18f0b0cfc546"
|
||||
},
|
||||
{
|
||||
"id": "7bf61a40-f65a-4d2f-bb19-d42bfd80e2e9",
|
||||
"number": 6,
|
||||
"name": "T6",
|
||||
"row": 1,
|
||||
"col": 1,
|
||||
"row_span": 1,
|
||||
"col_span": 1,
|
||||
"plate_position_id": "6ed12532-eeae-4c16-a9ae-18f0b0cfc546"
|
||||
},
|
||||
{
|
||||
"id": "a3177806-3c02-4c4f-86d6-604a38c2ba2a",
|
||||
"number": 7,
|
||||
"name": "T7",
|
||||
"row": 1,
|
||||
"col": 2,
|
||||
"row_span": 1,
|
||||
"col_span": 1,
|
||||
"plate_position_id": "6ed12532-eeae-4c16-a9ae-18f0b0cfc546"
|
||||
},
|
||||
{
|
||||
"id": "8ccaad5a-8588-4ff3-b0d7-17e7fd5ac6cc",
|
||||
"number": 1,
|
||||
"name": "T1",
|
||||
"row": 0,
|
||||
"col": 0,
|
||||
"row_span": 1,
|
||||
"col_span": 1,
|
||||
"plate_position_id": "77673540-92c4-4404-b659-4257034a9c5e"
|
||||
},
|
||||
{
|
||||
"id": "93ae7707-b6b8-4bc4-8700-c500c3d7b165",
|
||||
"number": 2,
|
||||
"name": "T2",
|
||||
"row": 0,
|
||||
"col": 1,
|
||||
"row_span": 1,
|
||||
"col_span": 1,
|
||||
"plate_position_id": "77673540-92c4-4404-b659-4257034a9c5e"
|
||||
},
|
||||
{
|
||||
"id": "3591a07b-4922-4882-996f-7bebee843be1",
|
||||
"number": 3,
|
||||
"name": "T3",
|
||||
"row": 0,
|
||||
"col": 2,
|
||||
"row_span": 1,
|
||||
"col_span": 1,
|
||||
"plate_position_id": "77673540-92c4-4404-b659-4257034a9c5e"
|
||||
},
|
||||
{
|
||||
"id": "669fdba9-b20c-4bd2-8352-8fe5682e3e0c",
|
||||
"number": 4,
|
||||
"name": "T4",
|
||||
"row": 1,
|
||||
"col": 0,
|
||||
"row_span": 1,
|
||||
"col_span": 1,
|
||||
"plate_position_id": "77673540-92c4-4404-b659-4257034a9c5e"
|
||||
},
|
||||
{
|
||||
"id": "8bf3333e-4a73-4e4c-959a-8ae44e1038a2",
|
||||
"number": 5,
|
||||
"name": "T5",
|
||||
"row": 1,
|
||||
"col": 1,
|
||||
"row_span": 1,
|
||||
"col_span": 1,
|
||||
"plate_position_id": "77673540-92c4-4404-b659-4257034a9c5e"
|
||||
},
|
||||
{
|
||||
"id": "2837bf69-273a-4cbb-a74c-0af1b362f609",
|
||||
"number": 6,
|
||||
"name": "T6",
|
||||
"row": 1,
|
||||
"col": 2,
|
||||
"row_span": 1,
|
||||
"col_span": 1,
|
||||
"plate_position_id": "77673540-92c4-4404-b659-4257034a9c5e"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,74 @@
|
||||
[
|
||||
{
|
||||
"uuid": "9a3007baa748457b8d5162f5c5918553",
|
||||
"ArmCode": "SC10",
|
||||
"ArmName": "单道-10uL",
|
||||
"CmdCode": "SC10",
|
||||
"ChannelNum": 1,
|
||||
"Dosage": 10,
|
||||
"CreateName": "admin",
|
||||
"CreateTime": "2021-11-13 14:04:02.000",
|
||||
"UpdateName": "admin",
|
||||
"UpdateTime": "2021-11-13 14:04:12.000"
|
||||
},
|
||||
{
|
||||
"uuid": "8f57a4cc859d4c02bffbeeadcfb2b661",
|
||||
"ArmCode": "SC300",
|
||||
"ArmName": "单道-300uL",
|
||||
"CmdCode": "SC300",
|
||||
"ChannelNum": 1,
|
||||
"Dosage": 300,
|
||||
"CreateName": "admin",
|
||||
"CreateTime": "2021-11-11 11:11:11.000",
|
||||
"UpdateName": "admin",
|
||||
"UpdateTime": "2021-11-11 11:11:11.000"
|
||||
},
|
||||
{
|
||||
"uuid": "8fe0320823de49a99bfa5060ce1aaa28",
|
||||
"ArmCode": "SC1250",
|
||||
"ArmName": "单道-1250",
|
||||
"CmdCode": "SC1250",
|
||||
"ChannelNum": 1,
|
||||
"Dosage": 1250,
|
||||
"CreateName": "admin",
|
||||
"CreateTime": "2021-11-12 10:10:10.000",
|
||||
"UpdateName": "admin",
|
||||
"UpdateTime": "2021-11-12 11:11:11.000"
|
||||
},
|
||||
{
|
||||
"uuid": "88f22c5384e94dbbad60961d4d2b5e91",
|
||||
"ArmCode": "MC10",
|
||||
"ArmName": "八道-10uL",
|
||||
"CmdCode": "MC10",
|
||||
"ChannelNum": 8,
|
||||
"Dosage": 10,
|
||||
"CreateName": "admin",
|
||||
"CreateTime": "2021-11-12 10:10:10.000",
|
||||
"UpdateName": "admin",
|
||||
"UpdateTime": "2021-11-13 12:12:12.000"
|
||||
},
|
||||
{
|
||||
"uuid": "09206ff90e64466f90ce6a785a24bad8",
|
||||
"ArmCode": "MC300",
|
||||
"ArmName": "八道-300uL",
|
||||
"CmdCode": "MC300",
|
||||
"ChannelNum": 8,
|
||||
"Dosage": 300,
|
||||
"CreateName": "admin",
|
||||
"CreateTime": "2021-11-12 12:12:12.000",
|
||||
"UpdateName": "admin",
|
||||
"UpdateTime": "2021-11-12 10:10:10.000"
|
||||
},
|
||||
{
|
||||
"uuid": "5afcbd7d1d6749079d1c94f8c2e68f06",
|
||||
"ArmCode": "MC1250",
|
||||
"ArmName": "八道-1250uL",
|
||||
"CmdCode": "MC1250",
|
||||
"ChannelNum": 8,
|
||||
"Dosage": 1250,
|
||||
"CreateName": "admin",
|
||||
"CreateTime": "2021-11-12 12:12:10.000",
|
||||
"UpdateName": "admin",
|
||||
"UpdateTime": "2021-11-12 12:11:11.000"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,10 @@
|
||||
[
|
||||
{
|
||||
"uuid": "bd52d6566534441ea523265814dc06e8",
|
||||
"uuidMaterial": "01bdeb95a1314dc78b8f25667b08d531",
|
||||
"ChannelNum": 8,
|
||||
"HoleNo": 96,
|
||||
"HoleCenterXYZ": "300",
|
||||
"uuidLayoutMaster": "4f35adc958c540fcb40d6f9dd51e40fa"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,20 @@
|
||||
[
|
||||
{
|
||||
"uuid": "4f35adc958c540fcb40d6f9dd51e40fa",
|
||||
"BoardCode": 34,
|
||||
"BoardNum": 1,
|
||||
"BoardLength": 500,
|
||||
"BoardWidth": 400,
|
||||
"BoardColum": 4,
|
||||
"BoardRow": 3,
|
||||
"TotalColum": 4,
|
||||
"TotalRow": 3,
|
||||
"BoardCenterXY": "300",
|
||||
"HoleQty": 96,
|
||||
"Version": 1,
|
||||
"CreateTime": "2021-11-15",
|
||||
"CreateName": "admin",
|
||||
"UpdateTime": "2021-11-15",
|
||||
"UpdateName": "admin"
|
||||
}
|
||||
]
|
||||
180578
unilabos/devices/liquid_handling/prcxi/json_output/base_plan_detail.json
Normal file
180578
unilabos/devices/liquid_handling/prcxi/json_output/base_plan_detail.json
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,98 @@
|
||||
[
|
||||
{
|
||||
"id": "ef121889-2724-4b3d-a786-bbf0bd213c3d",
|
||||
"name": "9300_V02",
|
||||
"row": 2,
|
||||
"col": 3,
|
||||
"create_name": "",
|
||||
"create_time": "2023-08-12 16:02:20.994",
|
||||
"update_name": null,
|
||||
"update_time": null,
|
||||
"remark": "9300_V02",
|
||||
"isUse": 0
|
||||
},
|
||||
{
|
||||
"id": "9af15efc-29d2-4c44-8533-bbaf24913be6",
|
||||
"name": "9310",
|
||||
"row": 3,
|
||||
"col": 4,
|
||||
"create_name": "",
|
||||
"create_time": "2023-08-12 16:23:07.472",
|
||||
"update_name": null,
|
||||
"update_time": null,
|
||||
"remark": "9310",
|
||||
"isUse": 0
|
||||
},
|
||||
{
|
||||
"id": "6ed12532-eeae-4c16-a9ae-18f0b0cfc546",
|
||||
"name": "6版位",
|
||||
"row": 2,
|
||||
"col": 4,
|
||||
"create_name": "",
|
||||
"create_time": "2023-10-09 11:05:57.244",
|
||||
"update_name": null,
|
||||
"update_time": null,
|
||||
"remark": "6版位",
|
||||
"isUse": 0
|
||||
},
|
||||
{
|
||||
"id": "77673540-92c4-4404-b659-4257034a9c5e",
|
||||
"name": "9300_V03",
|
||||
"row": 2,
|
||||
"col": 3,
|
||||
"create_name": "",
|
||||
"create_time": "2024-01-20 08:49:09.620",
|
||||
"update_name": null,
|
||||
"update_time": null,
|
||||
"remark": "9300_V03",
|
||||
"isUse": 0
|
||||
},
|
||||
{
|
||||
"id": "c08591fe-bc7e-42a8-bfa1-a27a4967058e",
|
||||
"name": "9320",
|
||||
"row": 4,
|
||||
"col": 7,
|
||||
"create_name": "",
|
||||
"create_time": "2025-03-10 13:44:17.994",
|
||||
"update_name": null,
|
||||
"update_time": null,
|
||||
"remark": "9320",
|
||||
"isUse": 0
|
||||
},
|
||||
{
|
||||
"id": "54092457-a8b8-4457-bccd-e8c251e83ebd",
|
||||
"name": "7.17演示",
|
||||
"row": 4,
|
||||
"col": 4,
|
||||
"create_name": "",
|
||||
"create_time": "2025-07-12 17:08:38.336",
|
||||
"update_name": null,
|
||||
"update_time": null,
|
||||
"remark": "7.17演示",
|
||||
"isUse": 0
|
||||
},
|
||||
{
|
||||
"id": "e3855307-d91f-4ddc-9caf-565c0fd8adfc",
|
||||
"name": "北京大学 16版位",
|
||||
"row": 4,
|
||||
"col": 4,
|
||||
"create_name": "",
|
||||
"create_time": "2025-09-03 13:23:51.781",
|
||||
"update_name": null,
|
||||
"update_time": null,
|
||||
"remark": "北京大学 16版位",
|
||||
"isUse": 1
|
||||
},
|
||||
{
|
||||
"id": "a25563ec-8a2a-4de8-9ca2-a59c1c71427a",
|
||||
"name": "TEST",
|
||||
"row": 4,
|
||||
"col": 4,
|
||||
"create_name": "",
|
||||
"create_time": "2025-10-27 14:36:03.266",
|
||||
"update_name": null,
|
||||
"update_time": null,
|
||||
"remark": "TEST",
|
||||
"isUse": 0
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,872 @@
|
||||
[
|
||||
{
|
||||
"id": "630a9ca9-dfbf-40f9-b90b-6df73e6a1d7f",
|
||||
"number": 1,
|
||||
"name": "T1",
|
||||
"row": 0,
|
||||
"col": 0,
|
||||
"row_span": 1,
|
||||
"col_span": 1,
|
||||
"plate_position_id": "ef121889-2724-4b3d-a786-bbf0bd213c3d"
|
||||
},
|
||||
{
|
||||
"id": "db955443-1397-4a7a-a0cc-185eb6422c27",
|
||||
"number": 2,
|
||||
"name": "T2",
|
||||
"row": 0,
|
||||
"col": 1,
|
||||
"row_span": 1,
|
||||
"col_span": 1,
|
||||
"plate_position_id": "ef121889-2724-4b3d-a786-bbf0bd213c3d"
|
||||
},
|
||||
{
|
||||
"id": "635e8265-e2b9-430e-8a4e-ddf94256266f",
|
||||
"number": 3,
|
||||
"name": "T3",
|
||||
"row": 0,
|
||||
"col": 2,
|
||||
"row_span": 2,
|
||||
"col_span": 1,
|
||||
"plate_position_id": "ef121889-2724-4b3d-a786-bbf0bd213c3d"
|
||||
},
|
||||
{
|
||||
"id": "6de1521d-a249-4a7e-800f-1d49b5c7b56f",
|
||||
"number": 4,
|
||||
"name": "T4",
|
||||
"row": 1,
|
||||
"col": 0,
|
||||
"row_span": 1,
|
||||
"col_span": 1,
|
||||
"plate_position_id": "ef121889-2724-4b3d-a786-bbf0bd213c3d"
|
||||
},
|
||||
{
|
||||
"id": "4f9f2527-0f71-4ec4-a0ac-e546407e2960",
|
||||
"number": 5,
|
||||
"name": "T5",
|
||||
"row": 1,
|
||||
"col": 1,
|
||||
"row_span": 1,
|
||||
"col_span": 1,
|
||||
"plate_position_id": "ef121889-2724-4b3d-a786-bbf0bd213c3d"
|
||||
},
|
||||
{
|
||||
"id": "55ecff40-453f-4a5f-9ed3-1267b0a03cae",
|
||||
"number": 1,
|
||||
"name": "T1",
|
||||
"row": 0,
|
||||
"col": 0,
|
||||
"row_span": 1,
|
||||
"col_span": 1,
|
||||
"plate_position_id": "9af15efc-29d2-4c44-8533-bbaf24913be6"
|
||||
},
|
||||
{
|
||||
"id": "7dcd9c87-6702-4659-b28a-f6565b27f8e3",
|
||||
"number": 2,
|
||||
"name": "T2",
|
||||
"row": 0,
|
||||
"col": 1,
|
||||
"row_span": 1,
|
||||
"col_span": 1,
|
||||
"plate_position_id": "9af15efc-29d2-4c44-8533-bbaf24913be6"
|
||||
},
|
||||
{
|
||||
"id": "67e51bd6-6eee-46e4-931c-73d9e07397eb",
|
||||
"number": 3,
|
||||
"name": "T3",
|
||||
"row": 0,
|
||||
"col": 2,
|
||||
"row_span": 1,
|
||||
"col_span": 1,
|
||||
"plate_position_id": "9af15efc-29d2-4c44-8533-bbaf24913be6"
|
||||
},
|
||||
{
|
||||
"id": "e1289406-4f5e-4966-a1e6-fb29be6cd4bd",
|
||||
"number": 4,
|
||||
"name": "T4",
|
||||
"row": 0,
|
||||
"col": 3,
|
||||
"row_span": 1,
|
||||
"col_span": 1,
|
||||
"plate_position_id": "9af15efc-29d2-4c44-8533-bbaf24913be6"
|
||||
},
|
||||
{
|
||||
"id": "4ecb9ef7-cbd4-44bc-a6a9-fdbbefdc01d6",
|
||||
"number": 5,
|
||||
"name": "T5",
|
||||
"row": 1,
|
||||
"col": 0,
|
||||
"row_span": 1,
|
||||
"col_span": 1,
|
||||
"plate_position_id": "9af15efc-29d2-4c44-8533-bbaf24913be6"
|
||||
},
|
||||
{
|
||||
"id": "c7bcaeeb-7ce7-479d-8dae-e82f4023a2b6",
|
||||
"number": 6,
|
||||
"name": "T6",
|
||||
"row": 1,
|
||||
"col": 1,
|
||||
"row_span": 1,
|
||||
"col_span": 1,
|
||||
"plate_position_id": "9af15efc-29d2-4c44-8533-bbaf24913be6"
|
||||
},
|
||||
{
|
||||
"id": "e502d5ee-3197-4f60-8ac4-3bc005349dfd",
|
||||
"number": 7,
|
||||
"name": "T7",
|
||||
"row": 1,
|
||||
"col": 2,
|
||||
"row_span": 1,
|
||||
"col_span": 1,
|
||||
"plate_position_id": "9af15efc-29d2-4c44-8533-bbaf24913be6"
|
||||
},
|
||||
{
|
||||
"id": "829c78b0-9e05-448f-9531-6d19c094c83f",
|
||||
"number": 8,
|
||||
"name": "T8",
|
||||
"row": 1,
|
||||
"col": 3,
|
||||
"row_span": 1,
|
||||
"col_span": 1,
|
||||
"plate_position_id": "9af15efc-29d2-4c44-8533-bbaf24913be6"
|
||||
},
|
||||
{
|
||||
"id": "d0fd64d6-360d-4f5e-9451-21a332e247f5",
|
||||
"number": 9,
|
||||
"name": "T9",
|
||||
"row": 2,
|
||||
"col": 0,
|
||||
"row_span": 1,
|
||||
"col_span": 1,
|
||||
"plate_position_id": "9af15efc-29d2-4c44-8533-bbaf24913be6"
|
||||
},
|
||||
{
|
||||
"id": "7f3da25d-0be0-4e07-885f-fbbbfa952f9f",
|
||||
"number": 10,
|
||||
"name": "T10",
|
||||
"row": 2,
|
||||
"col": 1,
|
||||
"row_span": 1,
|
||||
"col_span": 1,
|
||||
"plate_position_id": "9af15efc-29d2-4c44-8533-bbaf24913be6"
|
||||
},
|
||||
{
|
||||
"id": "491d396d-7264-43d6-9ad4-60bffbe66c26",
|
||||
"number": 11,
|
||||
"name": "T11",
|
||||
"row": 2,
|
||||
"col": 2,
|
||||
"row_span": 1,
|
||||
"col_span": 1,
|
||||
"plate_position_id": "9af15efc-29d2-4c44-8533-bbaf24913be6"
|
||||
},
|
||||
{
|
||||
"id": "a8853b6d-639d-46f9-a4bf-9153c0c22461",
|
||||
"number": 12,
|
||||
"name": "T12",
|
||||
"row": 2,
|
||||
"col": 3,
|
||||
"row_span": 1,
|
||||
"col_span": 1,
|
||||
"plate_position_id": "9af15efc-29d2-4c44-8533-bbaf24913be6"
|
||||
},
|
||||
{
|
||||
"id": "b7beb8d0-0003-471d-bd8d-a9c0e09b07d5",
|
||||
"number": 1,
|
||||
"name": "T1",
|
||||
"row": 0,
|
||||
"col": 0,
|
||||
"row_span": 1,
|
||||
"col_span": 1,
|
||||
"plate_position_id": "6ed12532-eeae-4c16-a9ae-18f0b0cfc546"
|
||||
},
|
||||
{
|
||||
"id": "306e3f96-a6d7-484a-83ef-722e3710d5c4",
|
||||
"number": 2,
|
||||
"name": "T2",
|
||||
"row": 0,
|
||||
"col": 1,
|
||||
"row_span": 1,
|
||||
"col_span": 1,
|
||||
"plate_position_id": "6ed12532-eeae-4c16-a9ae-18f0b0cfc546"
|
||||
},
|
||||
{
|
||||
"id": "4e7bb617-ac1a-4360-b379-7ac4197089c4",
|
||||
"number": 3,
|
||||
"name": "T3",
|
||||
"row": 0,
|
||||
"col": 2,
|
||||
"row_span": 1,
|
||||
"col_span": 1,
|
||||
"plate_position_id": "6ed12532-eeae-4c16-a9ae-18f0b0cfc546"
|
||||
},
|
||||
{
|
||||
"id": "af583180-c29d-418e-9061-9e030f77cf57",
|
||||
"number": 4,
|
||||
"name": "T4",
|
||||
"row": 0,
|
||||
"col": 3,
|
||||
"row_span": 2,
|
||||
"col_span": 1,
|
||||
"plate_position_id": "6ed12532-eeae-4c16-a9ae-18f0b0cfc546"
|
||||
},
|
||||
{
|
||||
"id": "24a85ce8-e9e3-44f5-9d08-25116173ba75",
|
||||
"number": 5,
|
||||
"name": "T5",
|
||||
"row": 1,
|
||||
"col": 0,
|
||||
"row_span": 1,
|
||||
"col_span": 1,
|
||||
"plate_position_id": "6ed12532-eeae-4c16-a9ae-18f0b0cfc546"
|
||||
},
|
||||
{
|
||||
"id": "7bf61a40-f65a-4d2f-bb19-d42bfd80e2e9",
|
||||
"number": 6,
|
||||
"name": "T6",
|
||||
"row": 1,
|
||||
"col": 1,
|
||||
"row_span": 1,
|
||||
"col_span": 1,
|
||||
"plate_position_id": "6ed12532-eeae-4c16-a9ae-18f0b0cfc546"
|
||||
},
|
||||
{
|
||||
"id": "a3177806-3c02-4c4f-86d6-604a38c2ba2a",
|
||||
"number": 7,
|
||||
"name": "T7",
|
||||
"row": 1,
|
||||
"col": 2,
|
||||
"row_span": 1,
|
||||
"col_span": 1,
|
||||
"plate_position_id": "6ed12532-eeae-4c16-a9ae-18f0b0cfc546"
|
||||
},
|
||||
{
|
||||
"id": "8ccaad5a-8588-4ff3-b0d7-17e7fd5ac6cc",
|
||||
"number": 1,
|
||||
"name": "T1",
|
||||
"row": 0,
|
||||
"col": 0,
|
||||
"row_span": 1,
|
||||
"col_span": 1,
|
||||
"plate_position_id": "77673540-92c4-4404-b659-4257034a9c5e"
|
||||
},
|
||||
{
|
||||
"id": "93ae7707-b6b8-4bc4-8700-c500c3d7b165",
|
||||
"number": 2,
|
||||
"name": "T2",
|
||||
"row": 0,
|
||||
"col": 1,
|
||||
"row_span": 1,
|
||||
"col_span": 1,
|
||||
"plate_position_id": "77673540-92c4-4404-b659-4257034a9c5e"
|
||||
},
|
||||
{
|
||||
"id": "3591a07b-4922-4882-996f-7bebee843be1",
|
||||
"number": 3,
|
||||
"name": "T3",
|
||||
"row": 0,
|
||||
"col": 2,
|
||||
"row_span": 1,
|
||||
"col_span": 1,
|
||||
"plate_position_id": "77673540-92c4-4404-b659-4257034a9c5e"
|
||||
},
|
||||
{
|
||||
"id": "669fdba9-b20c-4bd2-8352-8fe5682e3e0c",
|
||||
"number": 4,
|
||||
"name": "T4",
|
||||
"row": 1,
|
||||
"col": 0,
|
||||
"row_span": 1,
|
||||
"col_span": 1,
|
||||
"plate_position_id": "77673540-92c4-4404-b659-4257034a9c5e"
|
||||
},
|
||||
{
|
||||
"id": "8bf3333e-4a73-4e4c-959a-8ae44e1038a2",
|
||||
"number": 5,
|
||||
"name": "T5",
|
||||
"row": 1,
|
||||
"col": 1,
|
||||
"row_span": 1,
|
||||
"col_span": 1,
|
||||
"plate_position_id": "77673540-92c4-4404-b659-4257034a9c5e"
|
||||
},
|
||||
{
|
||||
"id": "2837bf69-273a-4cbb-a74c-0af1b362f609",
|
||||
"number": 6,
|
||||
"name": "T6",
|
||||
"row": 1,
|
||||
"col": 2,
|
||||
"row_span": 1,
|
||||
"col_span": 1,
|
||||
"plate_position_id": "77673540-92c4-4404-b659-4257034a9c5e"
|
||||
},
|
||||
{
|
||||
"id": "e9d352fa-816a-4c01-a9e2-f52bce8771f1",
|
||||
"number": 1,
|
||||
"name": "T1",
|
||||
"row": 0,
|
||||
"col": 0,
|
||||
"row_span": 4,
|
||||
"col_span": 1,
|
||||
"plate_position_id": "c08591fe-bc7e-42a8-bfa1-a27a4967058e"
|
||||
},
|
||||
{
|
||||
"id": "713f1d85-b671-49f1-a2f9-11a64e5bb545",
|
||||
"number": 2,
|
||||
"name": "T2",
|
||||
"row": 0,
|
||||
"col": 1,
|
||||
"row_span": 4,
|
||||
"col_span": 1,
|
||||
"plate_position_id": "c08591fe-bc7e-42a8-bfa1-a27a4967058e"
|
||||
},
|
||||
{
|
||||
"id": "ba2d8fd6-e2fa-4dd3-8afc-13472ca12afb",
|
||||
"number": 3,
|
||||
"name": "T3",
|
||||
"row": 0,
|
||||
"col": 2,
|
||||
"row_span": 4,
|
||||
"col_span": 1,
|
||||
"plate_position_id": "c08591fe-bc7e-42a8-bfa1-a27a4967058e"
|
||||
},
|
||||
{
|
||||
"id": "68137a87-ae26-4e27-8953-4b1335ed957c",
|
||||
"number": 4,
|
||||
"name": "T4",
|
||||
"row": 0,
|
||||
"col": 3,
|
||||
"row_span": 1,
|
||||
"col_span": 1,
|
||||
"plate_position_id": "c08591fe-bc7e-42a8-bfa1-a27a4967058e"
|
||||
},
|
||||
{
|
||||
"id": "182b2814-9c89-4a75-8456-9a82e774f876",
|
||||
"number": 5,
|
||||
"name": "T5",
|
||||
"row": 0,
|
||||
"col": 4,
|
||||
"row_span": 1,
|
||||
"col_span": 1,
|
||||
"plate_position_id": "c08591fe-bc7e-42a8-bfa1-a27a4967058e"
|
||||
},
|
||||
{
|
||||
"id": "bc149d3c-9d54-45f0-8c33-23a5d4b70aff",
|
||||
"number": 6,
|
||||
"name": "T6",
|
||||
"row": 0,
|
||||
"col": 5,
|
||||
"row_span": 1,
|
||||
"col_span": 1,
|
||||
"plate_position_id": "c08591fe-bc7e-42a8-bfa1-a27a4967058e"
|
||||
},
|
||||
{
|
||||
"id": "7d9ce812-c39c-42fe-9b73-f35364a7b01f",
|
||||
"number": 7,
|
||||
"name": "T7",
|
||||
"row": 0,
|
||||
"col": 6,
|
||||
"row_span": 1,
|
||||
"col_span": 1,
|
||||
"plate_position_id": "c08591fe-bc7e-42a8-bfa1-a27a4967058e"
|
||||
},
|
||||
{
|
||||
"id": "4907b17d-c3f8-40a6-a8a2-e874f66195b1",
|
||||
"number": 8,
|
||||
"name": "T8",
|
||||
"row": 1,
|
||||
"col": 3,
|
||||
"row_span": 1,
|
||||
"col_span": 1,
|
||||
"plate_position_id": "c08591fe-bc7e-42a8-bfa1-a27a4967058e"
|
||||
},
|
||||
{
|
||||
"id": "f858fdb5-649f-4cb2-8e95-06a1b2d97113",
|
||||
"number": 9,
|
||||
"name": "T9",
|
||||
"row": 1,
|
||||
"col": 4,
|
||||
"row_span": 1,
|
||||
"col_span": 1,
|
||||
"plate_position_id": "c08591fe-bc7e-42a8-bfa1-a27a4967058e"
|
||||
},
|
||||
{
|
||||
"id": "cc5f91d2-494a-4991-9dda-3b82ae61556b",
|
||||
"number": 10,
|
||||
"name": "T10",
|
||||
"row": 1,
|
||||
"col": 5,
|
||||
"row_span": 1,
|
||||
"col_span": 1,
|
||||
"plate_position_id": "c08591fe-bc7e-42a8-bfa1-a27a4967058e"
|
||||
},
|
||||
{
|
||||
"id": "afed9a1f-2f48-4ca9-ae14-eb1ae4e80181",
|
||||
"number": 11,
|
||||
"name": "T11",
|
||||
"row": 1,
|
||||
"col": 6,
|
||||
"row_span": 1,
|
||||
"col_span": 1,
|
||||
"plate_position_id": "c08591fe-bc7e-42a8-bfa1-a27a4967058e"
|
||||
},
|
||||
{
|
||||
"id": "1d39cacd-7828-4318-9d4f-5bf8fc21d77d",
|
||||
"number": 12,
|
||||
"name": "T12",
|
||||
"row": 2,
|
||||
"col": 3,
|
||||
"row_span": 1,
|
||||
"col_span": 1,
|
||||
"plate_position_id": "c08591fe-bc7e-42a8-bfa1-a27a4967058e"
|
||||
},
|
||||
{
|
||||
"id": "086912ac-4f33-4214-a2c8-22acb5291bfe",
|
||||
"number": 13,
|
||||
"name": "T13",
|
||||
"row": 2,
|
||||
"col": 4,
|
||||
"row_span": 1,
|
||||
"col_span": 1,
|
||||
"plate_position_id": "c08591fe-bc7e-42a8-bfa1-a27a4967058e"
|
||||
},
|
||||
{
|
||||
"id": "89d43ea4-93f6-4cbf-aba4-564b0067295f",
|
||||
"number": 14,
|
||||
"name": "T14",
|
||||
"row": 2,
|
||||
"col": 5,
|
||||
"row_span": 1,
|
||||
"col_span": 1,
|
||||
"plate_position_id": "c08591fe-bc7e-42a8-bfa1-a27a4967058e"
|
||||
},
|
||||
{
|
||||
"id": "866b12a8-5ef6-426d-a65b-b0583a3d8f16",
|
||||
"number": 15,
|
||||
"name": "T15",
|
||||
"row": 2,
|
||||
"col": 6,
|
||||
"row_span": 1,
|
||||
"col_span": 1,
|
||||
"plate_position_id": "c08591fe-bc7e-42a8-bfa1-a27a4967058e"
|
||||
},
|
||||
{
|
||||
"id": "6c5969a9-e763-48f4-97f4-a9027e3ea7ef",
|
||||
"number": 16,
|
||||
"name": "T16",
|
||||
"row": 3,
|
||||
"col": 3,
|
||||
"row_span": 1,
|
||||
"col_span": 1,
|
||||
"plate_position_id": "c08591fe-bc7e-42a8-bfa1-a27a4967058e"
|
||||
},
|
||||
{
|
||||
"id": "af8370be-076d-455d-b0b3-dd246f76d930",
|
||||
"number": 17,
|
||||
"name": "T17",
|
||||
"row": 3,
|
||||
"col": 4,
|
||||
"row_span": 1,
|
||||
"col_span": 1,
|
||||
"plate_position_id": "c08591fe-bc7e-42a8-bfa1-a27a4967058e"
|
||||
},
|
||||
{
|
||||
"id": "abf2b8c7-79ef-4fd1-9f9b-14e7e6a128c7",
|
||||
"number": 18,
|
||||
"name": "T18",
|
||||
"row": 3,
|
||||
"col": 5,
|
||||
"row_span": 1,
|
||||
"col_span": 1,
|
||||
"plate_position_id": "c08591fe-bc7e-42a8-bfa1-a27a4967058e"
|
||||
},
|
||||
{
|
||||
"id": "ca92a1e9-eb7d-4f9a-a42c-9bae461da797",
|
||||
"number": 19,
|
||||
"name": "T19",
|
||||
"row": 3,
|
||||
"col": 6,
|
||||
"row_span": 1,
|
||||
"col_span": 1,
|
||||
"plate_position_id": "c08591fe-bc7e-42a8-bfa1-a27a4967058e"
|
||||
},
|
||||
{
|
||||
"id": "4a4df4fd-ea0b-461c-aad4-032bfda5abab",
|
||||
"number": 1,
|
||||
"name": "T1",
|
||||
"row": 0,
|
||||
"col": 0,
|
||||
"row_span": 4,
|
||||
"col_span": 1,
|
||||
"plate_position_id": "54092457-a8b8-4457-bccd-e8c251e83ebd"
|
||||
},
|
||||
{
|
||||
"id": "dba90870-4b7a-4fbd-b33f-948bbb594703",
|
||||
"number": 2,
|
||||
"name": "T2",
|
||||
"row": 0,
|
||||
"col": 1,
|
||||
"row_span": 1,
|
||||
"col_span": 1,
|
||||
"plate_position_id": "54092457-a8b8-4457-bccd-e8c251e83ebd"
|
||||
},
|
||||
{
|
||||
"id": "fddc5c2b-157f-4554-8b39-2c9e338f4d3a",
|
||||
"number": 3,
|
||||
"name": "T3",
|
||||
"row": 0,
|
||||
"col": 2,
|
||||
"row_span": 1,
|
||||
"col_span": 1,
|
||||
"plate_position_id": "54092457-a8b8-4457-bccd-e8c251e83ebd"
|
||||
},
|
||||
{
|
||||
"id": "2569a396-2cd8-4cac-8b78-a8af1313c993",
|
||||
"number": 4,
|
||||
"name": "T4",
|
||||
"row": 0,
|
||||
"col": 3,
|
||||
"row_span": 1,
|
||||
"col_span": 1,
|
||||
"plate_position_id": "54092457-a8b8-4457-bccd-e8c251e83ebd"
|
||||
},
|
||||
{
|
||||
"id": "f0f693c7-a45f-4dd3-b629-621461ca9992",
|
||||
"number": 5,
|
||||
"name": "T5",
|
||||
"row": 1,
|
||||
"col": 1,
|
||||
"row_span": 1,
|
||||
"col_span": 1,
|
||||
"plate_position_id": "54092457-a8b8-4457-bccd-e8c251e83ebd"
|
||||
},
|
||||
{
|
||||
"id": "9dcba2bf-8a48-4bc6-a9b1-88f51ffaa8af",
|
||||
"number": 6,
|
||||
"name": "T6",
|
||||
"row": 1,
|
||||
"col": 2,
|
||||
"row_span": 1,
|
||||
"col_span": 1,
|
||||
"plate_position_id": "54092457-a8b8-4457-bccd-e8c251e83ebd"
|
||||
},
|
||||
{
|
||||
"id": "08449a38-0dca-48c4-a156-6f1055cf74c4",
|
||||
"number": 7,
|
||||
"name": "T7",
|
||||
"row": 1,
|
||||
"col": 3,
|
||||
"row_span": 1,
|
||||
"col_span": 1,
|
||||
"plate_position_id": "54092457-a8b8-4457-bccd-e8c251e83ebd"
|
||||
},
|
||||
{
|
||||
"id": "6ec7343f-12b9-42ae-86d1-3894758e69b4",
|
||||
"number": 8,
|
||||
"name": "T8",
|
||||
"row": 2,
|
||||
"col": 1,
|
||||
"row_span": 1,
|
||||
"col_span": 1,
|
||||
"plate_position_id": "54092457-a8b8-4457-bccd-e8c251e83ebd"
|
||||
},
|
||||
{
|
||||
"id": "b5f02dbc-ffc6-452a-ad9f-2d1ff3db2064",
|
||||
"number": 9,
|
||||
"name": "T9",
|
||||
"row": 2,
|
||||
"col": 2,
|
||||
"row_span": 1,
|
||||
"col_span": 1,
|
||||
"plate_position_id": "54092457-a8b8-4457-bccd-e8c251e83ebd"
|
||||
},
|
||||
{
|
||||
"id": "7635380a-4f96-4894-9a54-37c2bd27f148",
|
||||
"number": 10,
|
||||
"name": "T10",
|
||||
"row": 2,
|
||||
"col": 3,
|
||||
"row_span": 1,
|
||||
"col_span": 1,
|
||||
"plate_position_id": "54092457-a8b8-4457-bccd-e8c251e83ebd"
|
||||
},
|
||||
{
|
||||
"id": "b4b6b063-5a0b-45a2-aa47-f427d4cd06f6",
|
||||
"number": 11,
|
||||
"name": "T11",
|
||||
"row": 3,
|
||||
"col": 1,
|
||||
"row_span": 1,
|
||||
"col_span": 1,
|
||||
"plate_position_id": "54092457-a8b8-4457-bccd-e8c251e83ebd"
|
||||
},
|
||||
{
|
||||
"id": "af02c689-7bca-476b-bd05-ce21d3e83f27",
|
||||
"number": 12,
|
||||
"name": "T12",
|
||||
"row": 3,
|
||||
"col": 2,
|
||||
"row_span": 1,
|
||||
"col_span": 1,
|
||||
"plate_position_id": "54092457-a8b8-4457-bccd-e8c251e83ebd"
|
||||
},
|
||||
{
|
||||
"id": "52a42e58-c0d6-420c-bc0b-575f749c7e3b",
|
||||
"number": 13,
|
||||
"name": "T13",
|
||||
"row": 3,
|
||||
"col": 3,
|
||||
"row_span": 1,
|
||||
"col_span": 1,
|
||||
"plate_position_id": "54092457-a8b8-4457-bccd-e8c251e83ebd"
|
||||
},
|
||||
{
|
||||
"id": "169c12fe-e2f4-465e-9fd3-e58eac83a502",
|
||||
"number": 1,
|
||||
"name": "T1",
|
||||
"row": 0,
|
||||
"col": 0,
|
||||
"row_span": 1,
|
||||
"col_span": 1,
|
||||
"plate_position_id": "e3855307-d91f-4ddc-9caf-565c0fd8adfc"
|
||||
},
|
||||
{
|
||||
"id": "b6072651-1df5-4946-a5b4-fbff3fa54e6a",
|
||||
"number": 2,
|
||||
"name": "T2",
|
||||
"row": 0,
|
||||
"col": 1,
|
||||
"row_span": 1,
|
||||
"col_span": 1,
|
||||
"plate_position_id": "e3855307-d91f-4ddc-9caf-565c0fd8adfc"
|
||||
},
|
||||
{
|
||||
"id": "d0b8ea7c-f06e-4d94-98a8-70ffcba73c47",
|
||||
"number": 3,
|
||||
"name": "T3",
|
||||
"row": 0,
|
||||
"col": 2,
|
||||
"row_span": 1,
|
||||
"col_span": 1,
|
||||
"plate_position_id": "e3855307-d91f-4ddc-9caf-565c0fd8adfc"
|
||||
},
|
||||
{
|
||||
"id": "a7a8eb69-63f6-494e-a441-b7aef0f7c8a4",
|
||||
"number": 4,
|
||||
"name": "T4",
|
||||
"row": 0,
|
||||
"col": 3,
|
||||
"row_span": 1,
|
||||
"col_span": 1,
|
||||
"plate_position_id": "e3855307-d91f-4ddc-9caf-565c0fd8adfc"
|
||||
},
|
||||
{
|
||||
"id": "21966669-6761-4e37-947c-12fec82173fb",
|
||||
"number": 5,
|
||||
"name": "T5",
|
||||
"row": 1,
|
||||
"col": 0,
|
||||
"row_span": 1,
|
||||
"col_span": 1,
|
||||
"plate_position_id": "e3855307-d91f-4ddc-9caf-565c0fd8adfc"
|
||||
},
|
||||
{
|
||||
"id": "2227b825-fe1d-4fa3-bcb2-6e4b3c10ea53",
|
||||
"number": 6,
|
||||
"name": "T6",
|
||||
"row": 1,
|
||||
"col": 1,
|
||||
"row_span": 1,
|
||||
"col_span": 1,
|
||||
"plate_position_id": "e3855307-d91f-4ddc-9caf-565c0fd8adfc"
|
||||
},
|
||||
{
|
||||
"id": "b799da88-c2d9-4ec4-81ec-bc0991a50fe5",
|
||||
"number": 7,
|
||||
"name": "T7",
|
||||
"row": 1,
|
||||
"col": 2,
|
||||
"row_span": 1,
|
||||
"col_span": 1,
|
||||
"plate_position_id": "e3855307-d91f-4ddc-9caf-565c0fd8adfc"
|
||||
},
|
||||
{
|
||||
"id": "adaaa00a-ff6b-4bd8-b8f1-bb100488f306",
|
||||
"number": 8,
|
||||
"name": "T8",
|
||||
"row": 1,
|
||||
"col": 3,
|
||||
"row_span": 1,
|
||||
"col_span": 1,
|
||||
"plate_position_id": "e3855307-d91f-4ddc-9caf-565c0fd8adfc"
|
||||
},
|
||||
{
|
||||
"id": "3bc98311-b548-46d3-a0e0-4f1edcf10e24",
|
||||
"number": 9,
|
||||
"name": "T9",
|
||||
"row": 2,
|
||||
"col": 0,
|
||||
"row_span": 1,
|
||||
"col_span": 1,
|
||||
"plate_position_id": "e3855307-d91f-4ddc-9caf-565c0fd8adfc"
|
||||
},
|
||||
{
|
||||
"id": "81befc70-d249-49af-93dd-2efbe88c0211",
|
||||
"number": 10,
|
||||
"name": "T10",
|
||||
"row": 2,
|
||||
"col": 1,
|
||||
"row_span": 1,
|
||||
"col_span": 1,
|
||||
"plate_position_id": "e3855307-d91f-4ddc-9caf-565c0fd8adfc"
|
||||
},
|
||||
{
|
||||
"id": "45dd5535-0293-4d27-beab-1e486657b148",
|
||||
"number": 11,
|
||||
"name": "T11",
|
||||
"row": 2,
|
||||
"col": 2,
|
||||
"row_span": 1,
|
||||
"col_span": 1,
|
||||
"plate_position_id": "e3855307-d91f-4ddc-9caf-565c0fd8adfc"
|
||||
},
|
||||
{
|
||||
"id": "12ccf33a-6fe7-44a4-8643-b0b0ac6dd181",
|
||||
"number": 12,
|
||||
"name": "T12",
|
||||
"row": 2,
|
||||
"col": 3,
|
||||
"row_span": 1,
|
||||
"col_span": 1,
|
||||
"plate_position_id": "e3855307-d91f-4ddc-9caf-565c0fd8adfc"
|
||||
},
|
||||
{
|
||||
"id": "900272dd-23fd-41a4-a366-254999a30487",
|
||||
"number": 13,
|
||||
"name": "T13",
|
||||
"row": 3,
|
||||
"col": 0,
|
||||
"row_span": 1,
|
||||
"col_span": 1,
|
||||
"plate_position_id": "e3855307-d91f-4ddc-9caf-565c0fd8adfc"
|
||||
},
|
||||
{
|
||||
"id": "c366710d-2b81-4cee-8667-2b86e77e5c34",
|
||||
"number": 14,
|
||||
"name": "T14",
|
||||
"row": 3,
|
||||
"col": 1,
|
||||
"row_span": 1,
|
||||
"col_span": 1,
|
||||
"plate_position_id": "e3855307-d91f-4ddc-9caf-565c0fd8adfc"
|
||||
},
|
||||
{
|
||||
"id": "e18a9271-bc66-4c2b-8bc1-0fb129b5cc2f",
|
||||
"number": 15,
|
||||
"name": "T15",
|
||||
"row": 3,
|
||||
"col": 2,
|
||||
"row_span": 1,
|
||||
"col_span": 1,
|
||||
"plate_position_id": "e3855307-d91f-4ddc-9caf-565c0fd8adfc"
|
||||
},
|
||||
{
|
||||
"id": "6737cba0-de84-4c1f-992d-645e7f159b0c",
|
||||
"number": 16,
|
||||
"name": "T16",
|
||||
"row": 3,
|
||||
"col": 3,
|
||||
"row_span": 1,
|
||||
"col_span": 1,
|
||||
"plate_position_id": "e3855307-d91f-4ddc-9caf-565c0fd8adfc"
|
||||
},
|
||||
{
|
||||
"id": "8ace38ab-dbc7-48a1-8226-0fe92d176e07",
|
||||
"number": 1,
|
||||
"name": "T1",
|
||||
"row": 0,
|
||||
"col": 0,
|
||||
"row_span": 1,
|
||||
"col_span": 1,
|
||||
"plate_position_id": "a25563ec-8a2a-4de8-9ca2-a59c1c71427a"
|
||||
},
|
||||
{
|
||||
"id": "033fec53-c52d-4b59-aec6-2135ae0e18b9",
|
||||
"number": 2,
|
||||
"name": "T2",
|
||||
"row": 0,
|
||||
"col": 1,
|
||||
"row_span": 1,
|
||||
"col_span": 1,
|
||||
"plate_position_id": "a25563ec-8a2a-4de8-9ca2-a59c1c71427a"
|
||||
},
|
||||
{
|
||||
"id": "fa730930-8709-4250-928f-f757fce57b60",
|
||||
"number": 3,
|
||||
"name": "T3",
|
||||
"row": 0,
|
||||
"col": 2,
|
||||
"row_span": 1,
|
||||
"col_span": 1,
|
||||
"plate_position_id": "a25563ec-8a2a-4de8-9ca2-a59c1c71427a"
|
||||
},
|
||||
{
|
||||
"id": "e279d6f1-5243-4224-8953-1033dbea25ac",
|
||||
"number": 4,
|
||||
"name": "T4",
|
||||
"row": 0,
|
||||
"col": 3,
|
||||
"row_span": 1,
|
||||
"col_span": 1,
|
||||
"plate_position_id": "a25563ec-8a2a-4de8-9ca2-a59c1c71427a"
|
||||
},
|
||||
{
|
||||
"id": "76bd9426-6324-4af2-b12f-6ec0ff8c416e",
|
||||
"number": 5,
|
||||
"name": "T5",
|
||||
"row": 1,
|
||||
"col": 0,
|
||||
"row_span": 1,
|
||||
"col_span": 1,
|
||||
"plate_position_id": "a25563ec-8a2a-4de8-9ca2-a59c1c71427a"
|
||||
},
|
||||
{
|
||||
"id": "3f4ff652-3d87-4254-a235-bafde3359dae",
|
||||
"number": 6,
|
||||
"name": "T6",
|
||||
"row": 1,
|
||||
"col": 1,
|
||||
"row_span": 1,
|
||||
"col_span": 1,
|
||||
"plate_position_id": "a25563ec-8a2a-4de8-9ca2-a59c1c71427a"
|
||||
},
|
||||
{
|
||||
"id": "a38e94af-e91e-4e7a-b49d-8668001bb356",
|
||||
"number": 7,
|
||||
"name": "T7",
|
||||
"row": 1,
|
||||
"col": 2,
|
||||
"row_span": 1,
|
||||
"col_span": 1,
|
||||
"plate_position_id": "a25563ec-8a2a-4de8-9ca2-a59c1c71427a"
|
||||
},
|
||||
{
|
||||
"id": "9e45da24-1346-4886-a303-932880a79954",
|
||||
"number": 8,
|
||||
"name": "T8",
|
||||
"row": 1,
|
||||
"col": 3,
|
||||
"row_span": 1,
|
||||
"col_span": 1,
|
||||
"plate_position_id": "a25563ec-8a2a-4de8-9ca2-a59c1c71427a"
|
||||
},
|
||||
{
|
||||
"id": "1ac46e58-86ae-42d9-b230-d476b984507a",
|
||||
"number": 9,
|
||||
"name": "T9",
|
||||
"row": 2,
|
||||
"col": 0,
|
||||
"row_span": 1,
|
||||
"col_span": 1,
|
||||
"plate_position_id": "a25563ec-8a2a-4de8-9ca2-a59c1c71427a"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1 @@
|
||||
[]
|
||||
@@ -0,0 +1 @@
|
||||
[]
|
||||
@@ -0,0 +1,58 @@
|
||||
[
|
||||
{
|
||||
"uuid": "4034fa042e7f418db42ab80b0044a8cd",
|
||||
"Code": "MDHC-001-10",
|
||||
"Key": "c28ae2cb",
|
||||
"Value": "MDHC-001-1000522001001612db9dc",
|
||||
"CreateTime": "2022-01-22 17:07:00.8651386"
|
||||
},
|
||||
{
|
||||
"uuid": "8fb6d7589fdd42df93c1e1989ff13a62",
|
||||
"Code": "MDHC-001-10",
|
||||
"Key": "52980979",
|
||||
"Value": "MDHC-001-100052200100119bb6731",
|
||||
"CreateTime": "2022-01-22 20:19:20.9444209"
|
||||
},
|
||||
{
|
||||
"uuid": "efc4c92b40a94de6b0662c64486c18d1",
|
||||
"Code": "MDHC-001-10",
|
||||
"Key": "79da8402",
|
||||
"Value": "MDHC-001-1000522001001e24ea780",
|
||||
"CreateTime": "2022-01-22 20:19:26.8107506"
|
||||
},
|
||||
{
|
||||
"uuid": "3b81b1a9eabc4449b4dcbbbde47cb17f",
|
||||
"Code": "MDHC-001-10",
|
||||
"Key": "daa51755",
|
||||
"Value": "MDHC-001-100052200100185dd22e2",
|
||||
"CreateTime": "2022-01-22 20:19:36.1581374"
|
||||
},
|
||||
{
|
||||
"uuid": "d005a70801544e42ab9d216ad68dbf50",
|
||||
"Code": "MDHC-023-0.2",
|
||||
"Key": "992bbdab",
|
||||
"Value": "MDHC-023-0.2005220010014871a385",
|
||||
"CreateTime": "2022-02-16 15:49:53.760377"
|
||||
},
|
||||
{
|
||||
"uuid": "222315afb8e04320b0fcff10e3ddb8ae",
|
||||
"Code": "MDHC-023-0.2",
|
||||
"Key": "76d23270",
|
||||
"Value": "MDHC-023-0.200522001001e61547ee",
|
||||
"CreateTime": "2022-02-16 15:50:05.1932055"
|
||||
},
|
||||
{
|
||||
"uuid": "31e2a5d4f884419aa9ba96cef98b7385",
|
||||
"Code": "MDHC-023-0.2",
|
||||
"Key": "ba2b8a46",
|
||||
"Value": "MDHC-023-0.2005220010013bfed6cf",
|
||||
"CreateTime": "2022-02-16 17:26:20.0024235"
|
||||
},
|
||||
{
|
||||
"uuid": "9ccb8e0c5ca64ef09b8aced680395335",
|
||||
"Code": "MDHC-023-0.2",
|
||||
"Key": "1d1276d0",
|
||||
"Value": "MDHC-023-0.2005220010015c039a9c",
|
||||
"CreateTime": "2022-02-16 17:26:31.8479966"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,22 @@
|
||||
[
|
||||
{
|
||||
"uuid": "f3932aeae93533f19c0519c4c14702aa",
|
||||
"RoleCode": "admin",
|
||||
"RoleName": "管理员",
|
||||
"RoleMenu": "all",
|
||||
"CreateTime": "2022-02-26 00:00:00.000",
|
||||
"CreateName": "admin",
|
||||
"UpdateTime": "2022-02-26 14:50:10.000",
|
||||
"UpdateName": "admin"
|
||||
},
|
||||
{
|
||||
"uuid": "8c822592b360345fb59690e49ac6b181",
|
||||
"RoleCode": "user",
|
||||
"RoleName": "实验员",
|
||||
"RoleMenu": "nosetting",
|
||||
"CreateTime": "2022-02-26 14:54:16.000",
|
||||
"CreateName": "admin",
|
||||
"UpdateTime": "2022-02-26 14:54:19.000",
|
||||
"UpdateName": "admin"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,54 @@
|
||||
[
|
||||
{
|
||||
"uuid": "f3932aeae93533f19c0519c4c14702dd",
|
||||
"UserName": "admin",
|
||||
"Password": "NuGlByx4NZBm7XcV9f89qA==",
|
||||
"RealName": "管理员",
|
||||
"IsEnable": 1,
|
||||
"uuidRole": "f3932aeae93533f19c0519c4c14702aa",
|
||||
"IsDel": 0,
|
||||
"CreateTime": "2022-02-26 14:51:41.000",
|
||||
"CreateName": "admin",
|
||||
"UpdateTime": "2022-02-26 14:51:49.000",
|
||||
"UpdateName": "admin"
|
||||
},
|
||||
{
|
||||
"uuid": "5c522592b366645fb55690e49ac6b166",
|
||||
"UserName": "user",
|
||||
"Password": "4QrcOUm6Wau+VuBX8g+IPg==",
|
||||
"RealName": "实验员",
|
||||
"IsEnable": 1,
|
||||
"uuidRole": "8c822592b360345fb59690e49ac6b181",
|
||||
"IsDel": 0,
|
||||
"CreateTime": "2022-02-26 14:56:57.000",
|
||||
"CreateName": "admin",
|
||||
"UpdateTime": "2022-02-26 14:58:39.000",
|
||||
"UpdateName": "admin"
|
||||
},
|
||||
{
|
||||
"uuid": "ju0514zjhi9267mz8s0buspq8b9s0bgb",
|
||||
"UserName": "Administrator",
|
||||
"Password": "3J17Il4KOR+wKPszf/0cHQ==",
|
||||
"RealName": "超级管理员",
|
||||
"IsEnable": 1,
|
||||
"uuidRole": "f3932aeae93533f19c0519c4c14702aa",
|
||||
"IsDel": 0,
|
||||
"CreateTime": "2023-08-12 00:00:00.000",
|
||||
"CreateName": "admin",
|
||||
"UpdateTime": "2023-08-12 00:00:00.000",
|
||||
"UpdateName": "admin"
|
||||
},
|
||||
{
|
||||
"uuid": "2",
|
||||
"UserName": "shortcut",
|
||||
"Password": "4QrcOUm6Wau+VuBX8g+IPg==",
|
||||
"RealName": "实验员",
|
||||
"IsEnable": 1,
|
||||
"uuidRole": "8c822592b360345fb59690e49ac6b181",
|
||||
"IsDel": 0,
|
||||
"CreateTime": null,
|
||||
"CreateName": "admin",
|
||||
"UpdateTime": "2023-10-23 00:00:00.000",
|
||||
"UpdateName": null
|
||||
}
|
||||
]
|
||||
@@ -1,5 +1,6 @@
|
||||
import asyncio
|
||||
import collections
|
||||
from collections import OrderedDict
|
||||
import contextlib
|
||||
import json
|
||||
import os
|
||||
@@ -7,6 +8,7 @@ import socket
|
||||
import time
|
||||
import uuid
|
||||
from typing import Any, List, Dict, Optional, Tuple, TypedDict, Union, Sequence, Iterator, Literal
|
||||
from pylabrobot.liquid_handling.standard import GripDirection
|
||||
|
||||
from pylabrobot.liquid_handling import (
|
||||
LiquidHandlerBackend,
|
||||
@@ -28,9 +30,9 @@ from pylabrobot.liquid_handling.standard import (
|
||||
ResourceMove,
|
||||
ResourceDrop,
|
||||
)
|
||||
from pylabrobot.resources import Tip, Deck, Plate, Well, TipRack, Resource, Container, Coordinate, TipSpot, Trash
|
||||
from pylabrobot.resources import ResourceHolder, ResourceStack, Tip, Deck, Plate, Well, TipRack, Resource, Container, Coordinate, TipSpot, Trash, PlateAdapter, TubeRack
|
||||
|
||||
from unilabos.devices.liquid_handling.liquid_handler_abstract import LiquidHandlerAbstract
|
||||
from unilabos.devices.liquid_handling.liquid_handler_abstract import LiquidHandlerAbstract, SimpleReturn
|
||||
from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode
|
||||
|
||||
|
||||
@@ -69,10 +71,8 @@ class PRCXI9300Deck(Deck):
|
||||
def __init__(self, name: str, size_x: float, size_y: float, size_z: float, **kwargs):
|
||||
super().__init__(name, size_x, size_y, size_z)
|
||||
self.slots = [None] * 6 # PRCXI 9300 有 6 个槽位
|
||||
|
||||
|
||||
class PRCXI9300Container(Plate, TipRack):
|
||||
"""PRCXI 9300 的专用 Container 类,继承自 Plate和TipRack。
|
||||
class PRCXI9300Container(Plate):
|
||||
"""PRCXI 9300 的专用 Container 类,继承自 Plate,用于槽位定位和未知模块。
|
||||
|
||||
该类定义了 PRCXI 9300 的工作台布局和槽位信息。
|
||||
"""
|
||||
@@ -100,7 +100,173 @@ class PRCXI9300Container(Plate, TipRack):
|
||||
data = super().serialize_state()
|
||||
data.update(self._unilabos_state)
|
||||
return data
|
||||
class PRCXI9300Plate(Plate):
|
||||
"""
|
||||
专用孔板类:
|
||||
1. 继承自 PLR 原生 Plate,保留所有物理特性。
|
||||
2. 增加 material_info 参数,用于在初始化时直接绑定 Unilab UUID。
|
||||
"""
|
||||
def __init__(self, name: str, size_x: float, size_y: float, size_z: float,
|
||||
category: str = "plate",
|
||||
ordered_items: collections.OrderedDict = None,
|
||||
ordering: Optional[collections.OrderedDict] = None,
|
||||
model: Optional[str] = None,
|
||||
material_info: Optional[Dict[str, Any]] = None,
|
||||
**kwargs):
|
||||
# 如果 ordered_items 不为 None,直接使用
|
||||
if ordered_items is not None:
|
||||
items = ordered_items
|
||||
elif ordering is not None:
|
||||
# 检查 ordering 中的值是否是字符串(从 JSON 反序列化时的情况)
|
||||
# 如果是字符串,说明这是位置名称,需要让 Plate 自己创建 Well 对象
|
||||
# 我们只传递位置信息(键),不传递值,使用 ordering 参数
|
||||
if ordering and isinstance(next(iter(ordering.values()), None), str):
|
||||
# ordering 的值是字符串,只使用键(位置信息)创建新的 OrderedDict
|
||||
# 传递 ordering 参数而不是 ordered_items,让 Plate 自己创建 Well 对象
|
||||
items = None
|
||||
# 使用 ordering 参数,只包含位置信息(键)
|
||||
ordering_param = collections.OrderedDict((k, None) for k in ordering.keys())
|
||||
else:
|
||||
# ordering 的值已经是对象,可以直接使用
|
||||
items = ordering
|
||||
ordering_param = None
|
||||
else:
|
||||
items = None
|
||||
ordering_param = None
|
||||
|
||||
# 根据情况传递不同的参数
|
||||
if items is not None:
|
||||
super().__init__(name, size_x, size_y, size_z,
|
||||
ordered_items=items,
|
||||
category=category,
|
||||
model=model, **kwargs)
|
||||
elif ordering_param is not None:
|
||||
# 传递 ordering 参数,让 Plate 自己创建 Well 对象
|
||||
super().__init__(name, size_x, size_y, size_z,
|
||||
ordering=ordering_param,
|
||||
category=category,
|
||||
model=model, **kwargs)
|
||||
else:
|
||||
super().__init__(name, size_x, size_y, size_z,
|
||||
category=category,
|
||||
model=model, **kwargs)
|
||||
|
||||
self._unilabos_state = {}
|
||||
if material_info:
|
||||
self._unilabos_state["Material"] = material_info
|
||||
|
||||
|
||||
def load_state(self, state: Dict[str, Any]) -> None:
|
||||
super().load_state(state)
|
||||
self._unilabos_state = state
|
||||
|
||||
|
||||
def serialize_state(self) -> Dict[str, Dict[str, Any]]:
|
||||
try:
|
||||
data = super().serialize_state()
|
||||
except AttributeError:
|
||||
data = {}
|
||||
if hasattr(self, '_unilabos_state') and self._unilabos_state:
|
||||
safe_state = {}
|
||||
for k, v in self._unilabos_state.items():
|
||||
# 如果是 Material 字典,深入检查
|
||||
if k == "Material" and isinstance(v, dict):
|
||||
safe_material = {}
|
||||
for mk, mv in v.items():
|
||||
# 只保留基本数据类型 (字符串, 数字, 布尔值, 列表, 字典)
|
||||
if isinstance(mv, (str, int, float, bool, list, dict, type(None))):
|
||||
safe_material[mk] = mv
|
||||
else:
|
||||
# 打印日志提醒(可选)
|
||||
# print(f"Warning: Removing non-serializable key {mk} from {self.name}")
|
||||
pass
|
||||
safe_state[k] = safe_material
|
||||
# 其他顶层属性也进行类型检查
|
||||
elif isinstance(v, (str, int, float, bool, list, dict, type(None))):
|
||||
safe_state[k] = v
|
||||
|
||||
data.update(safe_state)
|
||||
return data # 其他顶层属性也进行类型检查
|
||||
class PRCXI9300TipRack(TipRack):
|
||||
""" 专用吸头盒类 """
|
||||
def __init__(self, name: str, size_x: float, size_y: float, size_z: float,
|
||||
category: str = "tip_rack",
|
||||
ordered_items: collections.OrderedDict = None,
|
||||
ordering: Optional[collections.OrderedDict] = None,
|
||||
model: Optional[str] = None,
|
||||
material_info: Optional[Dict[str, Any]] = None,
|
||||
**kwargs):
|
||||
# 如果 ordered_items 不为 None,直接使用
|
||||
if ordered_items is not None:
|
||||
items = ordered_items
|
||||
elif ordering is not None:
|
||||
# 检查 ordering 中的值是否是字符串(从 JSON 反序列化时的情况)
|
||||
# 如果是字符串,说明这是位置名称,需要让 TipRack 自己创建 Tip 对象
|
||||
# 我们只传递位置信息(键),不传递值,使用 ordering 参数
|
||||
if ordering and isinstance(next(iter(ordering.values()), None), str):
|
||||
# ordering 的值是字符串,只使用键(位置信息)创建新的 OrderedDict
|
||||
# 传递 ordering 参数而不是 ordered_items,让 TipRack 自己创建 Tip 对象
|
||||
items = None
|
||||
# 使用 ordering 参数,只包含位置信息(键)
|
||||
ordering_param = collections.OrderedDict((k, None) for k in ordering.keys())
|
||||
else:
|
||||
# ordering 的值已经是对象,可以直接使用
|
||||
items = ordering
|
||||
ordering_param = None
|
||||
else:
|
||||
items = None
|
||||
ordering_param = None
|
||||
|
||||
# 根据情况传递不同的参数
|
||||
if items is not None:
|
||||
super().__init__(name, size_x, size_y, size_z,
|
||||
ordered_items=items,
|
||||
category=category,
|
||||
model=model, **kwargs)
|
||||
elif ordering_param is not None:
|
||||
# 传递 ordering 参数,让 TipRack 自己创建 Tip 对象
|
||||
super().__init__(name, size_x, size_y, size_z,
|
||||
ordering=ordering_param,
|
||||
category=category,
|
||||
model=model, **kwargs)
|
||||
else:
|
||||
super().__init__(name, size_x, size_y, size_z,
|
||||
category=category,
|
||||
model=model, **kwargs)
|
||||
self._unilabos_state = {}
|
||||
if material_info:
|
||||
self._unilabos_state["Material"] = material_info
|
||||
|
||||
def load_state(self, state: Dict[str, Any]) -> None:
|
||||
super().load_state(state)
|
||||
self._unilabos_state = state
|
||||
|
||||
def serialize_state(self) -> Dict[str, Dict[str, Any]]:
|
||||
try:
|
||||
data = super().serialize_state()
|
||||
except AttributeError:
|
||||
data = {}
|
||||
if hasattr(self, '_unilabos_state') and self._unilabos_state:
|
||||
safe_state = {}
|
||||
for k, v in self._unilabos_state.items():
|
||||
# 如果是 Material 字典,深入检查
|
||||
if k == "Material" and isinstance(v, dict):
|
||||
safe_material = {}
|
||||
for mk, mv in v.items():
|
||||
# 只保留基本数据类型 (字符串, 数字, 布尔值, 列表, 字典)
|
||||
if isinstance(mv, (str, int, float, bool, list, dict, type(None))):
|
||||
safe_material[mk] = mv
|
||||
else:
|
||||
# 打印日志提醒(可选)
|
||||
# print(f"Warning: Removing non-serializable key {mk} from {self.name}")
|
||||
pass
|
||||
safe_state[k] = safe_material
|
||||
# 其他顶层属性也进行类型检查
|
||||
elif isinstance(v, (str, int, float, bool, list, dict, type(None))):
|
||||
safe_state[k] = v
|
||||
|
||||
data.update(safe_state)
|
||||
return data
|
||||
|
||||
class PRCXI9300Trash(Trash):
|
||||
"""PRCXI 9300 的专用 Trash 类,继承自 Trash。
|
||||
@@ -108,12 +274,18 @@ class PRCXI9300Trash(Trash):
|
||||
该类定义了 PRCXI 9300 的工作台布局和槽位信息。
|
||||
"""
|
||||
|
||||
def __init__(self, name: str, size_x: float, size_y: float, size_z: float, category: str, **kwargs):
|
||||
def __init__(self, name: str, size_x: float, size_y: float, size_z: float,
|
||||
category: str = "trash",
|
||||
material_info: Optional[Dict[str, Any]] = None,
|
||||
**kwargs):
|
||||
|
||||
if name != "trash":
|
||||
name = "trash"
|
||||
print("PRCXI9300Trash name must be 'trash', using 'trash' instead.")
|
||||
super().__init__(name, size_x, size_y, size_z, category=category, **kwargs)
|
||||
print(f"Warning: PRCXI9300Trash usually expects name='trash' for backend logic, but got '{name}'.")
|
||||
super().__init__(name, size_x, size_y, size_z, **kwargs)
|
||||
self._unilabos_state = {}
|
||||
# 初始化时注入 UUID
|
||||
if material_info:
|
||||
self._unilabos_state["Material"] = material_info
|
||||
|
||||
def load_state(self, state: Dict[str, Any]) -> None:
|
||||
"""从给定的状态加载工作台信息。"""
|
||||
@@ -121,10 +293,189 @@ class PRCXI9300Trash(Trash):
|
||||
self._unilabos_state = state
|
||||
|
||||
def serialize_state(self) -> Dict[str, Dict[str, Any]]:
|
||||
try:
|
||||
data = super().serialize_state()
|
||||
data.update(self._unilabos_state)
|
||||
except AttributeError:
|
||||
data = {}
|
||||
if hasattr(self, '_unilabos_state') and self._unilabos_state:
|
||||
safe_state = {}
|
||||
for k, v in self._unilabos_state.items():
|
||||
# 如果是 Material 字典,深入检查
|
||||
if k == "Material" and isinstance(v, dict):
|
||||
safe_material = {}
|
||||
for mk, mv in v.items():
|
||||
# 只保留基本数据类型 (字符串, 数字, 布尔值, 列表, 字典)
|
||||
if isinstance(mv, (str, int, float, bool, list, dict, type(None))):
|
||||
safe_material[mk] = mv
|
||||
else:
|
||||
# 打印日志提醒(可选)
|
||||
# print(f"Warning: Removing non-serializable key {mk} from {self.name}")
|
||||
pass
|
||||
safe_state[k] = safe_material
|
||||
# 其他顶层属性也进行类型检查
|
||||
elif isinstance(v, (str, int, float, bool, list, dict, type(None))):
|
||||
safe_state[k] = v
|
||||
|
||||
data.update(safe_state)
|
||||
return data
|
||||
|
||||
class PRCXI9300TubeRack(TubeRack):
|
||||
"""
|
||||
专用管架类:用于 EP 管架、试管架等。
|
||||
继承自 PLR 的 TubeRack,并支持注入 material_info (UUID)。
|
||||
"""
|
||||
def __init__(self, name: str, size_x: float, size_y: float, size_z: float,
|
||||
category: str = "tube_rack",
|
||||
items: Optional[Dict[str, Any]] = None,
|
||||
ordered_items: Optional[OrderedDict] = None,
|
||||
ordering: Optional[OrderedDict] = None,
|
||||
model: Optional[str] = None,
|
||||
material_info: Optional[Dict[str, Any]] = None,
|
||||
**kwargs):
|
||||
|
||||
# 如果 ordered_items 不为 None,直接使用
|
||||
if ordered_items is not None:
|
||||
items_to_pass = ordered_items
|
||||
ordering_param = None
|
||||
elif ordering is not None:
|
||||
# 检查 ordering 中的值是否是字符串(从 JSON 反序列化时的情况)
|
||||
# 如果是字符串,说明这是位置名称,需要让 TubeRack 自己创建 Tube 对象
|
||||
# 我们只传递位置信息(键),不传递值,使用 ordering 参数
|
||||
if ordering and isinstance(next(iter(ordering.values()), None), str):
|
||||
# ordering 的值是字符串,只使用键(位置信息)创建新的 OrderedDict
|
||||
# 传递 ordering 参数而不是 ordered_items,让 TubeRack 自己创建 Tube 对象
|
||||
items_to_pass = None
|
||||
# 使用 ordering 参数,只包含位置信息(键)
|
||||
ordering_param = collections.OrderedDict((k, None) for k in ordering.keys())
|
||||
else:
|
||||
# ordering 的值已经是对象,可以直接使用
|
||||
items_to_pass = ordering
|
||||
ordering_param = None
|
||||
elif items is not None:
|
||||
# 兼容旧的 items 参数
|
||||
items_to_pass = items
|
||||
ordering_param = None
|
||||
else:
|
||||
items_to_pass = None
|
||||
ordering_param = None
|
||||
|
||||
# 根据情况传递不同的参数
|
||||
if items_to_pass is not None:
|
||||
super().__init__(name, size_x, size_y, size_z,
|
||||
ordered_items=items_to_pass,
|
||||
model=model,
|
||||
**kwargs)
|
||||
elif ordering_param is not None:
|
||||
# 传递 ordering 参数,让 TubeRack 自己创建 Tube 对象
|
||||
super().__init__(name, size_x, size_y, size_z,
|
||||
ordering=ordering_param,
|
||||
model=model,
|
||||
**kwargs)
|
||||
else:
|
||||
super().__init__(name, size_x, size_y, size_z,
|
||||
model=model,
|
||||
**kwargs)
|
||||
|
||||
self._unilabos_state = {}
|
||||
if material_info:
|
||||
self._unilabos_state["Material"] = material_info
|
||||
|
||||
def serialize_state(self) -> Dict[str, Dict[str, Any]]:
|
||||
try:
|
||||
data = super().serialize_state()
|
||||
except AttributeError:
|
||||
data = {}
|
||||
if hasattr(self, '_unilabos_state') and self._unilabos_state:
|
||||
safe_state = {}
|
||||
for k, v in self._unilabos_state.items():
|
||||
# 如果是 Material 字典,深入检查
|
||||
if k == "Material" and isinstance(v, dict):
|
||||
safe_material = {}
|
||||
for mk, mv in v.items():
|
||||
# 只保留基本数据类型 (字符串, 数字, 布尔值, 列表, 字典)
|
||||
if isinstance(mv, (str, int, float, bool, list, dict, type(None))):
|
||||
safe_material[mk] = mv
|
||||
else:
|
||||
# 打印日志提醒(可选)
|
||||
# print(f"Warning: Removing non-serializable key {mk} from {self.name}")
|
||||
pass
|
||||
safe_state[k] = safe_material
|
||||
# 其他顶层属性也进行类型检查
|
||||
elif isinstance(v, (str, int, float, bool, list, dict, type(None))):
|
||||
safe_state[k] = v
|
||||
|
||||
data.update(safe_state)
|
||||
return data
|
||||
|
||||
class PRCXI9300PlateAdapter(PlateAdapter):
|
||||
"""
|
||||
专用板式适配器类:用于承载 Plate 的底座(如 PCR 适配器、磁吸架等)。
|
||||
支持注入 material_info (UUID)。
|
||||
"""
|
||||
def __init__(self, name: str, size_x: float, size_y: float, size_z: float,
|
||||
category: str = "plate_adapter",
|
||||
model: Optional[str] = None,
|
||||
material_info: Optional[Dict[str, Any]] = None,
|
||||
# 参数给予默认值 (标准96孔板尺寸)
|
||||
adapter_hole_size_x: float = 127.76,
|
||||
adapter_hole_size_y: float = 85.48,
|
||||
adapter_hole_size_z: float = 10.0, # 假设凹槽深度或板子放置高度
|
||||
dx: Optional[float] = None,
|
||||
dy: Optional[float] = None,
|
||||
dz: float = 0.0, # 默认Z轴偏移
|
||||
**kwargs):
|
||||
|
||||
# 自动居中计算:如果未指定 dx/dy,则根据适配器尺寸和孔尺寸计算居中位置
|
||||
if dx is None:
|
||||
dx = (size_x - adapter_hole_size_x) / 2
|
||||
if dy is None:
|
||||
dy = (size_y - adapter_hole_size_y) / 2
|
||||
|
||||
super().__init__(
|
||||
name=name,
|
||||
size_x=size_x,
|
||||
size_y=size_y,
|
||||
size_z=size_z,
|
||||
dx=dx,
|
||||
dy=dy,
|
||||
dz=dz,
|
||||
adapter_hole_size_x=adapter_hole_size_x,
|
||||
adapter_hole_size_y=adapter_hole_size_y,
|
||||
adapter_hole_size_z=adapter_hole_size_z,
|
||||
model=model,
|
||||
**kwargs
|
||||
)
|
||||
|
||||
self._unilabos_state = {}
|
||||
if material_info:
|
||||
self._unilabos_state["Material"] = material_info
|
||||
|
||||
def serialize_state(self) -> Dict[str, Dict[str, Any]]:
|
||||
try:
|
||||
data = super().serialize_state()
|
||||
except AttributeError:
|
||||
data = {}
|
||||
if hasattr(self, '_unilabos_state') and self._unilabos_state:
|
||||
safe_state = {}
|
||||
for k, v in self._unilabos_state.items():
|
||||
# 如果是 Material 字典,深入检查
|
||||
if k == "Material" and isinstance(v, dict):
|
||||
safe_material = {}
|
||||
for mk, mv in v.items():
|
||||
# 只保留基本数据类型 (字符串, 数字, 布尔值, 列表, 字典)
|
||||
if isinstance(mv, (str, int, float, bool, list, dict, type(None))):
|
||||
safe_material[mk] = mv
|
||||
else:
|
||||
# 打印日志提醒(可选)
|
||||
# print(f"Warning: Removing non-serializable key {mk} from {self.name}")
|
||||
pass
|
||||
safe_state[k] = safe_material
|
||||
# 其他顶层属性也进行类型检查
|
||||
elif isinstance(v, (str, int, float, bool, list, dict, type(None))):
|
||||
safe_state[k] = v
|
||||
|
||||
data.update(safe_state)
|
||||
return data
|
||||
|
||||
class PRCXI9300Handler(LiquidHandlerAbstract):
|
||||
support_touch_tip = False
|
||||
@@ -154,10 +505,11 @@ class PRCXI9300Handler(LiquidHandlerAbstract):
|
||||
tablets_info = []
|
||||
count = 0
|
||||
for child in deck.children:
|
||||
if "Material" in child._unilabos_state:
|
||||
count += 1
|
||||
if child.children:
|
||||
if "Material" in child.children[0]._unilabos_state:
|
||||
number = int(child.name.replace("T", ""))
|
||||
tablets_info.append(
|
||||
WorkTablets(Number=count, Code=f"T{count}", Material=child._unilabos_state["Material"])
|
||||
WorkTablets(Number=number, Code=f"T{number}", Material=child.children[0]._unilabos_state["Material"])
|
||||
)
|
||||
if is_9320:
|
||||
print("当前设备是9320")
|
||||
@@ -177,7 +529,7 @@ class PRCXI9300Handler(LiquidHandlerAbstract):
|
||||
super().post_init(ros_node)
|
||||
self._unilabos_backend.post_init(ros_node)
|
||||
|
||||
def set_liquid(self, wells: list[Well], liquid_names: list[str], volumes: list[float]):
|
||||
def set_liquid(self, wells: list[Well], liquid_names: list[str], volumes: list[float]) -> SimpleReturn:
|
||||
return super().set_liquid(wells, liquid_names, volumes)
|
||||
|
||||
def set_group(self, group_name: str, wells: List[Well], volumes: List[float]):
|
||||
@@ -434,6 +786,36 @@ class PRCXI9300Handler(LiquidHandlerAbstract):
|
||||
async def move_to(self, well: Well, dis_to_top: float = 0, channel: int = 0):
|
||||
return await super().move_to(well, dis_to_top, channel)
|
||||
|
||||
async def shaker_action(self, time: int, module_no: int, amplitude: int, is_wait: bool):
|
||||
return await self._unilabos_backend.shaker_action(time, module_no, amplitude, is_wait)
|
||||
|
||||
async def heater_action(self, temperature: float, time: int):
|
||||
return await self._unilabos_backend.heater_action(temperature, time)
|
||||
async def move_plate(
|
||||
self,
|
||||
plate: Plate,
|
||||
to: Resource,
|
||||
intermediate_locations: Optional[List[Coordinate]] = None,
|
||||
pickup_offset: Coordinate = Coordinate.zero(),
|
||||
destination_offset: Coordinate = Coordinate.zero(),
|
||||
drop_direction: GripDirection = GripDirection.FRONT,
|
||||
pickup_direction: GripDirection = GripDirection.FRONT,
|
||||
pickup_distance_from_top: float = 13.2 - 3.33,
|
||||
**backend_kwargs,
|
||||
):
|
||||
|
||||
return await super().move_plate(
|
||||
plate,
|
||||
to,
|
||||
intermediate_locations,
|
||||
pickup_offset,
|
||||
destination_offset,
|
||||
drop_direction,
|
||||
pickup_direction,
|
||||
pickup_distance_from_top,
|
||||
target_plate_number = to,
|
||||
**backend_kwargs,
|
||||
)
|
||||
|
||||
class PRCXI9300Backend(LiquidHandlerBackend):
|
||||
"""PRCXI 9300 的后端实现,继承自 LiquidHandlerBackend。
|
||||
@@ -475,6 +857,55 @@ class PRCXI9300Backend(LiquidHandlerBackend):
|
||||
self._num_channels = channel_num
|
||||
self._execute_setup = setup
|
||||
self.debug = debug
|
||||
self.axis = "Left"
|
||||
|
||||
async def shaker_action(self, time: int, module_no: int, amplitude: int, is_wait: bool):
|
||||
step = self.api_client.shaker_action(
|
||||
time=time,
|
||||
module_no=module_no,
|
||||
amplitude=amplitude,
|
||||
is_wait=is_wait,
|
||||
)
|
||||
self.steps_todo_list.append(step)
|
||||
return step
|
||||
|
||||
|
||||
async def pick_up_resource(self, pickup: ResourcePickup, **backend_kwargs):
|
||||
|
||||
resource=pickup.resource
|
||||
offset=pickup.offset
|
||||
pickup_distance_from_top=pickup.pickup_distance_from_top
|
||||
direction=pickup.direction
|
||||
|
||||
plate_number = int(resource.parent.name.replace("T", ""))
|
||||
is_whole_plate = True
|
||||
balance_height = 0
|
||||
step = self.api_client.clamp_jaw_pick_up(plate_number, is_whole_plate, balance_height)
|
||||
|
||||
self.steps_todo_list.append(step)
|
||||
return step
|
||||
|
||||
async def drop_resource(self, drop: ResourceDrop, **backend_kwargs):
|
||||
|
||||
|
||||
plate_number = None
|
||||
target_plate_number = backend_kwargs.get("target_plate_number", None)
|
||||
if target_plate_number is not None:
|
||||
plate_number = int(target_plate_number.name.replace("T", ""))
|
||||
|
||||
|
||||
is_whole_plate = True
|
||||
balance_height = 0
|
||||
if plate_number is None:
|
||||
raise ValueError("target_plate_number is required when dropping a resource")
|
||||
step = self.api_client.clamp_jaw_drop(plate_number, is_whole_plate, balance_height)
|
||||
self.steps_todo_list.append(step)
|
||||
return step
|
||||
|
||||
|
||||
async def heater_action(self, temperature: float, time: int):
|
||||
print(f"\n\nHeater action: temperature={temperature}, time={time}\n\n")
|
||||
# return await self.api_client.heater_action(temperature, time)
|
||||
|
||||
def post_init(self, ros_node: BaseROS2DeviceNode):
|
||||
self._ros_node = ros_node
|
||||
@@ -506,7 +937,11 @@ class PRCXI9300Backend(LiquidHandlerBackend):
|
||||
print(f"PRCXI9300Backend created solution with ID: {solution_id}")
|
||||
self.api_client.load_solution(solution_id)
|
||||
print(json.dumps(self.steps_todo_list, indent=2))
|
||||
return self.api_client.start()
|
||||
if not self.api_client.start():
|
||||
return False
|
||||
if not self.api_client.wait_for_finish():
|
||||
return False
|
||||
return True
|
||||
|
||||
@classmethod
|
||||
def check_channels(cls, use_channels: List[int]) -> List[int]:
|
||||
@@ -528,7 +963,7 @@ class PRCXI9300Backend(LiquidHandlerBackend):
|
||||
# 清除错误代码
|
||||
self.api_client.clear_error_code()
|
||||
print("PRCXI9300 error code cleared.")
|
||||
|
||||
self.api_client.call("IAutomation", "Stop")
|
||||
# 执行重置
|
||||
print("Starting PRCXI9300 reset...")
|
||||
self.api_client.call("IAutomation", "Reset")
|
||||
@@ -552,12 +987,23 @@ class PRCXI9300Backend(LiquidHandlerBackend):
|
||||
|
||||
async def pick_up_tips(self, ops: List[Pickup], use_channels: List[int] = None):
|
||||
"""Pick up tips from the specified resource."""
|
||||
|
||||
# INSERT_YOUR_CODE
|
||||
# Ensure use_channels is converted to a list of ints if it's an array
|
||||
if hasattr(use_channels, 'tolist'):
|
||||
_use_channels = use_channels.tolist()
|
||||
else:
|
||||
_use_channels = list(use_channels) if use_channels is not None else None
|
||||
if _use_channels == [0]:
|
||||
axis = "Left"
|
||||
elif _use_channels == [1]:
|
||||
axis = "Right"
|
||||
else:
|
||||
raise ValueError("Invalid use channels: " + str(_use_channels))
|
||||
plate_indexes = []
|
||||
for op in ops:
|
||||
plate = op.resource.parent
|
||||
deck = plate.parent
|
||||
plate_index = deck.children.index(plate)
|
||||
deck = plate.parent.parent
|
||||
plate_index = deck.children.index(plate.parent)
|
||||
# print(f"Plate index: {plate_index}, Plate name: {plate.name}")
|
||||
# print(f"Number of children in deck: {len(deck.children)}")
|
||||
|
||||
@@ -582,6 +1028,7 @@ class PRCXI9300Backend(LiquidHandlerBackend):
|
||||
hole_row = tipspot_index % 8 + 1
|
||||
|
||||
step = self.api_client.Load(
|
||||
axis=axis,
|
||||
dosage=0,
|
||||
plate_no=PlateNo,
|
||||
is_whole_plate=False,
|
||||
@@ -596,13 +1043,23 @@ class PRCXI9300Backend(LiquidHandlerBackend):
|
||||
|
||||
async def drop_tips(self, ops: List[Drop], use_channels: List[int] = None):
|
||||
"""Pick up tips from the specified resource."""
|
||||
|
||||
if hasattr(use_channels, 'tolist'):
|
||||
_use_channels = use_channels.tolist()
|
||||
else:
|
||||
_use_channels = list(use_channels) if use_channels is not None else None
|
||||
if _use_channels == [0]:
|
||||
axis = "Left"
|
||||
elif _use_channels == [1]:
|
||||
axis = "Right"
|
||||
else:
|
||||
raise ValueError("Invalid use channels: " + str(_use_channels))
|
||||
# 检查trash #
|
||||
if ops[0].resource.name == "trash":
|
||||
|
||||
PlateNo = ops[0].resource.parent.children.index(ops[0].resource) + 1
|
||||
PlateNo = ops[0].resource.parent.parent.children.index(ops[0].resource.parent) + 1
|
||||
|
||||
step = self.api_client.UnLoad(
|
||||
axis=axis,
|
||||
dosage=0,
|
||||
plate_no=PlateNo,
|
||||
is_whole_plate=False,
|
||||
@@ -620,8 +1077,8 @@ class PRCXI9300Backend(LiquidHandlerBackend):
|
||||
plate_indexes = []
|
||||
for op in ops:
|
||||
plate = op.resource.parent
|
||||
deck = plate.parent
|
||||
plate_index = deck.children.index(plate)
|
||||
deck = plate.parent.parent
|
||||
plate_index = deck.children.index(plate.parent)
|
||||
plate_indexes.append(plate_index)
|
||||
if len(set(plate_indexes)) != 1:
|
||||
raise ValueError(
|
||||
@@ -645,6 +1102,7 @@ class PRCXI9300Backend(LiquidHandlerBackend):
|
||||
hole_row = tipspot_index % 8 + 1
|
||||
|
||||
step = self.api_client.UnLoad(
|
||||
axis=axis,
|
||||
dosage=0,
|
||||
plate_no=PlateNo,
|
||||
is_whole_plate=False,
|
||||
@@ -671,9 +1129,9 @@ class PRCXI9300Backend(LiquidHandlerBackend):
|
||||
|
||||
plate_indexes = []
|
||||
for op in targets:
|
||||
deck = op.parent.parent
|
||||
deck = op.parent.parent.parent
|
||||
plate = op.parent
|
||||
plate_index = deck.children.index(plate)
|
||||
plate_index = deck.children.index(plate.parent)
|
||||
plate_indexes.append(plate_index)
|
||||
|
||||
if len(set(plate_indexes)) != 1:
|
||||
@@ -711,12 +1169,21 @@ class PRCXI9300Backend(LiquidHandlerBackend):
|
||||
|
||||
async def aspirate(self, ops: List[SingleChannelAspiration], use_channels: List[int] = None):
|
||||
"""Aspirate liquid from the specified resources."""
|
||||
|
||||
if hasattr(use_channels, 'tolist'):
|
||||
_use_channels = use_channels.tolist()
|
||||
else:
|
||||
_use_channels = list(use_channels) if use_channels is not None else None
|
||||
if _use_channels == [0]:
|
||||
axis = "Left"
|
||||
elif _use_channels == [1]:
|
||||
axis = "Right"
|
||||
else:
|
||||
raise ValueError("Invalid use channels: " + str(_use_channels))
|
||||
plate_indexes = []
|
||||
for op in ops:
|
||||
plate = op.resource.parent
|
||||
deck = plate.parent
|
||||
plate_index = deck.children.index(plate)
|
||||
deck = plate.parent.parent
|
||||
plate_index = deck.children.index(plate.parent)
|
||||
plate_indexes.append(plate_index)
|
||||
|
||||
if len(set(plate_indexes)) != 1:
|
||||
@@ -744,6 +1211,7 @@ class PRCXI9300Backend(LiquidHandlerBackend):
|
||||
hole_row = tipspot_index % 8 + 1
|
||||
|
||||
step = self.api_client.Imbibing(
|
||||
axis=axis,
|
||||
dosage=int(volumes[0]),
|
||||
plate_no=PlateNo,
|
||||
is_whole_plate=False,
|
||||
@@ -758,12 +1226,21 @@ class PRCXI9300Backend(LiquidHandlerBackend):
|
||||
|
||||
async def dispense(self, ops: List[SingleChannelDispense], use_channels: List[int] = None):
|
||||
"""Dispense liquid into the specified resources."""
|
||||
|
||||
if hasattr(use_channels, 'tolist'):
|
||||
_use_channels = use_channels.tolist()
|
||||
else:
|
||||
_use_channels = list(use_channels) if use_channels is not None else None
|
||||
if _use_channels == [0]:
|
||||
axis = "Left"
|
||||
elif _use_channels == [1]:
|
||||
axis = "Right"
|
||||
else:
|
||||
raise ValueError("Invalid use channels: " + str(_use_channels))
|
||||
plate_indexes = []
|
||||
for op in ops:
|
||||
plate = op.resource.parent
|
||||
deck = plate.parent
|
||||
plate_index = deck.children.index(plate)
|
||||
deck = plate.parent.parent
|
||||
plate_index = deck.children.index(plate.parent)
|
||||
plate_indexes.append(plate_index)
|
||||
|
||||
if len(set(plate_indexes)) != 1:
|
||||
@@ -792,6 +1269,7 @@ class PRCXI9300Backend(LiquidHandlerBackend):
|
||||
hole_row = tipspot_index % 8 + 1
|
||||
|
||||
step = self.api_client.Tapping(
|
||||
axis=axis,
|
||||
dosage=int(volumes[0]),
|
||||
plate_no=PlateNo,
|
||||
is_whole_plate=False,
|
||||
@@ -816,14 +1294,8 @@ class PRCXI9300Backend(LiquidHandlerBackend):
|
||||
async def dispense96(self, dispense: Union[MultiHeadDispensePlate, MultiHeadDispenseContainer]):
|
||||
raise NotImplementedError("The Opentrons backend does not support the 96 head.")
|
||||
|
||||
async def pick_up_resource(self, pickup: ResourcePickup):
|
||||
raise NotImplementedError("The Opentrons backend does not support the robotic arm.")
|
||||
|
||||
async def move_picked_up_resource(self, move: ResourceMove):
|
||||
raise NotImplementedError("The Opentrons backend does not support the robotic arm.")
|
||||
|
||||
async def drop_resource(self, drop: ResourceDrop):
|
||||
raise NotImplementedError("The Opentrons backend does not support the robotic arm.")
|
||||
pass
|
||||
|
||||
def can_pick_up_tip(self, channel_idx: int, tip: Tip) -> bool:
|
||||
return True # PRCXI9300Backend does not have tip compatibility issues
|
||||
@@ -914,6 +1386,28 @@ class PRCXI9300Api:
|
||||
def start(self) -> bool:
|
||||
return self.call("IAutomation", "Start")
|
||||
|
||||
def wait_for_finish(self) -> bool:
|
||||
success = False
|
||||
start = False
|
||||
while not success:
|
||||
status = self.step_state_list()
|
||||
if len(status) == 1:
|
||||
start = True
|
||||
if status is None:
|
||||
break
|
||||
if len(status) == 0:
|
||||
break
|
||||
if status[-1]["State"] == 2 and start:
|
||||
success = True
|
||||
elif status[-1]["State"] > 2:
|
||||
break
|
||||
elif status[-1]["State"] == 0:
|
||||
start = True
|
||||
else:
|
||||
time.sleep(1)
|
||||
return success
|
||||
|
||||
|
||||
def call(self, service: str, method: str, params: Optional[list] = None) -> Any:
|
||||
payload = json.dumps(
|
||||
{"ServiceName": service, "MethodName": method, "Paramters": params or []}, separators=(",", ":")
|
||||
@@ -1000,9 +1494,10 @@ class PRCXI9300Api:
|
||||
assist_fun4: str = "",
|
||||
assist_fun5: str = "",
|
||||
liquid_method: str = "NormalDispense",
|
||||
axis: str = "Left",
|
||||
) -> Dict[str, Any]:
|
||||
return {
|
||||
"StepAxis": self.axis,
|
||||
"StepAxis": axis,
|
||||
"Function": "Load",
|
||||
"DosageNum": dosage,
|
||||
"PlateNo": plate_no,
|
||||
@@ -1038,9 +1533,10 @@ class PRCXI9300Api:
|
||||
assist_fun4: str = "",
|
||||
assist_fun5: str = "",
|
||||
liquid_method: str = "NormalDispense",
|
||||
axis: str = "Left",
|
||||
) -> Dict[str, Any]:
|
||||
return {
|
||||
"StepAxis": self.axis,
|
||||
"StepAxis": axis,
|
||||
"Function": "Imbibing",
|
||||
"DosageNum": dosage,
|
||||
"PlateNo": plate_no,
|
||||
@@ -1076,9 +1572,10 @@ class PRCXI9300Api:
|
||||
assist_fun4: str = "",
|
||||
assist_fun5: str = "",
|
||||
liquid_method: str = "NormalDispense",
|
||||
axis: str = "Left",
|
||||
) -> Dict[str, Any]:
|
||||
return {
|
||||
"StepAxis": self.axis,
|
||||
"StepAxis": axis,
|
||||
"Function": "Tapping",
|
||||
"DosageNum": dosage,
|
||||
"PlateNo": plate_no,
|
||||
@@ -1114,9 +1611,10 @@ class PRCXI9300Api:
|
||||
assist_fun4: str = "",
|
||||
assist_fun5: str = "",
|
||||
liquid_method: str = "NormalDispense",
|
||||
axis: str = "Left",
|
||||
) -> Dict[str, Any]:
|
||||
return {
|
||||
"StepAxis": self.axis,
|
||||
"StepAxis": axis,
|
||||
"Function": "Blending",
|
||||
"DosageNum": dosage,
|
||||
"PlateNo": plate_no,
|
||||
@@ -1152,9 +1650,10 @@ class PRCXI9300Api:
|
||||
assist_fun4: str = "",
|
||||
assist_fun5: str = "",
|
||||
liquid_method: str = "NormalDispense",
|
||||
axis: str = "Left",
|
||||
) -> Dict[str, Any]:
|
||||
return {
|
||||
"StepAxis": self.axis,
|
||||
"StepAxis": axis,
|
||||
"Function": "UnLoad",
|
||||
"DosageNum": dosage,
|
||||
"PlateNo": plate_no,
|
||||
@@ -1173,6 +1672,50 @@ class PRCXI9300Api:
|
||||
"LiquidDispensingMethod": liquid_method,
|
||||
}
|
||||
|
||||
def clamp_jaw_pick_up(self,
|
||||
plate_no: int,
|
||||
is_whole_plate: bool,
|
||||
balance_height: int,
|
||||
|
||||
) -> Dict[str, Any]:
|
||||
return {
|
||||
"StepAxis": "ClampingJaw",
|
||||
"Function": "DefectiveLift",
|
||||
"PlateNo": plate_no,
|
||||
"IsWholePlate": is_whole_plate,
|
||||
"HoleRow": 1,
|
||||
"HoleCol": 1,
|
||||
"BalanceHeight": balance_height,
|
||||
"PlateOrHoleNum": f"T{plate_no}"
|
||||
}
|
||||
|
||||
def clamp_jaw_drop(
|
||||
self,
|
||||
plate_no: int,
|
||||
is_whole_plate: bool,
|
||||
balance_height: int,
|
||||
|
||||
) -> Dict[str, Any]:
|
||||
return {
|
||||
"StepAxis": "ClampingJaw",
|
||||
"Function": "PutDown",
|
||||
"PlateNo": plate_no,
|
||||
"IsWholePlate": is_whole_plate,
|
||||
"HoleRow": 1,
|
||||
"HoleCol": 1,
|
||||
"BalanceHeight": balance_height,
|
||||
"PlateOrHoleNum": f"T{plate_no}"
|
||||
}
|
||||
|
||||
def shaker_action(self, time: int, module_no: int, amplitude: int, is_wait: bool):
|
||||
return {
|
||||
"StepAxis": "Left",
|
||||
"Function": "Shaking",
|
||||
"AssistFun1": time,
|
||||
"AssistFun2": module_no,
|
||||
"AssistFun3": amplitude,
|
||||
"AssistFun4": is_wait,
|
||||
}
|
||||
|
||||
class DefaultLayout:
|
||||
|
||||
@@ -1533,31 +2076,31 @@ if __name__ == "__main__":
|
||||
from pylabrobot.resources.opentrons.tip_racks import tipone_96_tiprack_200ul, opentrons_96_tiprack_10ul
|
||||
from pylabrobot.resources.opentrons.plates import corning_96_wellplate_360ul_flat, nest_96_wellplate_2ml_deep
|
||||
|
||||
def get_well_container(name: str) -> PRCXI9300Container:
|
||||
def get_well_container(name: str) -> PRCXI9300Plate:
|
||||
well_containers = corning_96_wellplate_360ul_flat(name).serialize()
|
||||
plate = PRCXI9300Container(
|
||||
name=name, size_x=50, size_y=50, size_z=10, category="plate", ordering=well_containers["ordering"]
|
||||
plate = PRCXI9300Plate(
|
||||
name=name, size_x=50, size_y=50, size_z=10, category="plate", ordered_items=well_containers["ordering"]
|
||||
)
|
||||
plate_serialized = plate.serialize()
|
||||
plate_serialized["parent_name"] = deck.name
|
||||
well_containers.update({k: v for k, v in plate_serialized.items() if k not in ["children"]})
|
||||
new_plate: PRCXI9300Container = PRCXI9300Container.deserialize(well_containers)
|
||||
new_plate: PRCXI9300Plate = PRCXI9300Plate.deserialize(well_containers)
|
||||
return new_plate
|
||||
|
||||
def get_tip_rack(name: str, child_prefix: str = "tip") -> PRCXI9300Container:
|
||||
def get_tip_rack(name: str, child_prefix: str = "tip") -> PRCXI9300TipRack:
|
||||
tip_racks = opentrons_96_tiprack_10ul(name).serialize()
|
||||
tip_rack = PRCXI9300Container(
|
||||
tip_rack = PRCXI9300TipRack(
|
||||
name=name,
|
||||
size_x=50,
|
||||
size_y=50,
|
||||
size_z=10,
|
||||
category="tip_rack",
|
||||
ordering=collections.OrderedDict({k: f"{child_prefix}_{k}" for k, v in tip_racks["ordering"].items()}),
|
||||
ordered_items=collections.OrderedDict({k: f"{child_prefix}_{k}" for k, v in tip_racks["ordering"].items()}),
|
||||
)
|
||||
tip_rack_serialized = tip_rack.serialize()
|
||||
tip_rack_serialized["parent_name"] = deck.name
|
||||
tip_racks.update({k: v for k, v in tip_rack_serialized.items() if k not in ["children"]})
|
||||
new_tip_rack: PRCXI9300Container = PRCXI9300Container.deserialize(tip_racks)
|
||||
new_tip_rack: PRCXI9300TipRack = PRCXI9300TipRack.deserialize(tip_racks)
|
||||
return new_tip_rack
|
||||
|
||||
plate1 = get_tip_rack("RackT1")
|
||||
@@ -1604,8 +2147,8 @@ if __name__ == "__main__":
|
||||
}
|
||||
}
|
||||
)
|
||||
plate7 = PRCXI9300Container(
|
||||
name="plateT7", size_x=50, size_y=50, size_z=10, category="plate", ordering=collections.OrderedDict()
|
||||
plate7 = PRCXI9300Plate(
|
||||
name="plateT7", size_x=50, size_y=50, size_z=10, category="plate", ordered_items=collections.OrderedDict()
|
||||
)
|
||||
plate7.load_state({"Material": {"uuid": "04211a2dc93547fe9bf6121eac533650"}})
|
||||
plate8 = get_tip_rack("PlateT8")
|
||||
@@ -1679,13 +2222,13 @@ if __name__ == "__main__":
|
||||
deck.assign_child_resource(plate1, location=Coordinate(0, 0, 0))
|
||||
deck.assign_child_resource(plate2, location=Coordinate(0, 0, 0))
|
||||
deck.assign_child_resource(
|
||||
PRCXI9300Container(
|
||||
PRCXI9300Plate(
|
||||
name="container_for_nothin3",
|
||||
size_x=50,
|
||||
size_y=50,
|
||||
size_z=10,
|
||||
category="plate",
|
||||
ordering=collections.OrderedDict(),
|
||||
ordered_items=collections.OrderedDict(),
|
||||
),
|
||||
location=Coordinate(0, 0, 0),
|
||||
)
|
||||
@@ -1693,48 +2236,48 @@ if __name__ == "__main__":
|
||||
deck.assign_child_resource(plate5, location=Coordinate(0, 0, 0))
|
||||
deck.assign_child_resource(plate6, location=Coordinate(0, 0, 0))
|
||||
deck.assign_child_resource(
|
||||
PRCXI9300Container(
|
||||
PRCXI9300Plate(
|
||||
name="container_for_nothing7",
|
||||
size_x=50,
|
||||
size_y=50,
|
||||
size_z=10,
|
||||
category="plate",
|
||||
ordering=collections.OrderedDict(),
|
||||
ordered_items=collections.OrderedDict(),
|
||||
),
|
||||
location=Coordinate(0, 0, 0),
|
||||
)
|
||||
deck.assign_child_resource(
|
||||
PRCXI9300Container(
|
||||
PRCXI9300Plate(
|
||||
name="container_for_nothing8",
|
||||
size_x=50,
|
||||
size_y=50,
|
||||
size_z=10,
|
||||
category="plate",
|
||||
ordering=collections.OrderedDict(),
|
||||
ordered_items=collections.OrderedDict(),
|
||||
),
|
||||
location=Coordinate(0, 0, 0),
|
||||
)
|
||||
deck.assign_child_resource(plate9, location=Coordinate(0, 0, 0))
|
||||
deck.assign_child_resource(plate10, location=Coordinate(0, 0, 0))
|
||||
deck.assign_child_resource(
|
||||
PRCXI9300Container(
|
||||
PRCXI9300Plate(
|
||||
name="container_for_nothing11",
|
||||
size_x=50,
|
||||
size_y=50,
|
||||
size_z=10,
|
||||
category="plate",
|
||||
ordering=collections.OrderedDict(),
|
||||
ordered_items=collections.OrderedDict(),
|
||||
),
|
||||
location=Coordinate(0, 0, 0),
|
||||
)
|
||||
deck.assign_child_resource(
|
||||
PRCXI9300Container(
|
||||
PRCXI9300Plate(
|
||||
name="container_for_nothing12",
|
||||
size_x=50,
|
||||
size_y=50,
|
||||
size_z=10,
|
||||
category="plate",
|
||||
ordering=collections.OrderedDict(),
|
||||
ordered_items=collections.OrderedDict(),
|
||||
),
|
||||
location=Coordinate(0, 0, 0),
|
||||
)
|
||||
|
||||
841
unilabos/devices/liquid_handling/prcxi/prcxi_labware.py
Normal file
841
unilabos/devices/liquid_handling/prcxi/prcxi_labware.py
Normal file
@@ -0,0 +1,841 @@
|
||||
from typing import Optional
|
||||
from pylabrobot.resources import Tube, Coordinate
|
||||
from pylabrobot.resources.well import Well, WellBottomType, CrossSectionType
|
||||
from pylabrobot.resources.tip import Tip, TipCreator
|
||||
from pylabrobot.resources.tip_rack import TipRack, TipSpot
|
||||
from pylabrobot.resources.utils import create_ordered_items_2d
|
||||
from pylabrobot.resources.height_volume_functions import (
|
||||
compute_height_from_volume_rectangle,
|
||||
compute_volume_from_height_rectangle,
|
||||
)
|
||||
|
||||
from .prcxi import PRCXI9300Plate, PRCXI9300TipRack, PRCXI9300Trash, PRCXI9300TubeRack, PRCXI9300PlateAdapter
|
||||
|
||||
def _make_tip_helper(volume: float, length: float, depth: float) -> Tip:
|
||||
"""
|
||||
PLR 的 Tip 类参数名为: maximal_volume, total_tip_length, fitting_depth
|
||||
"""
|
||||
return Tip(
|
||||
has_filter=False, # 默认无滤芯
|
||||
maximal_volume=volume,
|
||||
total_tip_length=length,
|
||||
fitting_depth=depth
|
||||
)
|
||||
|
||||
# =========================================================================
|
||||
# 标准品 参照 PLR 标准库的参数,但是用 PRCXI9300Plate 实例化,并注入 UUID
|
||||
# =========================================================================
|
||||
def PRCXI_BioER_96_wellplate(name: str) -> PRCXI9300Plate:
|
||||
"""
|
||||
对应 JSON Code: ZX-019-2.2 (2.2ml 深孔板)
|
||||
原型: pylabrobot.resources.bioer.BioER_96_wellplate_Vb_2200uL
|
||||
"""
|
||||
return PRCXI9300Plate(
|
||||
name=name,
|
||||
size_x=127.1,
|
||||
size_y=85.0,
|
||||
size_z=44.2,
|
||||
lid=None,
|
||||
model="PRCXI_BioER_96_wellplate",
|
||||
category="plate",
|
||||
material_info={
|
||||
"uuid": "ca877b8b114a4310b429d1de4aae96ee",
|
||||
"Code": "ZX-019-2.2",
|
||||
"Name": "2.2ml 深孔板",
|
||||
"materialEnum": 0,
|
||||
"SupplyType": 1
|
||||
},
|
||||
|
||||
ordered_items=create_ordered_items_2d(
|
||||
Well,
|
||||
size_x=8.25,
|
||||
size_y=8.25,
|
||||
size_z=39.3, # 修改过
|
||||
dx=9.5,
|
||||
dy=7.5,
|
||||
dz=6,
|
||||
material_z_thickness=0.8,
|
||||
item_dx=9.0,
|
||||
item_dy=9.0,
|
||||
num_items_x=12,
|
||||
num_items_y=8,
|
||||
cross_section_type=CrossSectionType.RECTANGLE,
|
||||
bottom_type=WellBottomType.V, # 是否需要修改?
|
||||
max_volume=2200,
|
||||
),
|
||||
)
|
||||
def PRCXI_nest_1_troughplate(name: str) -> PRCXI9300Plate:
|
||||
"""
|
||||
对应 JSON Code: ZX-58-10000 (储液槽)
|
||||
原型: pylabrobot.resources.nest.nest_1_troughplate_195000uL_Vb
|
||||
"""
|
||||
well_size_x = 127.76 - (14.38 - 9 / 2) * 2
|
||||
well_size_y = 85.48 - (11.24 - 9 / 2) * 2
|
||||
well_kwargs = {
|
||||
"size_x": well_size_x,
|
||||
"size_y": well_size_y,
|
||||
"size_z": 26.85,
|
||||
"bottom_type": WellBottomType.V,
|
||||
"compute_height_from_volume": lambda liquid_volume: compute_height_from_volume_rectangle(
|
||||
liquid_volume=liquid_volume, well_length=well_size_x, well_width=well_size_y
|
||||
),
|
||||
"compute_volume_from_height": lambda liquid_height: compute_volume_from_height_rectangle(
|
||||
liquid_height=liquid_height, well_length=well_size_x, well_width=well_size_y
|
||||
),
|
||||
"material_z_thickness": 31.4 - 26.85 - 3.55,
|
||||
}
|
||||
|
||||
return PRCXI9300Plate(
|
||||
name=name,
|
||||
size_x=127.76,
|
||||
size_y=85.48,
|
||||
size_z=31.4,
|
||||
lid=None,
|
||||
model="PRCXI_Nest_1_troughplate",
|
||||
category="plate",
|
||||
material_info={
|
||||
"uuid": "04211a2dc93547fe9bf6121eac533650",
|
||||
"Code": "ZX-58-10000",
|
||||
"Name": "储液槽",
|
||||
"materialEnum": 0,
|
||||
"SupplyType": 1
|
||||
},
|
||||
|
||||
ordered_items=create_ordered_items_2d(
|
||||
Well,
|
||||
num_items_x=1,
|
||||
num_items_y=1,
|
||||
dx=14.38 - 9 / 2,
|
||||
dy=11.24 - 9 / 2,
|
||||
dz=3.55,
|
||||
item_dx=9.0,
|
||||
item_dy=9.0,
|
||||
**well_kwargs, # 传入上面计算好的孔参数
|
||||
),
|
||||
)
|
||||
def PRCXI_BioRad_384_wellplate(name: str) -> PRCXI9300Plate:
|
||||
"""
|
||||
对应 JSON Code: q3 (384板)
|
||||
原型: pylabrobot.resources.biorad.BioRad_384_wellplate_50uL_Vb
|
||||
"""
|
||||
return PRCXI9300Plate(
|
||||
name=name,
|
||||
# 直接抄录 PLR 标准品的物理尺寸
|
||||
size_x=127.76,
|
||||
size_y=85.48,
|
||||
size_z=10.40,
|
||||
model="BioRad_384_wellplate_50uL_Vb",
|
||||
category="plate",
|
||||
# 2. 注入 Unilab 必须的 UUID 信息
|
||||
material_info={
|
||||
"uuid": "853dcfb6226f476e8b23c250217dc7da",
|
||||
"Code": "q3",
|
||||
"Name": "384板",
|
||||
"SupplyType": 1,
|
||||
},
|
||||
# 3. 定义孔的排列 (抄录标准参数)
|
||||
ordered_items=create_ordered_items_2d(
|
||||
Well,
|
||||
num_items_x=24,
|
||||
num_items_y=16,
|
||||
dx=10.58, # A1 左边缘距离板子左边缘 需要进一步测量
|
||||
dy=7.44, # P1 下边缘距离板子下边缘 需要进一步测量
|
||||
dz=1.05,
|
||||
item_dx=4.5,
|
||||
item_dy=4.5,
|
||||
size_x=3.10,
|
||||
size_y=3.10,
|
||||
size_z=9.35,
|
||||
max_volume=50,
|
||||
material_z_thickness=1,
|
||||
bottom_type=WellBottomType.V,
|
||||
cross_section_type=CrossSectionType.CIRCLE,
|
||||
)
|
||||
)
|
||||
def PRCXI_AGenBio_4_troughplate(name: str) -> PRCXI9300Plate:
|
||||
"""
|
||||
对应 JSON Code: sdfrth654 (4道储液槽)
|
||||
原型: pylabrobot.resources.agenbio.AGenBio_4_troughplate_75000uL_Vb
|
||||
"""
|
||||
INNER_WELL_WIDTH = 26.1
|
||||
INNER_WELL_LENGTH = 71.2
|
||||
well_kwargs = {
|
||||
"size_x": 26,
|
||||
"size_y": 71.2,
|
||||
"size_z": 42.55,
|
||||
"bottom_type": WellBottomType.FLAT,
|
||||
"cross_section_type": CrossSectionType.RECTANGLE,
|
||||
"compute_height_from_volume": lambda liquid_volume: compute_height_from_volume_rectangle(
|
||||
liquid_volume,
|
||||
INNER_WELL_LENGTH,
|
||||
INNER_WELL_WIDTH,
|
||||
),
|
||||
"compute_volume_from_height": lambda liquid_height: compute_volume_from_height_rectangle(
|
||||
liquid_height,
|
||||
INNER_WELL_LENGTH,
|
||||
INNER_WELL_WIDTH,
|
||||
),
|
||||
"material_z_thickness": 1,
|
||||
}
|
||||
|
||||
return PRCXI9300Plate(
|
||||
name=name,
|
||||
size_x=127.76,
|
||||
size_y=85.48,
|
||||
size_z=43.80,
|
||||
model="PRCXI_AGenBio_4_troughplate",
|
||||
category="plate",
|
||||
material_info={
|
||||
"uuid": "01953864f6f140ccaa8ddffd4f3e46f5",
|
||||
"Code": "sdfrth654",
|
||||
"Name": "4道储液槽",
|
||||
"materialEnum": 0,
|
||||
"SupplyType": 1
|
||||
},
|
||||
|
||||
ordered_items=create_ordered_items_2d(
|
||||
Well,
|
||||
num_items_x=4,
|
||||
num_items_y=1,
|
||||
dx=9.8,
|
||||
dy=7.2,
|
||||
dz=0.9,
|
||||
item_dx=INNER_WELL_WIDTH + 1, # 1 mm wall thickness
|
||||
item_dy=INNER_WELL_LENGTH,
|
||||
**well_kwargs,
|
||||
),
|
||||
)
|
||||
def PRCXI_nest_12_troughplate(name: str) -> PRCXI9300Plate:
|
||||
"""
|
||||
对应 JSON Code: 12道储液槽 (12道储液槽)
|
||||
原型: pylabrobot.resources.nest.nest_12_troughplate_15000uL_Vb
|
||||
"""
|
||||
well_size_x = 8.2
|
||||
well_size_y = 71.2
|
||||
well_kwargs = {
|
||||
"size_x": well_size_x,
|
||||
"size_y": well_size_y,
|
||||
"size_z": 26.85,
|
||||
"bottom_type": WellBottomType.V,
|
||||
"compute_height_from_volume": lambda liquid_volume: compute_height_from_volume_rectangle(
|
||||
liquid_volume=liquid_volume, well_length=well_size_x, well_width=well_size_y
|
||||
),
|
||||
"compute_volume_from_height": lambda liquid_height: compute_volume_from_height_rectangle(
|
||||
liquid_height=liquid_height, well_length=well_size_x, well_width=well_size_y
|
||||
),
|
||||
"material_z_thickness": 31.4 - 26.85 - 3.55,
|
||||
}
|
||||
|
||||
return PRCXI9300Plate(
|
||||
name=name,
|
||||
size_x=127.76,
|
||||
size_y=85.48,
|
||||
size_z=31.4,
|
||||
lid=None,
|
||||
model="PRCXI_nest_12_troughplate",
|
||||
category="plate",
|
||||
material_info={
|
||||
"uuid": "0f1639987b154e1fac78f4fb29a1f7c1",
|
||||
"Code": "12道储液槽",
|
||||
"Name": "12道储液槽",
|
||||
"materialEnum": 0,
|
||||
"SupplyType": 1
|
||||
},
|
||||
ordered_items=create_ordered_items_2d(
|
||||
Well,
|
||||
num_items_x=12,
|
||||
num_items_y=1,
|
||||
dx=14.38 - 8.2 / 2,
|
||||
dy=(85.48 - 71.2) / 2,
|
||||
dz=3.55,
|
||||
item_dx=9.0,
|
||||
item_dy=9.0,
|
||||
**well_kwargs,
|
||||
),
|
||||
)
|
||||
def PRCXI_CellTreat_96_wellplate(name: str) -> PRCXI9300Plate:
|
||||
"""
|
||||
对应 JSON Code: ZX-78-096 (细菌培养皿)
|
||||
原型: pylabrobot.resources.celltreat.CellTreat_96_wellplate_350ul_Fb
|
||||
"""
|
||||
well_kwargs = {
|
||||
"size_x": 6.96,
|
||||
"size_y": 6.96,
|
||||
"size_z": 10.04,
|
||||
"bottom_type": WellBottomType.FLAT,
|
||||
"material_z_thickness": 1.75,
|
||||
"cross_section_type": CrossSectionType.CIRCLE,
|
||||
"max_volume": 300,
|
||||
}
|
||||
|
||||
return PRCXI9300Plate(
|
||||
name=name,
|
||||
size_x=127.61,
|
||||
size_y=85.24,
|
||||
size_z=14.30,
|
||||
lid=None,
|
||||
model="PRCXI_CellTreat_96_wellplate",
|
||||
category="plate",
|
||||
material_info={
|
||||
"uuid": "b05b3b2aafd94ec38ea0cd3215ecea8f",
|
||||
"Code": "ZX-78-096",
|
||||
"Name": "细菌培养皿",
|
||||
"materialEnum": 4,
|
||||
"SupplyType": 1
|
||||
},
|
||||
ordered_items=create_ordered_items_2d(
|
||||
Well,
|
||||
num_items_x=12,
|
||||
num_items_y=8,
|
||||
dx=10.83,
|
||||
dy=7.67,
|
||||
dz=4.05,
|
||||
item_dx=9,
|
||||
item_dy=9,
|
||||
**well_kwargs,
|
||||
),
|
||||
)
|
||||
# =========================================================================
|
||||
# 自定义/需测量品 (Custom Measurement)
|
||||
# =========================================================================
|
||||
def PRCXI_10ul_eTips(name: str) -> PRCXI9300TipRack:
|
||||
"""
|
||||
对应 JSON Code: ZX-001-10+
|
||||
"""
|
||||
return PRCXI9300TipRack(
|
||||
name=name,
|
||||
size_x=122.11,
|
||||
size_y=85.48, #修改
|
||||
size_z=58.23,
|
||||
model="PRCXI_10ul_eTips",
|
||||
material_info={
|
||||
"uuid": "068b3815e36b4a72a59bae017011b29f",
|
||||
"Code": "ZX-001-10+",
|
||||
"Name": "10μL加长 Tip头",
|
||||
"SupplyType": 1
|
||||
},
|
||||
ordered_items=create_ordered_items_2d(
|
||||
TipSpot,
|
||||
num_items_x=12,
|
||||
num_items_y=8,
|
||||
dx=7.97, #需要修改
|
||||
dy=5.0, #需修改
|
||||
dz=2.0, #需修改
|
||||
item_dx=9.0,
|
||||
item_dy=9.0,
|
||||
size_x=7.0,
|
||||
size_y=7.0,
|
||||
size_z=0,
|
||||
make_tip=lambda: _make_tip_helper(volume=10, length=52.0, depth=45.1)
|
||||
)
|
||||
)
|
||||
def PRCXI_300ul_Tips(name: str) -> PRCXI9300TipRack:
|
||||
"""
|
||||
对应 JSON Code: ZX-001-300
|
||||
吸头盒通常比较特殊,需要定义 Tip 对象
|
||||
"""
|
||||
return PRCXI9300TipRack(
|
||||
name=name,
|
||||
size_x=122.11,
|
||||
size_y=85.48, #修改
|
||||
size_z=58.23,
|
||||
model="PRCXI_300ul_Tips",
|
||||
material_info={
|
||||
"uuid": "076250742950465b9d6ea29a225dfb00",
|
||||
"Code": "ZX-001-300",
|
||||
"Name": "300μL Tip头",
|
||||
"SupplyType": 1
|
||||
},
|
||||
ordered_items=create_ordered_items_2d(
|
||||
TipSpot,
|
||||
num_items_x=12,
|
||||
num_items_y=8,
|
||||
dx=7.97, #需要修改
|
||||
dy=5.0, #需修改
|
||||
dz=2.0, #需修改
|
||||
item_dx=9.0,
|
||||
item_dy=9.0,
|
||||
size_x=7.0,
|
||||
size_y=7.0,
|
||||
size_z=0,
|
||||
make_tip=lambda: _make_tip_helper(volume=300, length=60.0, depth=51.0)
|
||||
)
|
||||
)
|
||||
def PRCXI_PCR_Plate_200uL_nonskirted(name: str) -> PRCXI9300Plate:
|
||||
"""
|
||||
对应 JSON Code: ZX-023-0.2 (0.2ml PCR 板)
|
||||
"""
|
||||
return PRCXI9300Plate(
|
||||
name=name,
|
||||
size_x=119.5,
|
||||
size_y=80.0,
|
||||
size_z=26.0,
|
||||
model="PRCXI_PCR_Plate_200uL_nonskirted",
|
||||
plate_type="non-skirted",
|
||||
category="plate",
|
||||
material_info={
|
||||
"uuid": "73bb9b10bc394978b70e027bf45ce2d3",
|
||||
"Code": "ZX-023-0.2",
|
||||
"Name": "0.2ml PCR 板",
|
||||
"materialEnum": 0,
|
||||
"SupplyType": 1
|
||||
},
|
||||
ordered_items=create_ordered_items_2d(
|
||||
Well,
|
||||
num_items_x=12,
|
||||
num_items_y=8,
|
||||
dx=7,
|
||||
dy=5,
|
||||
dz=0.0,
|
||||
item_dx=9,
|
||||
item_dy=9,
|
||||
size_x=6,
|
||||
size_y=6,
|
||||
size_z=15.17,
|
||||
bottom_type=WellBottomType.V,
|
||||
cross_section_type=CrossSectionType.CIRCLE,
|
||||
),
|
||||
)
|
||||
def PRCXI_PCR_Plate_200uL_semiskirted(name: str) -> PRCXI9300Plate:
|
||||
"""
|
||||
对应 JSON Code: ZX-023-0.2 (0.2ml PCR 板)
|
||||
"""
|
||||
return PRCXI9300Plate(
|
||||
name=name,
|
||||
size_x=126,
|
||||
size_y=86,
|
||||
size_z=21.2,
|
||||
model="PRCXI_PCR_Plate_200uL_semiskirted",
|
||||
plate_type="semi-skirted",
|
||||
category="plate",
|
||||
material_info={
|
||||
"uuid": "73bb9b10bc394978b70e027bf45ce2d3",
|
||||
"Code": "ZX-023-0.2",
|
||||
"Name": "0.2ml PCR 板",
|
||||
"materialEnum": 0,
|
||||
"SupplyType": 1
|
||||
},
|
||||
ordered_items=create_ordered_items_2d(
|
||||
Well,
|
||||
num_items_x=12,
|
||||
num_items_y=8,
|
||||
dx=11,
|
||||
dy=8,
|
||||
dz=0.0,
|
||||
item_dx=9,
|
||||
item_dy=9,
|
||||
size_x=6,
|
||||
size_y=6,
|
||||
size_z=15.17,
|
||||
bottom_type=WellBottomType.V,
|
||||
cross_section_type=CrossSectionType.CIRCLE,
|
||||
),
|
||||
)
|
||||
def PRCXI_PCR_Plate_200uL_skirted(name: str) -> PRCXI9300Plate:
|
||||
"""
|
||||
对应 JSON Code: ZX-023-0.2 (0.2ml PCR 板)
|
||||
"""
|
||||
return PRCXI9300Plate(
|
||||
name=name,
|
||||
size_x=127.76,
|
||||
size_y=86,
|
||||
size_z=16.1,
|
||||
model="PRCXI_PCR_Plate_200uL_skirted",
|
||||
plate_type="skirted",
|
||||
category="plate",
|
||||
material_info={
|
||||
"uuid": "73bb9b10bc394978b70e027bf45ce2d3",
|
||||
"Code": "ZX-023-0.2",
|
||||
"Name": "0.2ml PCR 板",
|
||||
"materialEnum": 0,
|
||||
"SupplyType": 1
|
||||
},
|
||||
ordered_items=create_ordered_items_2d(
|
||||
Well,
|
||||
num_items_x=12,
|
||||
num_items_y=8,
|
||||
dx=11,
|
||||
dy=8.49,
|
||||
dz=0.8,
|
||||
item_dx=9,
|
||||
item_dy=9,
|
||||
size_x=6,
|
||||
size_y=6,
|
||||
size_z=15.1,
|
||||
bottom_type=WellBottomType.V,
|
||||
cross_section_type=CrossSectionType.CIRCLE,
|
||||
),
|
||||
)
|
||||
def PRCXI_trash(name: str = "trash") -> PRCXI9300Trash:
|
||||
"""
|
||||
对应 JSON Code: q1 (废弃槽)
|
||||
"""
|
||||
return PRCXI9300Trash(
|
||||
name="trash",
|
||||
size_x=126.59,
|
||||
size_y=84.87,
|
||||
size_z=89.5, # 修改
|
||||
category="trash",
|
||||
model="PRCXI_trash",
|
||||
material_info={
|
||||
"uuid": "730067cf07ae43849ddf4034299030e9",
|
||||
"Code": "q1",
|
||||
"Name": "废弃槽",
|
||||
"materialEnum": 0,
|
||||
"SupplyType": 1
|
||||
}
|
||||
)
|
||||
def PRCXI_96_DeepWell(name: str) -> PRCXI9300Plate:
|
||||
"""
|
||||
对应 JSON Code: q2 (96深孔板)
|
||||
"""
|
||||
return PRCXI9300Plate(
|
||||
name=name,
|
||||
size_x=127.3,
|
||||
size_y=85.35,
|
||||
size_z=45.0, #修改
|
||||
model="PRCXI_96_DeepWell",
|
||||
material_info={
|
||||
"uuid": "57b1e4711e9e4a32b529f3132fc5931f", # 对应 q2 uuid
|
||||
"Code": "q2",
|
||||
"Name": "96深孔板",
|
||||
"materialEnum": 0
|
||||
},
|
||||
ordered_items=create_ordered_items_2d(
|
||||
Well,
|
||||
num_items_x=12,
|
||||
num_items_y=8,
|
||||
dx=10.9,
|
||||
dy=8.25,
|
||||
dz=2.0,
|
||||
item_dx=9.0,
|
||||
item_dy=9.0,
|
||||
size_x=8.2,
|
||||
size_y=8.2,
|
||||
size_z=42.0,
|
||||
max_volume=2200
|
||||
)
|
||||
)
|
||||
def PRCXI_EP_Adapter(name: str) -> PRCXI9300TubeRack:
|
||||
"""
|
||||
对应 JSON Code: 1 (ep适配器)
|
||||
这是一个 4x6 的 EP 管架,适配 1.5mL/2.0mL 离心管
|
||||
"""
|
||||
ep_tube_prototype = Tube(
|
||||
name="EP_Tube_1.5mL",
|
||||
size_x=10.6,
|
||||
size_y=10.6,
|
||||
size_z=40.0, # 管子本身的高度,通常比架子孔略高或持平
|
||||
max_volume=1500,
|
||||
model="EP_Tube_1.5mL"
|
||||
)
|
||||
|
||||
# 计算 PRCXI9300TubeRack 中孔的起始位置 dx, dy
|
||||
dy_calc = 85.8 - 10.5 - (3 * 18) - 10.6
|
||||
dx_calc = 3.54
|
||||
return PRCXI9300TubeRack(
|
||||
name=name,
|
||||
size_x=128.04,
|
||||
size_y=85.8,
|
||||
size_z=42.66,
|
||||
model="PRCXI_EP_Adapter",
|
||||
category="tube_rack",
|
||||
material_info={
|
||||
"uuid": "e146697c395e4eabb3d6b74f0dd6aaf7",
|
||||
"Code": "1",
|
||||
"Name": "ep适配器",
|
||||
"materialEnum": 0,
|
||||
"SupplyType": 1
|
||||
},
|
||||
ordered_items=create_ordered_items_2d(
|
||||
Tube,
|
||||
num_items_x=6,
|
||||
num_items_y=4,
|
||||
dx=dx_calc,
|
||||
dy=dy_calc,
|
||||
dz=42.66 - 38.08, # 架高 - 孔深
|
||||
item_dx=21.0,
|
||||
item_dy=18.0,
|
||||
size_x=10.6,
|
||||
size_y=10.6,
|
||||
size_z=40.0,
|
||||
max_volume=1500
|
||||
)
|
||||
)
|
||||
# =========================================================================
|
||||
# 无实物,需要测量
|
||||
# =========================================================================
|
||||
def PRCXI_Tip1250_Adapter(name: str) -> PRCXI9300PlateAdapter:
|
||||
""" Code: ZX-58-1250 """
|
||||
return PRCXI9300PlateAdapter(
|
||||
name=name,
|
||||
size_x=128,
|
||||
size_y=85,
|
||||
size_z=20,
|
||||
material_info={
|
||||
"uuid": "3b6f33ffbf734014bcc20e3c63e124d4",
|
||||
"Code": "ZX-58-1250",
|
||||
"Name": "Tip头适配器 1250uL",
|
||||
"SupplyType": 2
|
||||
}
|
||||
)
|
||||
def PRCXI_Tip300_Adapter(name: str) -> PRCXI9300PlateAdapter:
|
||||
""" Code: ZX-58-300 """
|
||||
return PRCXI9300PlateAdapter(
|
||||
name=name,
|
||||
size_x=127,
|
||||
size_y=85,
|
||||
size_z=81,
|
||||
material_info={
|
||||
"uuid": "7c822592b360451fb59690e49ac6b181",
|
||||
"Code": "ZX-58-300",
|
||||
"Name": "ZHONGXI 适配器 300uL",
|
||||
"SupplyType": 2
|
||||
}
|
||||
)
|
||||
def PRCXI_Tip10_Adapter(name: str) -> PRCXI9300PlateAdapter:
|
||||
""" Code: ZX-58-10 """
|
||||
return PRCXI9300PlateAdapter(
|
||||
name=name,
|
||||
size_x=128,
|
||||
size_y=85,
|
||||
size_z=72.3,
|
||||
material_info={
|
||||
"uuid": "8cc3dce884ac41c09f4570d0bcbfb01c",
|
||||
"Code": "ZX-58-10",
|
||||
"Name": "吸头10ul 适配器",
|
||||
"SupplyType": 2
|
||||
}
|
||||
)
|
||||
def PRCXI_1250uL_Tips(name: str) -> PRCXI9300TipRack:
|
||||
""" Code: ZX-001-1250 """
|
||||
return PRCXI9300TipRack(
|
||||
name=name,
|
||||
size_x=118.09,
|
||||
size_y=80.7,
|
||||
size_z=107.67,
|
||||
model="PRCXI_1250uL_Tips",
|
||||
material_info={
|
||||
"uuid": "7960f49ddfe9448abadda89bd1556936",
|
||||
"Code": "ZX-001-1250",
|
||||
"Name": "1250μL Tip头",
|
||||
"SupplyType": 1
|
||||
},
|
||||
ordered_items=create_ordered_items_2d(
|
||||
TipSpot,
|
||||
num_items_x=12,
|
||||
num_items_y=8,
|
||||
dx=9.545 - 7.95/2,
|
||||
dy=8.85 - 7.95/2,
|
||||
dz=2.0,
|
||||
item_dx=9,
|
||||
item_dy=9,
|
||||
size_x=7.0,
|
||||
size_y=7.0,
|
||||
size_z=0,
|
||||
make_tip=lambda: _make_tip_helper(volume=1250, length=107.67, depth=8)
|
||||
)
|
||||
)
|
||||
def PRCXI_10uL_Tips(name: str) -> PRCXI9300TipRack:
|
||||
""" Code: ZX-001-10 """
|
||||
return PRCXI9300TipRack(
|
||||
name=name,
|
||||
size_x=120.98,
|
||||
size_y=82.12,
|
||||
size_z=67,
|
||||
model="PRCXI_10uL_Tips",
|
||||
material_info={
|
||||
"uuid": "45f2ed3ad925484d96463d675a0ebf66",
|
||||
"Code": "ZX-001-10",
|
||||
"Name": "10μL Tip头",
|
||||
"SupplyType": 1
|
||||
},
|
||||
ordered_items=create_ordered_items_2d(
|
||||
TipSpot,
|
||||
num_items_x=12,
|
||||
num_items_y=8,
|
||||
dx=10.99 - 5/2,
|
||||
dy=9.56 - 5/2,
|
||||
dz=2.0,
|
||||
item_dx=9,
|
||||
item_dy=9,
|
||||
size_x=7.0,
|
||||
size_y=7.0,
|
||||
size_z=0,
|
||||
make_tip=lambda: _make_tip_helper(volume=1250, length=52.0, depth=5)
|
||||
)
|
||||
)
|
||||
def PRCXI_1000uL_Tips(name: str) -> PRCXI9300TipRack:
|
||||
""" Code: ZX-001-1000 """
|
||||
return PRCXI9300TipRack(
|
||||
name=name,
|
||||
size_x=128.09,
|
||||
size_y=85.8,
|
||||
size_z=98,
|
||||
model="PRCXI_1000uL_Tips",
|
||||
material_info={
|
||||
"uuid": "80652665f6a54402b2408d50b40398df",
|
||||
"Code": "ZX-001-1000",
|
||||
"Name": "1000μL Tip头",
|
||||
"SupplyType": 1
|
||||
},
|
||||
ordered_items=create_ordered_items_2d(
|
||||
TipSpot,
|
||||
num_items_x=12,
|
||||
num_items_y=8,
|
||||
dx=14.5 - 7.95/2,
|
||||
dy=7.425,
|
||||
dz=2.0,
|
||||
item_dx=9,
|
||||
item_dy=9,
|
||||
size_x=7.0,
|
||||
size_y=7.0,
|
||||
size_z=0,
|
||||
make_tip=lambda: _make_tip_helper(volume=1000, length=55.0, depth=8)
|
||||
)
|
||||
)
|
||||
def PRCXI_200uL_Tips(name: str) -> PRCXI9300TipRack:
|
||||
""" Code: ZX-001-200 """
|
||||
return PRCXI9300TipRack(
|
||||
name=name,
|
||||
size_x=120.98,
|
||||
size_y=82.12,
|
||||
size_z=66.9,
|
||||
model="PRCXI_200uL_Tips",
|
||||
material_info={
|
||||
"uuid": "7a73bb9e5c264515a8fcbe88aed0e6f7",
|
||||
"Code": "ZX-001-200",
|
||||
"Name": "200μL Tip头",
|
||||
"SupplyType": 1},
|
||||
ordered_items=create_ordered_items_2d(
|
||||
TipSpot,
|
||||
num_items_x=12,
|
||||
num_items_y=8,
|
||||
dx=10.99 - 5.5/2,
|
||||
dy=9.56 - 5.5/2,
|
||||
dz=2.0,
|
||||
item_dx=9,
|
||||
item_dy=9,
|
||||
size_x=7.0,
|
||||
size_z=0,
|
||||
size_y=7.0,
|
||||
make_tip=lambda: _make_tip_helper(volume=200, length=52.0, depth=5)
|
||||
)
|
||||
)
|
||||
def PRCXI_PCR_Adapter(name: str) -> PRCXI9300PlateAdapter:
|
||||
"""
|
||||
对应 JSON Code: ZX-58-0001 (全裙边 PCR适配器)
|
||||
"""
|
||||
return PRCXI9300PlateAdapter(
|
||||
name=name,
|
||||
size_x=127.76,
|
||||
size_y=85.48,
|
||||
size_z=21.69,
|
||||
model="PRCXI_PCR_Adapter",
|
||||
material_info={
|
||||
"uuid": "4a043a07c65a4f9bb97745e1f129b165",
|
||||
"Code": "ZX-58-0001",
|
||||
"Name": "全裙边 PCR适配器",
|
||||
"materialEnum": 3,
|
||||
"SupplyType": 2
|
||||
}
|
||||
)
|
||||
def PRCXI_Reservoir_Adapter(name: str) -> PRCXI9300PlateAdapter:
|
||||
""" Code: ZX-ADP-001 """
|
||||
return PRCXI9300PlateAdapter(
|
||||
name=name,
|
||||
size_x=133,
|
||||
size_y=91.8,
|
||||
size_z=70,
|
||||
material_info={
|
||||
"uuid": "6bdfdd7069df453896b0806df50f2f4d",
|
||||
"Code": "ZX-ADP-001",
|
||||
"Name": "储液槽 适配器",
|
||||
"SupplyType": 2
|
||||
}
|
||||
)
|
||||
def PRCXI_Deep300_Adapter(name: str) -> PRCXI9300PlateAdapter:
|
||||
""" Code: ZX-002-300 """
|
||||
return PRCXI9300PlateAdapter(
|
||||
name=name,
|
||||
size_x=136.4,
|
||||
size_y=93.8,
|
||||
size_z=96,
|
||||
material_info={
|
||||
"uuid": "9a439bed8f3344549643d6b3bc5a5eb4",
|
||||
"Code": "ZX-002-300",
|
||||
"Name": "300ul深孔板适配器",
|
||||
"SupplyType": 2
|
||||
}
|
||||
)
|
||||
def PRCXI_Deep10_Adapter(name: str) -> PRCXI9300PlateAdapter:
|
||||
""" Code: ZX-002-10 """
|
||||
return PRCXI9300PlateAdapter(
|
||||
name=name,
|
||||
size_x=136.5,
|
||||
size_y=93.8,
|
||||
size_z=121.5,
|
||||
material_info={
|
||||
"uuid": "4dc8d6ecfd0449549683b8ef815a861b",
|
||||
"Code": "ZX-002-10",
|
||||
"Name": "10ul专用深孔板适配器",
|
||||
"SupplyType": 2
|
||||
}
|
||||
)
|
||||
def PRCXI_Adapter(name: str) -> PRCXI9300PlateAdapter:
|
||||
""" Code: Fhh478 """
|
||||
return PRCXI9300PlateAdapter(
|
||||
name=name,
|
||||
size_x=120,
|
||||
size_y=90,
|
||||
size_z=86,
|
||||
material_info={
|
||||
"uuid": "adfabfffa8f24af5abfbba67b8d0f973",
|
||||
"Code": "Fhh478",
|
||||
"Name": "适配器",
|
||||
"SupplyType": 2
|
||||
}
|
||||
)
|
||||
def PRCXI_48_DeepWell(name: str) -> PRCXI9300Plate:
|
||||
""" Code: 22 (48孔深孔板) """
|
||||
print("Warning: Code '22' (48孔深孔板) dimensions are null in JSON.")
|
||||
return PRCXI9300Plate(
|
||||
name=name,
|
||||
size_x=127,
|
||||
size_y=85,
|
||||
size_z=44,
|
||||
model="PRCXI_48_DeepWell",
|
||||
material_info={
|
||||
"uuid": "026c5d5cf3d94e56b4e16b7fb53a995b",
|
||||
"Code": "22",
|
||||
"Name": "48孔深孔板",
|
||||
"SupplyType": 1
|
||||
},
|
||||
ordered_items=create_ordered_items_2d(
|
||||
Well,
|
||||
num_items_x=6,
|
||||
num_items_y=8,
|
||||
dx=10,
|
||||
dy=10,
|
||||
dz=1,
|
||||
item_dx=18.5,
|
||||
item_dy=9,
|
||||
size_x=8,
|
||||
size_y=8,
|
||||
size_z=40
|
||||
)
|
||||
)
|
||||
def PRCXI_30mm_Adapter(name: str) -> PRCXI9300PlateAdapter:
|
||||
""" Code: ZX-58-30 """
|
||||
return PRCXI9300PlateAdapter(
|
||||
name=name,
|
||||
size_x=132,
|
||||
size_y=93.5,
|
||||
size_z=30,
|
||||
material_info={
|
||||
"uuid": "a0757a90d8e44e81a68f306a608694f2",
|
||||
"Code": "ZX-58-30",
|
||||
"Name": "30mm适配器",
|
||||
"SupplyType": 2
|
||||
}
|
||||
)
|
||||
@@ -1,31 +0,0 @@
|
||||
{
|
||||
"Tip头适配器 1250uL": {"uuid": "3b6f33ffbf734014bcc20e3c63e124d4", "materialEnum": "0"},
|
||||
"ZHONGXI 适配器 300uL": {"uuid": "7c822592b360451fb59690e49ac6b181", "materialEnum": "0"},
|
||||
"吸头10ul 适配器": {"uuid": "8cc3dce884ac41c09f4570d0bcbfb01c", "materialEnum": "0"},
|
||||
"1250μL Tip头": {"uuid": "7960f49ddfe9448abadda89bd1556936", "materialEnum": "0"},
|
||||
"10μL Tip头": {"uuid": "45f2ed3ad925484d96463d675a0ebf66", "materialEnum": "0"},
|
||||
"10μL加长 Tip头": {"uuid": "068b3815e36b4a72a59bae017011b29f", "materialEnum": "1"},
|
||||
"1000μL Tip头": {"uuid": "80652665f6a54402b2408d50b40398df", "materialEnum": "1"},
|
||||
"300μL Tip头": {"uuid": "076250742950465b9d6ea29a225dfb00", "materialEnum": "1"},
|
||||
"200μL Tip头": {"uuid": "7a73bb9e5c264515a8fcbe88aed0e6f7", "materialEnum": "0"},
|
||||
"0.2ml PCR板": {"uuid": "73bb9b10bc394978b70e027bf45ce2d3", "materialEnum": "0"},
|
||||
"2.2ml 深孔板": {"uuid": "ca877b8b114a4310b429d1de4aae96ee", "materialEnum": "0"},
|
||||
"储液槽": {"uuid": "04211a2dc93547fe9bf6121eac533650", "materialEnum": "0"},
|
||||
"全裙边 PCR适配器": {"uuid": "4a043a07c65a4f9bb97745e1f129b165", "materialEnum": "3"},
|
||||
"储液槽 适配器": {"uuid": "6bdfdd7069df453896b0806df50f2f4d", "materialEnum": "0"},
|
||||
"300ul深孔板适配器": {"uuid": "9a439bed8f3344549643d6b3bc5a5eb4", "materialEnum": "0"},
|
||||
"10ul专用深孔板适配器": {"uuid": "4dc8d6ecfd0449549683b8ef815a861b", "materialEnum": "0"},
|
||||
"爱津": {"uuid": "b01627718d3341aba649baa81c2c083c", "materialEnum": "0"},
|
||||
"适配器": {"uuid": "adfabfffa8f24af5abfbba67b8d0f973", "materialEnum": "0"},
|
||||
"废弃槽": {"uuid": "730067cf07ae43849ddf4034299030e9", "materialEnum": "0"},
|
||||
"96深孔板": {"uuid": "57b1e4711e9e4a32b529f3132fc5931f", "materialEnum": "0"},
|
||||
"384板": {"uuid": "853dcfb6226f476e8b23c250217dc7da", "materialEnum": "0"},
|
||||
"4道储液槽": {"uuid": "01953864f6f140ccaa8ddffd4f3e46f5", "materialEnum": "0"},
|
||||
"48孔深孔板": {"uuid": "026c5d5cf3d94e56b4e16b7fb53a995b", "materialEnum": "2"},
|
||||
"12道储液槽": {"uuid": "0f1639987b154e1fac78f4fb29a1f7c1", "materialEnum": "0"},
|
||||
"HPLC料盘": {"uuid": "548bbc3df0d4447586f2c19d2c0c0c55", "materialEnum": "0"},
|
||||
"ep适配器": {"uuid": "e146697c395e4eabb3d6b74f0dd6aaf7", "materialEnum": "0"},
|
||||
"30mm适配器": {"uuid": "a0757a90d8e44e81a68f306a608694f2", "materialEnum": "0"},
|
||||
"细菌培养皿": {"uuid": "b05b3b2aafd94ec38ea0cd3215ecea8f", "materialEnum": "4"},
|
||||
"96 细胞培养皿":{ "uuid": "57b1e4711e9e4a32b529f3132fc5931f", "materialEnum": "0"}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
import collections
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from unilabos.devices.liquid_handling.prcxi.prcxi import PRCXI9300Container
|
||||
|
||||
|
||||
prcxi_materials_path = str(Path(__file__).parent / "prcxi_material.json")
|
||||
with open(prcxi_materials_path, mode="r", encoding="utf-8") as f:
|
||||
prcxi_materials = json.loads(f.read())
|
||||
|
||||
|
||||
def tip_adaptor_1250ul(name="Tip头适配器 1250uL") -> PRCXI9300Container: # 必须传入一个name参数,是plr的规范要求
|
||||
# tip_rack = PRCXI9300Container(name, prcxi_materials["name"]["Height"])
|
||||
tip_rack = PRCXI9300Container(name, 1000,400,800, "tip_rack", collections.OrderedDict())
|
||||
tip_rack.load_state({
|
||||
"Materials": {"uuid": "7960f49ddfe9448abadda89bd1556936", "materialEnum": "0"}
|
||||
})
|
||||
return tip_rack
|
||||
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
import collections
|
||||
|
||||
from pylabrobot.resources import opentrons_96_tiprack_10ul
|
||||
from pylabrobot.resources.opentrons.plates import corning_96_wellplate_360ul_flat, nest_96_wellplate_2ml_deep
|
||||
|
||||
from unilabos.devices.liquid_handling.prcxi.prcxi import PRCXI9300Container, PRCXI9300Trash
|
||||
|
||||
|
||||
def get_well_container(name: str) -> PRCXI9300Container:
|
||||
well_containers = corning_96_wellplate_360ul_flat(name).serialize()
|
||||
plate = PRCXI9300Container(name=name, size_x=50, size_y=50, size_z=10, category="plate",
|
||||
ordering=collections.OrderedDict())
|
||||
plate_serialized = plate.serialize()
|
||||
well_containers.update({k: v for k, v in plate_serialized.items() if k not in ["children"]})
|
||||
new_plate: PRCXI9300Container = PRCXI9300Container.deserialize(well_containers)
|
||||
return new_plate
|
||||
|
||||
def get_tip_rack(name: str) -> PRCXI9300Container:
|
||||
tip_racks = opentrons_96_tiprack_10ul("name").serialize()
|
||||
tip_rack = PRCXI9300Container(name=name, size_x=50, size_y=50, size_z=10, category="tip_rack",
|
||||
ordering=collections.OrderedDict())
|
||||
tip_rack_serialized = tip_rack.serialize()
|
||||
tip_racks.update({k: v for k, v in tip_rack_serialized.items() if k not in ["children"]})
|
||||
new_tip_rack: PRCXI9300Container = PRCXI9300Container.deserialize(tip_racks)
|
||||
return new_tip_rack
|
||||
|
||||
def prcxi_96_wellplate_360ul_flat(name: str):
|
||||
return get_well_container(name)
|
||||
|
||||
def prcxi_opentrons_96_tiprack_10ul(name: str):
|
||||
return get_tip_rack(name)
|
||||
|
||||
def prcxi_trash(name: str = None):
|
||||
return PRCXI9300Trash(name="trash", size_x=50, size_y=50, size_z=10, category="trash")
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Example usage
|
||||
test_plate = prcxi_96_wellplate_360ul_flat("test_plate")
|
||||
test_rack = prcxi_opentrons_96_tiprack_10ul("test_rack")
|
||||
tash = prcxi_trash("trash")
|
||||
print(test_plate)
|
||||
print(test_rack)
|
||||
print(tash)
|
||||
# Output will be a dictionary representation of the PRCXI9300Container with well details
|
||||
@@ -0,0 +1,560 @@
|
||||
# 新威电池测试系统 - OSS 上传功能说明
|
||||
|
||||
## 功能概述
|
||||
|
||||
本次更新为新威电池测试系统添加了**阿里云 OSS 文件上传功能**,采用统一的 API 方式,允许将测试数据备份文件上传到云端存储。
|
||||
|
||||
## 版本更新说明
|
||||
|
||||
### ⚠️ 重大变更(2025-12-17)
|
||||
|
||||
本次更新将 OSS 上传方式从 **`oss2` 库** 改为 **统一 API 方式**,实现与团队其他系统的统一。
|
||||
|
||||
**主要变化**:
|
||||
- ✅ 用 `requests` 库
|
||||
- ✅ 通过统一 API 获取预签名 URL 进行上传
|
||||
- ✅ 简化环境变量配置(仅需要 JWT Token)
|
||||
- ✅ 返回文件访问 URL
|
||||
|
||||
## 主要改动
|
||||
|
||||
### 1. OSS 上传工具函数重构(第30-200行)
|
||||
|
||||
#### 新增函数
|
||||
|
||||
- **`get_upload_token(base_url, auth_token, scene, filename)`**
|
||||
从统一 API 获取文件上传的预签名 URL
|
||||
|
||||
- **`upload_file_with_presigned_url(upload_info, file_path)`**
|
||||
使用预签名 URL 上传文件到 OSS
|
||||
|
||||
#### 更新的函数
|
||||
|
||||
- **`upload_file_to_oss(local_file_path, oss_object_name)`**
|
||||
上传单个文件到阿里云 OSS(使用统一 API 方式)
|
||||
- 返回值变更:成功时返回文件访问 URL,失败时返回 `False`
|
||||
|
||||
- **`upload_files_to_oss(file_paths, oss_prefix)`**
|
||||
批量上传文件列表
|
||||
- `oss_prefix` 参数保留但暂不使用(接口兼容性)
|
||||
|
||||
- **`upload_directory_to_oss(local_dir, oss_prefix)`**
|
||||
上传整个目录
|
||||
- 简化实现,直接使用文件名上传
|
||||
|
||||
### 2. 环境变量配置简化
|
||||
|
||||
#### 新方式(推荐)
|
||||
```bash
|
||||
# ✅ 必需
|
||||
UNI_LAB_AUTH_TOKEN # API Key 格式: "Api xxxxxx"
|
||||
|
||||
# ✅ 可选(有默认值)
|
||||
UNI_LAB_BASE_URL (默认: https://uni-lab.test.bohrium.com)
|
||||
UNI_LAB_UPLOAD_SCENE (默认: job,其他值会被改成 default)
|
||||
```
|
||||
|
||||
### 3. 初始化方法(保持不变)
|
||||
|
||||
`__init__` 方法中的 OSS 相关配置参数:
|
||||
|
||||
```python
|
||||
# OSS 上传配置
|
||||
self.oss_upload_enabled = False # 默认不启用 OSS 上传
|
||||
self.oss_prefix = "neware_backup" # OSS 对象路径前缀
|
||||
self._last_backup_dir = None # 记录最近一次的 backup_dir
|
||||
```
|
||||
|
||||
**默认行为**:OSS 上传功能默认关闭(`oss_upload_enabled=False`),不影响现有系统。
|
||||
|
||||
### 4. upload_backup_to_oss 方法(保持不变)
|
||||
|
||||
```python
|
||||
def upload_backup_to_oss(
|
||||
self,
|
||||
backup_dir: str = None,
|
||||
file_pattern: str = "*",
|
||||
oss_prefix: str = None
|
||||
) -> dict
|
||||
```
|
||||
|
||||
## 使用说明
|
||||
|
||||
### 前置条件
|
||||
|
||||
#### 1. 安装依赖
|
||||
|
||||
```bash
|
||||
# requests 库(通常已安装)
|
||||
pip install requests
|
||||
```
|
||||
|
||||
#### 2. 配置环境变量
|
||||
|
||||
根据您使用的终端类型配置环境变量:
|
||||
|
||||
##### PowerShell(推荐)
|
||||
|
||||
```powershell
|
||||
# 必需:设置认证 Token(API Key 格式)
|
||||
$env:UNI_LAB_AUTH_TOKEN = "Api xxxx"
|
||||
|
||||
# 可选:自定义服务器地址(默认为 test 环境)
|
||||
$env:UNI_LAB_BASE_URL = "https://uni-lab.test.bohrium.com"
|
||||
|
||||
# 可选:自定义上传场景(默认为 job)
|
||||
$env:UNI_LAB_UPLOAD_SCENE = "job"
|
||||
|
||||
# 验证是否设置成功
|
||||
echo $env:UNI_LAB_AUTH_TOKEN
|
||||
```
|
||||
|
||||
##### CMD / 命令提示符
|
||||
|
||||
```cmd
|
||||
REM 必需:设置认证 Token(API Key 格式)
|
||||
set UNI_LAB_AUTH_TOKEN=Api xxxx
|
||||
|
||||
REM 可选:自定义配置
|
||||
set UNI_LAB_BASE_URL=https://uni-lab.test.bohrium.com
|
||||
set UNI_LAB_UPLOAD_SCENE=job
|
||||
|
||||
REM 验证是否设置成功
|
||||
echo %UNI_LAB_AUTH_TOKEN%
|
||||
```
|
||||
|
||||
##### Linux/Mac
|
||||
|
||||
```bash
|
||||
# 必需:设置认证 Token(API Key 格式)
|
||||
export UNI_LAB_AUTH_TOKEN="Api xxxx"
|
||||
|
||||
# 可选:自定义配置
|
||||
export UNI_LAB_BASE_URL="https://uni-lab.test.bohrium.com"
|
||||
export UNI_LAB_UPLOAD_SCENE="job"
|
||||
|
||||
# 验证是否设置成功
|
||||
echo $UNI_LAB_AUTH_TOKEN
|
||||
```
|
||||
|
||||
#### 3. 获取认证 Token
|
||||
|
||||
> **重要**:从 Uni-Lab 主页 → 账号安全 中获取 API Key。
|
||||
|
||||
**获取步骤**:
|
||||
1. 登录 Uni-Lab 系统
|
||||
2. 进入主页 → 账号安全
|
||||
3. 复制 API Key
|
||||
|
||||
Token 格式示例:
|
||||
```
|
||||
Api 48ccxx336fba44f39e1e37db93xxxxx
|
||||
```
|
||||
|
||||
> **提示**:
|
||||
> - 如果 Token 已经包含 `Api ` 前缀,直接使用
|
||||
> - 如果没有前缀,代码会自动添加 `Api ` 前缀
|
||||
> - 旧版 `Bearer` JWT Token 格式仍然兼容
|
||||
|
||||
#### 4. 持久化配置(可选)
|
||||
|
||||
**临时配置**:上述命令设置的环境变量只在当前终端会话中有效。
|
||||
|
||||
**持久化方式 1:PowerShell 配置文件**
|
||||
```powershell
|
||||
# 编辑 PowerShell 配置文件
|
||||
notepad $PROFILE
|
||||
|
||||
# 在打开的文件中添加:
|
||||
$env:UNI_LAB_AUTH_TOKEN = "Api 你的API_Key"
|
||||
```
|
||||
|
||||
**持久化方式 2:Windows 系统环境变量**
|
||||
- 右键"此电脑" → "属性" → "高级系统设置" → "环境变量"
|
||||
- 添加用户变量或系统变量:
|
||||
- 变量名:`UNI_LAB_AUTH_TOKEN`
|
||||
- 变量值:`Api 你的API_Key`
|
||||
|
||||
### 使用流程
|
||||
|
||||
#### 步骤 1:启用 OSS 上传功能
|
||||
|
||||
**推荐方式:在 `device.json` 中配置**
|
||||
|
||||
编辑设备配置文件 `unilabos/devices/neware_battery_test_system/device.json`,在 `config` 中添加:
|
||||
|
||||
```json
|
||||
{
|
||||
"nodes": [
|
||||
{
|
||||
"id": "NEWARE_BATTERY_TEST_SYSTEM",
|
||||
"config": {
|
||||
"ip": "127.0.0.1",
|
||||
"port": 502,
|
||||
"machine_id": 1,
|
||||
"oss_upload_enabled": true,
|
||||
"oss_prefix": "neware_backup/2025-12"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**参数说明**:
|
||||
- `oss_upload_enabled`: 设置为 `true` 启用 OSS 上传
|
||||
- `oss_prefix`: OSS 文件路径前缀,建议按日期或项目组织(暂时未使用,保留接口兼容性)
|
||||
|
||||
**其他方式:通过初始化参数**
|
||||
|
||||
```python
|
||||
device = NewareBatteryTestSystem(
|
||||
ip="127.0.0.1",
|
||||
port=502,
|
||||
oss_upload_enabled=True, # 启用 OSS 上传
|
||||
oss_prefix="neware_backup/2025-12" # 可选:自定义路径前缀
|
||||
)
|
||||
```
|
||||
|
||||
**配置完成后,重启 ROS 节点使配置生效。**
|
||||
|
||||
#### 步骤 2:提交测试任务
|
||||
|
||||
使用 `submit_from_csv` 提交测试任务:
|
||||
|
||||
```python
|
||||
result = device.submit_from_csv(
|
||||
csv_path="test_data.csv",
|
||||
output_dir="D:/neware_output"
|
||||
)
|
||||
```
|
||||
|
||||
此时会创建以下目录结构:
|
||||
```
|
||||
D:/neware_output/
|
||||
├── xml_dir/ # XML 配置文件
|
||||
└── backup_dir/ # 测试数据备份(由新威设备生成)
|
||||
```
|
||||
|
||||
#### 步骤 3:等待测试完成
|
||||
|
||||
等待新威设备完成测试,备份文件会生成到 `backup_dir` 中。
|
||||
|
||||
#### 步骤 4:上传备份文件到 OSS
|
||||
|
||||
**方法 A:使用默认设置(推荐)**
|
||||
```python
|
||||
# 自动使用最近一次的 backup_dir,上传所有文件
|
||||
result = device.upload_backup_to_oss()
|
||||
```
|
||||
|
||||
**方法 B:指定备份目录**
|
||||
```python
|
||||
# 手动指定备份目录
|
||||
result = device.upload_backup_to_oss(
|
||||
backup_dir="D:/neware_output/backup_dir"
|
||||
)
|
||||
```
|
||||
|
||||
**方法 C:筛选特定文件**
|
||||
```python
|
||||
# 仅上传 CSV 文件
|
||||
result = device.upload_backup_to_oss(
|
||||
backup_dir="D:/neware_output/backup_dir",
|
||||
file_pattern="*.csv"
|
||||
)
|
||||
|
||||
# 仅上传特定电池编号的文件
|
||||
result = device.upload_backup_to_oss(
|
||||
file_pattern="Battery_A001_*.nda"
|
||||
)
|
||||
```
|
||||
|
||||
### 返回结果示例
|
||||
|
||||
**成功上传所有文件**:
|
||||
```python
|
||||
{
|
||||
"return_info": "全部上传成功: 15/15 个文件",
|
||||
"success": True,
|
||||
"uploaded_count": 15,
|
||||
"total_count": 15,
|
||||
"failed_files": [],
|
||||
"uploaded_files": [
|
||||
{
|
||||
"filename": "Battery_A001.ndax",
|
||||
"url": "https://uni-lab-test.oss-cn-zhangjiakou.aliyuncs.com/job/abc123.../Battery_A001.ndax"
|
||||
},
|
||||
{
|
||||
"filename": "Battery_A002.ndax",
|
||||
"url": "https://uni-lab-test.oss-cn-zhangjiakou.aliyuncs.com/job/abc123.../Battery_A002.ndax"
|
||||
}
|
||||
# ... 其他 13 个文件
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**部分上传成功**:
|
||||
```python
|
||||
{
|
||||
"return_info": "部分上传成功: 12/15 个文件,失败 3 个",
|
||||
"success": True,
|
||||
"uploaded_count": 12,
|
||||
"total_count": 15,
|
||||
"failed_files": ["Battery_A003.csv", "Battery_A007.csv", "test.log"],
|
||||
"uploaded_files": [
|
||||
{
|
||||
"filename": "Battery_A001.ndax",
|
||||
"url": "https://uni-lab-test.oss-cn-zhangjiakou.aliyuncs.com/job/abc123.../Battery_A001.ndax"
|
||||
},
|
||||
{
|
||||
"filename": "Battery_A002.ndax",
|
||||
"url": "https://uni-lab-test.oss-cn-zhangjiakou.aliyuncs.com/job/abc123.../Battery_A002.ndax"
|
||||
}
|
||||
# ... 其他 10 个成功上传的文件
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
> **说明**:`uploaded_files` 字段包含所有成功上传文件的详细信息:
|
||||
> - `filename`: 文件名(不含路径)
|
||||
> - `url`: 文件在 OSS 上的完整访问 URL
|
||||
|
||||
## 错误处理
|
||||
|
||||
### OSS 上传未启用
|
||||
|
||||
如果 `oss_upload_enabled=False`,调用 `upload_backup_to_oss` 会返回:
|
||||
```python
|
||||
{
|
||||
"return_info": "OSS 上传未启用 (oss_upload_enabled=False),跳过上传。备份目录: ...",
|
||||
"success": False,
|
||||
"uploaded_count": 0,
|
||||
"total_count": 0,
|
||||
"failed_files": []
|
||||
}
|
||||
```
|
||||
|
||||
**解决方法**:设置 `device.oss_upload_enabled = True`
|
||||
|
||||
### 环境变量未配置
|
||||
|
||||
如果缺少 `UNI_LAB_AUTH_TOKEN`,会返回:
|
||||
```python
|
||||
{
|
||||
"return_info": "OSS 环境变量配置错误: 请设置环境变量: UNI_LAB_AUTH_TOKEN",
|
||||
"success": False,
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
**解决方法**:按照前置条件配置环境变量
|
||||
|
||||
### 备份目录不存在
|
||||
|
||||
如果指定的备份目录不存在,会返回:
|
||||
```python
|
||||
{
|
||||
"return_info": "备份目录不存在: D:/neware_output/backup_dir",
|
||||
"success": False,
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
**解决方法**:检查目录路径是否正确,或等待测试生成备份文件
|
||||
|
||||
### API 认证失败
|
||||
|
||||
如果 Token 无效或过期,会返回:
|
||||
```python
|
||||
{
|
||||
"return_info": "获取凭证失败: 认证失败",
|
||||
"success": False,
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
**解决方法**:检查 Token 是否正确,或联系开发团队获取新 Token
|
||||
|
||||
## 技术细节
|
||||
|
||||
### OSS 上传流程(新方式)
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[开始上传] --> B[验证配置和环境变量]
|
||||
B --> C[扫描备份目录]
|
||||
C --> D[筛选符合 pattern 的文件]
|
||||
D --> E[遍历每个文件]
|
||||
E --> F[调用 API 获取预签名 URL]
|
||||
F --> G{获取成功?}
|
||||
G -->|是| H[使用预签名 URL 上传文件]
|
||||
G -->|否| I[记录失败]
|
||||
H --> J{上传成功?}
|
||||
J -->|是| K[记录成功 + 文件 URL]
|
||||
J -->|否| I
|
||||
I --> L{还有文件?}
|
||||
K --> L
|
||||
L -->|是| E
|
||||
L -->|否| M[返回统计结果]
|
||||
```
|
||||
|
||||
### 上传 API 流程
|
||||
|
||||
1. **获取预签名 URL**
|
||||
- 请求:`GET /api/v1/applications/token?scene={scene}&filename={filename}`
|
||||
- 认证:`Authorization: Bearer {token}`
|
||||
- 响应:`{code: 0, data: {url: "预签名URL", path: "文件路径"}}`
|
||||
|
||||
2. **上传文件**
|
||||
- 请求:`PUT {预签名URL}`
|
||||
- 内容:文件二进制数据
|
||||
- 响应:HTTP 200 表示成功
|
||||
|
||||
3. **生成访问 URL**
|
||||
- 格式:`https://{OSS_PUBLIC_HOST}/{path}`
|
||||
- 示例:`https://uni-lab-test.oss-cn-zhangjiakou.aliyuncs.com/job/20251217/battery_data.csv`
|
||||
|
||||
### 日志记录
|
||||
|
||||
所有上传操作都会通过 ROS 日志系统记录:
|
||||
- `INFO` 级别:上传进度和成功信息
|
||||
- `WARNING` 级别:空目录、未启用等警告
|
||||
- `ERROR` 级别:上传失败、配置错误
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. **上传时机**:`backup_dir` 中的文件是在新威设备执行测试过程中实时生成的,请确保测试已完成再上传。
|
||||
|
||||
2. **文件命名**:上传到 OSS 的文件会保留原始文件名,路径由统一 API 分配。
|
||||
|
||||
3. **网络要求**:上传需要稳定的网络连接到阿里云 OSS 服务。
|
||||
|
||||
4. **Token 有效期**:JWT Token 有过期时间,过期后需要重新获取。
|
||||
|
||||
5. **成本考虑**:OSS 存储和流量会产生费用,请根据需要合理设置文件筛选规则。
|
||||
|
||||
6. **并发上传**:当前实现为串行上传,大量文件上传可能需要较长时间。
|
||||
|
||||
7. **文件大小限制**:请注意单个文件大小是否有上传限制(由统一 API 控制)。
|
||||
|
||||
## 兼容性
|
||||
|
||||
- ✅ **向后兼容**:默认 `oss_upload_enabled=False`,不影响现有系统
|
||||
- ✅ **可选功能**:仅在需要时启用
|
||||
- ✅ **独立操作**:上传失败不会影响测试任务的提交和执行
|
||||
- ⚠️ **环境变量变更**:需要更新环境变量配置(从 OSS AK/SK 改为 JWT Token)
|
||||
|
||||
## 迁移指南
|
||||
|
||||
如果您之前使用 `oss2` 库方式,请按以下步骤迁移:
|
||||
|
||||
### 1. 卸载旧依赖(可选)
|
||||
```bash
|
||||
pip uninstall oss2
|
||||
```
|
||||
|
||||
### 2. 删除旧环境变量
|
||||
```powershell
|
||||
# PowerShell
|
||||
Remove-Item Env:\OSS_ACCESS_KEY_ID
|
||||
Remove-Item Env:\OSS_ACCESS_KEY_SECRET
|
||||
Remove-Item Env:\OSS_BUCKET_NAME
|
||||
Remove-Item Env:\OSS_ENDPOINT
|
||||
```
|
||||
|
||||
### 3. 设置新环境变量
|
||||
```powershell
|
||||
# PowerShell
|
||||
$env:UNI_LAB_AUTH_TOKEN = "Bearer 你的token..."
|
||||
```
|
||||
|
||||
### 4. 测试上传功能
|
||||
```python
|
||||
# 验证上传是否正常工作
|
||||
result = device.upload_backup_to_oss(backup_dir="测试目录")
|
||||
print(result)
|
||||
```
|
||||
|
||||
## 常见问题
|
||||
|
||||
**Q: 为什么要从 `oss2` 改为统一 API?**
|
||||
A: 为了与团队其他系统保持一致,简化配置,并统一认证方式。
|
||||
|
||||
**Q: Token 在哪里获取?**
|
||||
A: 请联系开发团队获取有效的 JWT Token。
|
||||
|
||||
**Q: Token 过期了怎么办?**
|
||||
A: 重新获取新的 Token 并更新环境变量 `UNI_LAB_AUTH_TOKEN`。
|
||||
|
||||
**Q: 可以自定义上传路径吗?**
|
||||
A: 当前版本路径由统一 API 自动分配,`oss_prefix` 参数暂不使用(保留接口兼容性)。
|
||||
|
||||
**Q: 为什么不在 `submit_from_csv` 中自动上传?**
|
||||
A: 因为备份文件在测试进行中逐步生成,方法返回时可能文件尚未完全生成,因此提供独立的上传方法更灵活。
|
||||
|
||||
**Q: 上传后如何访问文件?**
|
||||
A: 上传成功后会返回文件访问 URL,格式为 `https://uni-lab-test.oss-cn-zhangjiakou.aliyuncs.com/{path}`
|
||||
|
||||
**Q: 如何删除已上传的文件?**
|
||||
A: 需要通过 OSS 控制台或 API 操作,本功能仅负责上传。
|
||||
|
||||
## 验证上传结果
|
||||
|
||||
### 方法1:通过阿里云控制台查看
|
||||
|
||||
1. 登录 [阿里云 OSS 控制台](https://oss.console.aliyun.com/)
|
||||
2. 点击左侧 **Bucket列表**
|
||||
3. 选择 `uni-lab-test` Bucket
|
||||
4. 点击 **文件管理**
|
||||
5. 查看上传的文件列表
|
||||
|
||||
### 方法2:使用返回的文件 URL
|
||||
|
||||
上传成功后,`upload_file_to_oss()` 会返回文件访问 URL:
|
||||
```python
|
||||
url = upload_file_to_oss("local_file.csv")
|
||||
print(f"文件访问 URL: {url}")
|
||||
# 输出示例:https://uni-lab-test.oss-cn-zhangjiakou.aliyuncs.com/job/20251217/local_file.csv
|
||||
```
|
||||
|
||||
> **注意**:OSS 文件默认为私有访问,直接访问 URL 可能需要签名认证。
|
||||
|
||||
### 方法3:使用 ossutil 命令行工具
|
||||
|
||||
安装 [ossutil](https://help.aliyun.com/document_detail/120075.html) 后:
|
||||
|
||||
```bash
|
||||
# 列出文件
|
||||
ossutil ls oss://uni-lab-test/job/
|
||||
|
||||
# 下载文件到本地
|
||||
ossutil cp oss://uni-lab-test/job/20251217/文件名 ./本地路径
|
||||
|
||||
# 生成签名URL(有效期1小时)
|
||||
ossutil sign oss://uni-lab-test/job/20251217/文件名 --timeout 3600
|
||||
```
|
||||
|
||||
## 更新日志
|
||||
|
||||
- **2025-12-17**: v2.0(重大更新)
|
||||
- ⚠️ 从 `oss2` 库改为统一 API 方式
|
||||
- 简化环境变量配置(仅需 JWT Token)
|
||||
- 新增 `get_upload_token()` 和 `upload_file_with_presigned_url()` 函数
|
||||
- `upload_file_to_oss()` 返回值改为文件访问 URL
|
||||
- 更新文档和迁移指南
|
||||
|
||||
- **2025-12-15**: v1.1
|
||||
- 添加初始化参数 `oss_upload_enabled` 和 `oss_prefix`
|
||||
- 支持在 `device.json` 中配置 OSS 上传
|
||||
- 更新使用说明,添加验证方法
|
||||
|
||||
- **2025-12-13**: v1.0 初始版本
|
||||
- 添加 OSS 上传工具函数(基于 `oss2` 库)
|
||||
- 创建 `upload_backup_to_oss` 动作方法
|
||||
- 支持文件筛选和自定义 OSS 路径
|
||||
|
||||
## 参考资料
|
||||
|
||||
- [Uni-Lab 统一文件上传 API 文档](https://uni-lab.test.bohrium.com/api/docs)(如有)
|
||||
- [阿里云 OSS 控制台](https://oss.console.aliyun.com/)
|
||||
- [ossutil 工具文档](https://help.aliyun.com/document_detail/120075.html)
|
||||
@@ -0,0 +1,574 @@
|
||||
# Neware Battery Test System - OSS Upload Feature
|
||||
|
||||
## Overview
|
||||
|
||||
This update adds **Aliyun OSS file upload functionality** to the Neware Battery Test System using a unified API approach, allowing test data backup files to be uploaded to cloud storage.
|
||||
|
||||
## Version Updates
|
||||
|
||||
### ⚠️ Breaking Changes (2025-12-17)
|
||||
|
||||
This update changes the OSS upload method from **`oss2` library** to **unified API approach** to align with other team systems.
|
||||
|
||||
**Main Changes**:
|
||||
- ✅ Use `requests` library
|
||||
- ✅ Upload via presigned URLs obtained through unified API
|
||||
- ✅ Simplified environment variable configuration (only API Key required)
|
||||
- ✅ Returns file access URLs
|
||||
|
||||
## Main Changes
|
||||
|
||||
### 1. OSS Upload Functions Refactored (Lines 30-200)
|
||||
|
||||
#### New Functions
|
||||
|
||||
- **`get_upload_token(base_url, auth_token, scene, filename)`**
|
||||
Obtain presigned URL for file upload from unified API
|
||||
|
||||
- **`upload_file_with_presigned_url(upload_info, file_path)`**
|
||||
Upload file to OSS using presigned URL
|
||||
|
||||
#### Updated Functions
|
||||
|
||||
- **`upload_file_to_oss(local_file_path, oss_object_name)`**
|
||||
Upload single file to Aliyun OSS (using unified API approach)
|
||||
- Return value changed: returns file access URL on success, `False` on failure
|
||||
|
||||
- **`upload_files_to_oss(file_paths, oss_prefix)`**
|
||||
Batch upload file list
|
||||
- `oss_prefix` parameter retained but not used (interface compatibility)
|
||||
|
||||
- **`upload_directory_to_oss(local_dir, oss_prefix)`**
|
||||
Upload entire directory
|
||||
- Simplified implementation, uploads using filenames directly
|
||||
|
||||
### 2. Simplified Environment Variable Configuration
|
||||
|
||||
#### Old Method (Deprecated)
|
||||
```bash
|
||||
# ❌ No longer used
|
||||
OSS_ACCESS_KEY_ID
|
||||
OSS_ACCESS_KEY_SECRET
|
||||
OSS_BUCKET_NAME
|
||||
OSS_ENDPOINT
|
||||
```
|
||||
|
||||
#### New Method (Recommended)
|
||||
```bash
|
||||
# ✅ Required
|
||||
UNI_LAB_AUTH_TOKEN # API Key format: "Api xxxxxx"
|
||||
|
||||
# ✅ Optional (with defaults)
|
||||
UNI_LAB_BASE_URL (default: https://uni-lab.test.bohrium.com)
|
||||
UNI_LAB_UPLOAD_SCENE (default: job, other values will be changed to default)
|
||||
```
|
||||
|
||||
### 3. Initialization Method (Unchanged)
|
||||
|
||||
OSS-related configuration parameters in `__init__` method:
|
||||
|
||||
```python
|
||||
# OSS upload configuration
|
||||
self.oss_upload_enabled = False # OSS upload disabled by default
|
||||
self.oss_prefix = "neware_backup" # OSS object path prefix
|
||||
self._last_backup_dir = None # Record last backup_dir
|
||||
```
|
||||
|
||||
**Default Behavior**: OSS upload is disabled by default (`oss_upload_enabled=False`), does not affect existing systems.
|
||||
|
||||
### 4. upload_backup_to_oss Method (Unchanged)
|
||||
|
||||
```python
|
||||
def upload_backup_to_oss(
|
||||
self,
|
||||
backup_dir: str = None,
|
||||
file_pattern: str = "*",
|
||||
oss_prefix: str = None
|
||||
) -> dict
|
||||
```
|
||||
|
||||
## Usage Guide
|
||||
|
||||
### Prerequisites
|
||||
|
||||
#### 1. Install Dependencies
|
||||
|
||||
```bash
|
||||
# requests library (usually pre-installed)
|
||||
pip install requests
|
||||
```
|
||||
|
||||
> **Note**: No longer need to install `oss2` library
|
||||
|
||||
#### 2. Configure Environment Variables
|
||||
|
||||
Configure environment variables based on your terminal type:
|
||||
|
||||
##### PowerShell (Recommended)
|
||||
|
||||
```powershell
|
||||
# Required: Set authentication Token (API Key format)
|
||||
$env:UNI_LAB_AUTH_TOKEN = "Api xxxx"
|
||||
|
||||
# Optional: Custom server URL (defaults to test environment)
|
||||
$env:UNI_LAB_BASE_URL = "https://uni-lab.test.bohrium.com"
|
||||
|
||||
# Optional: Custom upload scene (defaults to job)
|
||||
$env:UNI_LAB_UPLOAD_SCENE = "job"
|
||||
|
||||
# Verify if set successfully
|
||||
echo $env:UNI_LAB_AUTH_TOKEN
|
||||
```
|
||||
|
||||
##### CMD / Command Prompt
|
||||
|
||||
```cmd
|
||||
REM Required: Set authentication Token (API Key format)
|
||||
set UNI_LAB_AUTH_TOKEN=Api xxxx
|
||||
|
||||
REM Optional: Custom configuration
|
||||
set UNI_LAB_BASE_URL=https://uni-lab.test.bohrium.com
|
||||
set UNI_LAB_UPLOAD_SCENE=job
|
||||
|
||||
REM Verify if set successfully
|
||||
echo %UNI_LAB_AUTH_TOKEN%
|
||||
```
|
||||
|
||||
##### Linux/Mac
|
||||
|
||||
```bash
|
||||
# Required: Set authentication Token (API Key format)
|
||||
export UNI_LAB_AUTH_TOKEN="Api xxxx"
|
||||
|
||||
# Optional: Custom configuration
|
||||
export UNI_LAB_BASE_URL="https://uni-lab.test.bohrium.com"
|
||||
export UNI_LAB_UPLOAD_SCENE="job"
|
||||
|
||||
# Verify if set successfully
|
||||
echo $UNI_LAB_AUTH_TOKEN
|
||||
```
|
||||
|
||||
#### 3. Obtain Authentication Token
|
||||
|
||||
> **Important**: Obtain API Key from Uni-Lab Homepage → Account Security.
|
||||
|
||||
**Steps to Obtain**:
|
||||
1. Login to Uni-Lab system
|
||||
2. Go to Homepage → Account Security
|
||||
3. Copy your API Key
|
||||
|
||||
Token format example:
|
||||
```
|
||||
Api 48ccxx336fba44f39e1e37db93xxxxx
|
||||
```
|
||||
|
||||
> **Tips**:
|
||||
> - If Token already includes `Api ` prefix, use directly
|
||||
> - If no prefix, code will automatically add `Api ` prefix
|
||||
> - Old `Bearer` JWT Token format is still compatible
|
||||
|
||||
#### 4. Persistent Configuration (Optional)
|
||||
|
||||
**Temporary Configuration**: Environment variables set with the above commands are only valid for the current terminal session.
|
||||
|
||||
**Persistence Method 1: PowerShell Profile**
|
||||
```powershell
|
||||
# Edit PowerShell profile
|
||||
notepad $PROFILE
|
||||
|
||||
# Add to the opened file:
|
||||
$env:UNI_LAB_AUTH_TOKEN = "Api your_API_Key"
|
||||
```
|
||||
|
||||
**Persistence Method 2: Windows System Environment Variables**
|
||||
- Right-click "This PC" → "Properties" → "Advanced system settings" → "Environment Variables"
|
||||
- Add user or system variable:
|
||||
- Variable name: `UNI_LAB_AUTH_TOKEN`
|
||||
- Variable value: `Api your_API_Key`
|
||||
|
||||
### Usage Workflow
|
||||
|
||||
#### Step 1: Enable OSS Upload Feature
|
||||
|
||||
**Recommended: Configure in `device.json`**
|
||||
|
||||
Edit device configuration file `unilabos/devices/neware_battery_test_system/device.json`, add to `config`:
|
||||
|
||||
```json
|
||||
{
|
||||
"nodes": [
|
||||
{
|
||||
"id": "NEWARE_BATTERY_TEST_SYSTEM",
|
||||
"config": {
|
||||
"ip": "127.0.0.1",
|
||||
"port": 502,
|
||||
"machine_id": 1,
|
||||
"oss_upload_enabled": true,
|
||||
"oss_prefix": "neware_backup/2025-12"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Parameter Description**:
|
||||
- `oss_upload_enabled`: Set to `true` to enable OSS upload
|
||||
- `oss_prefix`: OSS file path prefix, recommended to organize by date or project (currently unused, retained for interface compatibility)
|
||||
|
||||
**Alternative: Via Initialization Parameters**
|
||||
|
||||
```python
|
||||
device = NewareBatteryTestSystem(
|
||||
ip="127.0.0.1",
|
||||
port=502,
|
||||
oss_upload_enabled=True, # Enable OSS upload
|
||||
oss_prefix="neware_backup/2025-12" # Optional: custom path prefix
|
||||
)
|
||||
```
|
||||
|
||||
**After configuration, restart the ROS node for changes to take effect.**
|
||||
|
||||
#### Step 2: Submit Test Tasks
|
||||
|
||||
Use `submit_from_csv` to submit test tasks:
|
||||
|
||||
```python
|
||||
result = device.submit_from_csv(
|
||||
csv_path="test_data.csv",
|
||||
output_dir="D:/neware_output"
|
||||
)
|
||||
```
|
||||
|
||||
This creates the following directory structure:
|
||||
```
|
||||
D:/neware_output/
|
||||
├── xml_dir/ # XML configuration files
|
||||
└── backup_dir/ # Test data backup (generated by Neware device)
|
||||
```
|
||||
|
||||
#### Step 3: Wait for Test Completion
|
||||
|
||||
Wait for the Neware device to complete testing. Backup files will be generated in the `backup_dir`.
|
||||
|
||||
#### Step 4: Upload Backup Files to OSS
|
||||
|
||||
**Method A: Use Default Settings (Recommended)**
|
||||
```python
|
||||
# Automatically uses the last backup_dir, uploads all files
|
||||
result = device.upload_backup_to_oss()
|
||||
```
|
||||
|
||||
**Method B: Specify Backup Directory**
|
||||
```python
|
||||
# Manually specify backup directory
|
||||
result = device.upload_backup_to_oss(
|
||||
backup_dir="D:/neware_output/backup_dir"
|
||||
)
|
||||
```
|
||||
|
||||
**Method C: Filter Specific Files**
|
||||
```python
|
||||
# Upload only CSV files
|
||||
result = device.upload_backup_to_oss(
|
||||
backup_dir="D:/neware_output/backup_dir",
|
||||
file_pattern="*.csv"
|
||||
)
|
||||
|
||||
# Upload files for specific battery IDs
|
||||
result = device.upload_backup_to_oss(
|
||||
file_pattern="Battery_A001_*.nda"
|
||||
)
|
||||
```
|
||||
|
||||
### Return Result Examples
|
||||
|
||||
**All Files Uploaded Successfully**:
|
||||
```python
|
||||
{
|
||||
"return_info": "All uploads successful: 15/15 files",
|
||||
"success": True,
|
||||
"uploaded_count": 15,
|
||||
"total_count": 15,
|
||||
"failed_files": [],
|
||||
"uploaded_files": [
|
||||
{
|
||||
"filename": "Battery_A001.ndax",
|
||||
"url": "https://uni-lab-test.oss-cn-zhangjiakou.aliyuncs.com/job/abc123.../Battery_A001.ndax"
|
||||
},
|
||||
{
|
||||
"filename": "Battery_A002.ndax",
|
||||
"url": "https://uni-lab-test.oss-cn-zhangjiakou.aliyuncs.com/job/abc123.../Battery_A002.ndax"
|
||||
}
|
||||
# ... other 13 files
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Partial Upload Success**:
|
||||
```python
|
||||
{
|
||||
"return_info": "Partial upload success: 12/15 files, 3 failed",
|
||||
"success": True,
|
||||
"uploaded_count": 12,
|
||||
"total_count": 15,
|
||||
"failed_files": ["Battery_A003.csv", "Battery_A007.csv", "test.log"],
|
||||
"uploaded_files": [
|
||||
{
|
||||
"filename": "Battery_A001.ndax",
|
||||
"url": "https://uni-lab-test.oss-cn-zhangjiakou.aliyuncs.com/job/abc123.../Battery_A001.ndax"
|
||||
},
|
||||
{
|
||||
"filename": "Battery_A002.ndax",
|
||||
"url": "https://uni-lab-test.oss-cn-zhangjiakou.aliyuncs.com/job/abc123.../Battery_A002.ndax"
|
||||
}
|
||||
# ... other 10 successfully uploaded files
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
> **Note**: The `uploaded_files` field contains detailed information for all successfully uploaded files:
|
||||
> - `filename`: Filename (without path)
|
||||
> - `url`: Complete OSS access URL for the file
|
||||
|
||||
## Error Handling
|
||||
|
||||
### OSS Upload Not Enabled
|
||||
|
||||
If `oss_upload_enabled=False`, calling `upload_backup_to_oss` returns:
|
||||
```python
|
||||
{
|
||||
"return_info": "OSS upload not enabled (oss_upload_enabled=False), skipping upload. Backup directory: ...",
|
||||
"success": False,
|
||||
"uploaded_count": 0,
|
||||
"total_count": 0,
|
||||
"failed_files": []
|
||||
}
|
||||
```
|
||||
|
||||
**Solution**: Set `device.oss_upload_enabled = True`
|
||||
|
||||
### Environment Variables Not Configured
|
||||
|
||||
If `UNI_LAB_AUTH_TOKEN` is missing, returns:
|
||||
```python
|
||||
{
|
||||
"return_info": "OSS environment variable configuration error: Please set environment variable: UNI_LAB_AUTH_TOKEN",
|
||||
"success": False,
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
**Solution**: Configure environment variables as per prerequisites
|
||||
|
||||
### Backup Directory Does Not Exist
|
||||
|
||||
If specified backup directory doesn't exist, returns:
|
||||
```python
|
||||
{
|
||||
"return_info": "Backup directory does not exist: D:/neware_output/backup_dir",
|
||||
"success": False,
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
**Solution**: Check if directory path is correct, or wait for test to generate backup files
|
||||
|
||||
### API Authentication Failed
|
||||
|
||||
If Token is invalid or expired, returns:
|
||||
```python
|
||||
{
|
||||
"return_info": "Failed to get credentials: Authentication failed",
|
||||
"success": False,
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
**Solution**: Check if Token is correct, or contact development team for new Token
|
||||
|
||||
## Technical Details
|
||||
|
||||
### OSS Upload Process (New Method)
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[Start Upload] --> B[Verify Configuration and Environment Variables]
|
||||
B --> C[Scan Backup Directory]
|
||||
C --> D[Filter Files Matching Pattern]
|
||||
D --> E[Iterate Each File]
|
||||
E --> F[Call API to Get Presigned URL]
|
||||
F --> G{Success?}
|
||||
G -->|Yes| H[Upload File Using Presigned URL]
|
||||
G -->|No| I[Record Failure]
|
||||
H --> J{Upload Success?}
|
||||
J -->|Yes| K[Record Success + File URL]
|
||||
J -->|No| I
|
||||
I --> L{More Files?}
|
||||
K --> L
|
||||
L -->|Yes| E
|
||||
L -->|No| M[Return Statistics]
|
||||
```
|
||||
|
||||
### Upload API Flow
|
||||
|
||||
1. **Get Presigned URL**
|
||||
- Request: `GET /api/v1/lab/storage/token?scene={scene}&filename={filename}&path={path}`
|
||||
- Authentication: `Authorization: Api {api_key}` or `Authorization: Bearer {token}`
|
||||
- Response: `{code: 0, data: {url: "presigned_url", path: "file_path"}}`
|
||||
|
||||
2. **Upload File**
|
||||
- Request: `PUT {presigned_url}`
|
||||
- Content: File binary data
|
||||
- Response: HTTP 200 indicates success
|
||||
|
||||
3. **Generate Access URL**
|
||||
- Format: `https://{OSS_PUBLIC_HOST}/{path}`
|
||||
- Example: `https://uni-lab-test.oss-cn-zhangjiakou.aliyuncs.com/job/20251217/battery_data.csv`
|
||||
|
||||
### Logging
|
||||
|
||||
All upload operations are logged through ROS logging system:
|
||||
- `INFO` level: Upload progress and success information
|
||||
- `WARNING` level: Empty directory, not enabled warnings
|
||||
- `ERROR` level: Upload failures, configuration errors
|
||||
|
||||
## Important Notes
|
||||
|
||||
1. **Upload Timing**: Files in `backup_dir` are generated in real-time during test execution. Ensure testing is complete before uploading.
|
||||
|
||||
2. **File Naming**: Files uploaded to OSS retain original filenames. Paths are assigned by unified API.
|
||||
|
||||
3. **Network Requirements**: Upload requires stable network connection to Aliyun OSS service.
|
||||
|
||||
4. **Token Expiration**: JWT Tokens have expiration time. Need to obtain new token after expiration.
|
||||
|
||||
5. **Cost Considerations**: OSS storage and traffic incur costs. Set file filtering rules appropriately.
|
||||
|
||||
6. **Concurrent Upload**: Current implementation uses serial upload. Large number of files may take considerable time.
|
||||
|
||||
7. **File Size Limits**: Note single file size upload limits (controlled by unified API).
|
||||
|
||||
## Compatibility
|
||||
|
||||
- ✅ **Backward Compatible**: Default `oss_upload_enabled=False`, does not affect existing systems
|
||||
- ✅ **Optional Feature**: Enable only when needed
|
||||
- ✅ **Independent Operation**: Upload failures do not affect test task submission and execution
|
||||
- ⚠️ **Environment Variable Changes**: Need to update environment variable configuration (from OSS AK/SK to API Key)
|
||||
|
||||
## Migration Guide
|
||||
|
||||
If you previously used the `oss2` library method, follow these steps to migrate:
|
||||
|
||||
### 1. Uninstall Old Dependencies (Optional)
|
||||
```bash
|
||||
pip uninstall oss2
|
||||
```
|
||||
|
||||
### 2. Remove Old Environment Variables
|
||||
```powershell
|
||||
# PowerShell
|
||||
Remove-Item Env:\OSS_ACCESS_KEY_ID
|
||||
Remove-Item Env:\OSS_ACCESS_KEY_SECRET
|
||||
Remove-Item Env:\OSS_BUCKET_NAME
|
||||
Remove-Item Env:\OSS_ENDPOINT
|
||||
```
|
||||
|
||||
### 3. Set New Environment Variables
|
||||
```powershell
|
||||
# PowerShell
|
||||
$env:UNI_LAB_AUTH_TOKEN = "Api your_API_Key"
|
||||
```
|
||||
|
||||
### 4. Test Upload Functionality
|
||||
```python
|
||||
# Verify upload works correctly
|
||||
result = device.upload_backup_to_oss(backup_dir="test_directory")
|
||||
print(result)
|
||||
```
|
||||
|
||||
## FAQ
|
||||
|
||||
**Q: Why change from `oss2` to unified API?**
|
||||
A: To maintain consistency with other team systems, simplify configuration, and unify authentication methods.
|
||||
|
||||
**Q: Where to get the Token?**
|
||||
A: Obtain API Key from Uni-Lab Homepage → Account Security.
|
||||
|
||||
**Q: What if Token expires?**
|
||||
A: Obtain a new API Key and update the `UNI_LAB_AUTH_TOKEN` environment variable.
|
||||
|
||||
**Q: Can I customize upload paths?**
|
||||
A: Current version has paths automatically assigned by unified API. `oss_prefix` parameter is currently unused (retained for interface compatibility).
|
||||
|
||||
**Q: Why not auto-upload in `submit_from_csv`?**
|
||||
A: Because backup files are generated progressively during testing, they may not be fully generated when the method returns. A separate upload method provides more flexibility.
|
||||
|
||||
**Q: How to access files after upload?**
|
||||
A: Upload success returns file access URL in format `https://uni-lab-test.oss-cn-zhangjiakou.aliyuncs.com/{path}`
|
||||
|
||||
**Q: How to delete uploaded files?**
|
||||
A: Need to operate through OSS console or API. This feature only handles uploads.
|
||||
|
||||
## Verifying Upload Results
|
||||
|
||||
### Method 1: Via Aliyun Console
|
||||
|
||||
1. Login to [Aliyun OSS Console](https://oss.console.aliyun.com/)
|
||||
2. Click **Bucket List** on the left
|
||||
3. Select the `uni-lab-test` Bucket
|
||||
4. Click **File Management**
|
||||
5. View uploaded file list
|
||||
|
||||
### Method 2: Using Returned File URL
|
||||
|
||||
After successful upload, `upload_file_to_oss()` returns file access URL:
|
||||
```python
|
||||
url = upload_file_to_oss("local_file.csv")
|
||||
print(f"File access URL: {url}")
|
||||
# Example output: https://uni-lab-test.oss-cn-zhangjiakou.aliyuncs.com/job/20251217/local_file.csv
|
||||
```
|
||||
|
||||
> **Note**: OSS files are private by default, direct URL access may require signature authentication.
|
||||
|
||||
### Method 3: Using ossutil CLI Tool
|
||||
|
||||
After installing [ossutil](https://help.aliyun.com/document_detail/120075.html):
|
||||
|
||||
```bash
|
||||
# List files
|
||||
ossutil ls oss://uni-lab-test/job/
|
||||
|
||||
# Download file to local
|
||||
ossutil cp oss://uni-lab-test/job/20251217/filename ./local_path
|
||||
|
||||
# Generate signed URL (valid for 1 hour)
|
||||
ossutil sign oss://uni-lab-test/job/20251217/filename --timeout 3600
|
||||
```
|
||||
|
||||
## Changelog
|
||||
|
||||
- **2025-12-17**: v2.0 (Major Update)
|
||||
- ⚠️ Changed from `oss2` library to unified API approach
|
||||
- Simplified environment variable configuration (only API Key required)
|
||||
- Added `get_upload_token()` and `upload_file_with_presigned_url()` functions
|
||||
- `upload_file_to_oss()` return value changed to file access URL
|
||||
- Updated documentation and migration guide
|
||||
- Token format: Support both `Api Key` and `Bearer JWT`
|
||||
- API endpoint: `/api/v1/lab/storage/token`
|
||||
- Scene parameter: Fixed to `job` (other values changed to `default`)
|
||||
|
||||
- **2025-12-15**: v1.1
|
||||
- Added initialization parameters `oss_upload_enabled` and `oss_prefix`
|
||||
- Support OSS upload configuration in `device.json`
|
||||
- Updated usage guide, added verification methods
|
||||
|
||||
- **2025-12-13**: v1.0 Initial Version
|
||||
- Added OSS upload utility functions (based on `oss2` library)
|
||||
- Created `upload_backup_to_oss` action method
|
||||
- Support file filtering and custom OSS paths
|
||||
|
||||
## References
|
||||
|
||||
- [Uni-Lab Unified File Upload API Documentation](https://uni-lab.test.bohrium.com/api/docs) (if available)
|
||||
- [Aliyun OSS Console](https://oss.console.aliyun.com/)
|
||||
- [ossutil Tool Documentation](https://help.aliyun.com/document_detail/120075.html)
|
||||
35
unilabos/devices/neware_battery_test_system/device.json
Normal file
35
unilabos/devices/neware_battery_test_system/device.json
Normal file
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"nodes": [
|
||||
{
|
||||
"id": "NEWARE_BATTERY_TEST_SYSTEM",
|
||||
"name": "Neware Battery Test System",
|
||||
"parent": null,
|
||||
"type": "device",
|
||||
"class": "neware_battery_test_system",
|
||||
"position": {
|
||||
"x": 620.0,
|
||||
"y": 200.0,
|
||||
"z": 0
|
||||
},
|
||||
"config": {
|
||||
"ip": "127.0.0.1",
|
||||
"port": 502,
|
||||
"machine_id": 1,
|
||||
"devtype": "27",
|
||||
"timeout": 20,
|
||||
"size_x": 500.0,
|
||||
"size_y": 500.0,
|
||||
"size_z": 2000.0,
|
||||
"oss_upload_enabled": true,
|
||||
"oss_prefix": "neware_backup/2025-12"
|
||||
},
|
||||
"data": {
|
||||
"功能说明": "新威电池测试系统,提供720通道监控和CSV批量提交功能",
|
||||
"监控功能": "支持720个通道的实时状态监控、2盘电池物料管理、状态导出等",
|
||||
"提交功能": "通过submit_from_csv action从CSV文件批量提交测试任务。CSV必须包含: Battery_Code, Pole_Weight, 集流体质量, 活性物质含量, 克容量mah/g, 电池体系, 设备号, 排号, 通道号"
|
||||
},
|
||||
"children": []
|
||||
}
|
||||
],
|
||||
"links": []
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,282 +1,649 @@
|
||||
import sys
|
||||
import threading
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Contains drivers for:
|
||||
1. SyringePump: Runze Fluid SY-03B (ASCII)
|
||||
2. EmmMotor: Emm V5.0 Closed-loop Stepper (Modbus-RTU variant)
|
||||
3. XKCSensor: XKC Non-contact Level Sensor (Modbus-RTU)
|
||||
"""
|
||||
|
||||
import socket
|
||||
import serial
|
||||
import serial.tools.list_ports
|
||||
import re
|
||||
import time
|
||||
from typing import Optional, List, Dict, Tuple
|
||||
import threading
|
||||
import struct
|
||||
import re
|
||||
import traceback
|
||||
import queue
|
||||
from typing import Optional, Dict, List, Any
|
||||
|
||||
class ChinweDevice:
|
||||
"""
|
||||
ChinWe设备控制类
|
||||
提供串口通信、电机控制、传感器数据读取等功能
|
||||
"""
|
||||
try:
|
||||
from unilabos.device_comms.universal_driver import UniversalDriver
|
||||
except ImportError:
|
||||
import logging
|
||||
class UniversalDriver:
|
||||
def __init__(self):
|
||||
self.logger = logging.getLogger(self.__class__.__name__)
|
||||
|
||||
def __init__(self, port: str, baudrate: int = 115200, debug: bool = False):
|
||||
"""
|
||||
初始化ChinWe设备
|
||||
def execute_command_from_outer(self, command: str):
|
||||
pass
|
||||
|
||||
Args:
|
||||
port: 串口名称,如果为None则自动检测
|
||||
baudrate: 波特率,默认115200
|
||||
# ==============================================================================
|
||||
# 1. Transport Layer (通信层)
|
||||
# ==============================================================================
|
||||
|
||||
class TransportManager:
|
||||
"""
|
||||
self.debug = debug
|
||||
统一通信管理类。
|
||||
自动识别 串口 (Serial) 或 网络 (TCP) 连接。
|
||||
"""
|
||||
def __init__(self, port: str, baudrate: int = 9600, timeout: float = 3.0, logger=None):
|
||||
self.port = port
|
||||
self.baudrate = baudrate
|
||||
self.serial_port: Optional[serial.Serial] = None
|
||||
self._voltage: float = 0.0
|
||||
self._ec_value: float = 0.0
|
||||
self._ec_adc_value: int = 0
|
||||
self.timeout = timeout
|
||||
self.logger = logger
|
||||
self.lock = threading.RLock() # 线程锁,确保多设备共用一个连接时不冲突
|
||||
|
||||
self.is_tcp = False
|
||||
self.serial = None
|
||||
self.socket = None
|
||||
|
||||
# 简单判断: 如果包含 ':' (如 192.168.1.1:8899) 或者看起来像 IP,则认为是 TCP
|
||||
if ':' in self.port or (self.port.count('.') == 3 and not self.port.startswith('/')):
|
||||
self.is_tcp = True
|
||||
self._connect_tcp()
|
||||
else:
|
||||
self._connect_serial()
|
||||
|
||||
def _log(self, msg):
|
||||
if self.logger:
|
||||
pass
|
||||
# self.logger.debug(f"[Transport] {msg}")
|
||||
|
||||
def _connect_tcp(self):
|
||||
try:
|
||||
if ':' in self.port:
|
||||
host, p = self.port.split(':')
|
||||
self.tcp_host = host
|
||||
self.tcp_port = int(p)
|
||||
else:
|
||||
self.tcp_host = self.port
|
||||
self.tcp_port = 8899 # 默认端口
|
||||
|
||||
# if self.logger: self.logger.info(f"Connecting TCP {self.tcp_host}:{self.tcp_port} ...")
|
||||
self.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
self.socket.settimeout(self.timeout)
|
||||
self.socket.connect((self.tcp_host, self.tcp_port))
|
||||
except Exception as e:
|
||||
raise ConnectionError(f"TCP connection failed: {e}")
|
||||
|
||||
def _connect_serial(self):
|
||||
try:
|
||||
# if self.logger: self.logger.info(f"Opening Serial {self.port} (Baud: {self.baudrate}) ...")
|
||||
self.serial = serial.Serial(
|
||||
port=self.port,
|
||||
baudrate=self.baudrate,
|
||||
timeout=self.timeout
|
||||
)
|
||||
except Exception as e:
|
||||
raise ConnectionError(f"Serial open failed: {e}")
|
||||
|
||||
def close(self):
|
||||
"""关闭连接"""
|
||||
if self.is_tcp and self.socket:
|
||||
try: self.socket.close()
|
||||
except: pass
|
||||
elif not self.is_tcp and self.serial and self.serial.is_open:
|
||||
self.serial.close()
|
||||
|
||||
def clear_buffer(self):
|
||||
"""清空缓冲区 (Thread-safe)"""
|
||||
with self.lock:
|
||||
if self.is_tcp:
|
||||
self.socket.setblocking(False)
|
||||
try:
|
||||
while True:
|
||||
if not self.socket.recv(1024): break
|
||||
except: pass
|
||||
finally: self.socket.settimeout(self.timeout)
|
||||
else:
|
||||
self.serial.reset_input_buffer()
|
||||
|
||||
def write(self, data: bytes):
|
||||
"""发送原始字节"""
|
||||
with self.lock:
|
||||
if self.is_tcp:
|
||||
self.socket.sendall(data)
|
||||
else:
|
||||
self.serial.write(data)
|
||||
|
||||
def read(self, size: int) -> bytes:
|
||||
"""读取指定长度字节"""
|
||||
if self.is_tcp:
|
||||
data = b''
|
||||
start = time.time()
|
||||
while len(data) < size:
|
||||
if time.time() - start > self.timeout: break
|
||||
try:
|
||||
chunk = self.socket.recv(size - len(data))
|
||||
if not chunk: break
|
||||
data += chunk
|
||||
except socket.timeout: break
|
||||
return data
|
||||
else:
|
||||
return self.serial.read(size)
|
||||
|
||||
def send_ascii_command(self, command: str) -> str:
|
||||
"""
|
||||
发送 ASCII 字符串命令 (如注射泵指令),读取直到 '\r'。
|
||||
"""
|
||||
with self.lock:
|
||||
data = command.encode('ascii') if isinstance(command, str) else command
|
||||
self.clear_buffer()
|
||||
self.write(data)
|
||||
|
||||
# Read until \r
|
||||
if self.is_tcp:
|
||||
resp = b''
|
||||
start = time.time()
|
||||
while True:
|
||||
if time.time() - start > self.timeout: break
|
||||
try:
|
||||
char = self.socket.recv(1)
|
||||
if not char: break
|
||||
resp += char
|
||||
if char == b'\r': break
|
||||
except: break
|
||||
return resp.decode('ascii', errors='ignore').strip()
|
||||
else:
|
||||
return self.serial.read_until(b'\r').decode('ascii', errors='ignore').strip()
|
||||
|
||||
# ==============================================================================
|
||||
# 2. Syringe Pump Driver (注射泵)
|
||||
# ==============================================================================
|
||||
|
||||
class SyringePump:
|
||||
"""SY-03B 注射泵驱动 (ASCII协议)"""
|
||||
|
||||
CMD_INITIALIZE = "Z{speed},{drain_port},{output_port}R"
|
||||
CMD_SWITCH_VALVE = "I{port}R"
|
||||
CMD_ASPIRATE = "P{vol}R"
|
||||
CMD_DISPENSE = "D{vol}R"
|
||||
CMD_DISPENSE_ALL = "A0R"
|
||||
CMD_STOP = "TR"
|
||||
CMD_QUERY_STATUS = "Q"
|
||||
CMD_QUERY_PLUNGER = "?0"
|
||||
|
||||
def __init__(self, device_id: int, transport: TransportManager):
|
||||
if not 1 <= device_id <= 15:
|
||||
pass # Allow all IDs for now
|
||||
self.id = str(device_id)
|
||||
self.transport = transport
|
||||
|
||||
def _send(self, template: str, **kwargs) -> str:
|
||||
cmd = f"/{self.id}" + template.format(**kwargs) + "\r"
|
||||
return self.transport.send_ascii_command(cmd)
|
||||
|
||||
def is_busy(self) -> bool:
|
||||
"""查询繁忙状态"""
|
||||
resp = self._send(self.CMD_QUERY_STATUS)
|
||||
# 响应如 /0` (Ready, 0x60) 或 /0@ (Busy, 0x40)
|
||||
if len(resp) >= 3:
|
||||
status_byte = ord(resp[2])
|
||||
# Bit 5: 1=Ready, 0=Busy
|
||||
return (status_byte & 0x20) == 0
|
||||
return False
|
||||
|
||||
def wait_until_idle(self, timeout=30):
|
||||
"""阻塞等待直到空闲"""
|
||||
start = time.time()
|
||||
while time.time() - start < timeout:
|
||||
if not self.is_busy(): return
|
||||
time.sleep(0.5)
|
||||
# raise TimeoutError(f"Pump {self.id} wait idle timeout")
|
||||
pass
|
||||
|
||||
def initialize(self, drain_port=0, output_port=0, speed=10):
|
||||
"""初始化"""
|
||||
self._send(self.CMD_INITIALIZE, speed=speed, drain_port=drain_port, output_port=output_port)
|
||||
|
||||
def switch_valve(self, port: int):
|
||||
"""切换阀门 (1-8)"""
|
||||
self._send(self.CMD_SWITCH_VALVE, port=port)
|
||||
|
||||
def aspirate(self, steps: int):
|
||||
"""吸液 (相对步数)"""
|
||||
self._send(self.CMD_ASPIRATE, vol=steps)
|
||||
|
||||
def dispense(self, steps: int):
|
||||
"""排液 (相对步数)"""
|
||||
self._send(self.CMD_DISPENSE, vol=steps)
|
||||
|
||||
def stop(self):
|
||||
"""停止"""
|
||||
self._send(self.CMD_STOP)
|
||||
|
||||
def get_position(self) -> int:
|
||||
"""获取柱塞位置 (步数)"""
|
||||
resp = self._send(self.CMD_QUERY_PLUNGER)
|
||||
m = re.search(r'\d+', resp)
|
||||
return int(m.group()) if m else -1
|
||||
|
||||
# ==============================================================================
|
||||
# 3. Stepper Motor Driver (步进电机)
|
||||
# ==============================================================================
|
||||
|
||||
class EmmMotor:
|
||||
"""Emm V5.0 闭环步进电机驱动"""
|
||||
|
||||
def __init__(self, device_id: int, transport: TransportManager):
|
||||
self.id = device_id
|
||||
self.transport = transport
|
||||
|
||||
def _send(self, func_code: int, payload: list) -> bytes:
|
||||
with self.transport.lock:
|
||||
self.transport.clear_buffer()
|
||||
# 格式: [ID] [Func] [Data...] [Check=0x6B]
|
||||
body = [self.id, func_code] + payload
|
||||
body.append(0x6B) # Checksum
|
||||
self.transport.write(bytes(body))
|
||||
|
||||
# 根据指令不同,读取不同长度响应
|
||||
read_len = 10 if func_code in [0x31, 0x32, 0x35, 0x24, 0x27] else 4
|
||||
return self.transport.read(read_len)
|
||||
|
||||
def enable(self, on=True):
|
||||
"""使能 (True=锁轴, False=松轴)"""
|
||||
state = 1 if on else 0
|
||||
self._send(0xF3, [0xAB, state, 0])
|
||||
|
||||
def run_speed(self, speed_rpm: int, direction=0, acc=10):
|
||||
"""速度模式运行"""
|
||||
sp = struct.pack('>H', int(speed_rpm))
|
||||
self._send(0xF6, [direction, sp[0], sp[1], acc, 0])
|
||||
|
||||
def run_position(self, pulses: int, speed_rpm: int, direction=0, acc=10, absolute=False):
|
||||
"""位置模式运行"""
|
||||
sp = struct.pack('>H', int(speed_rpm))
|
||||
pl = struct.pack('>I', int(pulses))
|
||||
is_abs = 1 if absolute else 0
|
||||
self._send(0xFD, [direction, sp[0], sp[1], acc, pl[0], pl[1], pl[2], pl[3], is_abs, 0])
|
||||
|
||||
def stop(self):
|
||||
"""停止"""
|
||||
self._send(0xFE, [0x98, 0])
|
||||
|
||||
def set_zero(self):
|
||||
"""清零位置"""
|
||||
self._send(0x0A, [])
|
||||
|
||||
def get_position(self) -> int:
|
||||
"""获取当前脉冲位置"""
|
||||
resp = self._send(0x32, [])
|
||||
if len(resp) >= 8:
|
||||
sign = resp[2]
|
||||
val = struct.unpack('>I', resp[3:7])[0]
|
||||
return -val if sign == 1 else val
|
||||
return 0
|
||||
|
||||
# ==============================================================================
|
||||
# 4. Liquid Sensor Driver (液位传感器)
|
||||
# ==============================================================================
|
||||
|
||||
class XKCSensor:
|
||||
"""XKC RS485 液位传感器 (Modbus RTU)"""
|
||||
|
||||
def __init__(self, device_id: int, transport: TransportManager, threshold: int = 300):
|
||||
self.id = device_id
|
||||
self.transport = transport
|
||||
self.threshold = threshold
|
||||
|
||||
def _crc(self, data: bytes) -> bytes:
|
||||
crc = 0xFFFF
|
||||
for byte in data:
|
||||
crc ^= byte
|
||||
for _ in range(8):
|
||||
if crc & 0x0001: crc = (crc >> 1) ^ 0xA001
|
||||
else: crc >>= 1
|
||||
return struct.pack('<H', crc)
|
||||
|
||||
def read_level(self) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
读取液位。
|
||||
返回: {'level': bool, 'rssi': int}
|
||||
"""
|
||||
with self.transport.lock:
|
||||
self.transport.clear_buffer()
|
||||
# Modbus Read Registers: 01 03 00 01 00 02 CRC
|
||||
payload = struct.pack('>HH', 0x0001, 0x0002)
|
||||
msg = struct.pack('BB', self.id, 0x03) + payload
|
||||
msg += self._crc(msg)
|
||||
self.transport.write(msg)
|
||||
|
||||
# Read header
|
||||
h = self.transport.read(3) # Addr, Func, Len
|
||||
if len(h) < 3: return None
|
||||
length = h[2]
|
||||
|
||||
# Read body + CRC
|
||||
body = self.transport.read(length + 2)
|
||||
if len(body) < length + 2:
|
||||
# Firmware bug fix specific to some modules
|
||||
if len(body) == 4 and length == 4:
|
||||
pass
|
||||
else:
|
||||
return None
|
||||
|
||||
data = body[:-2]
|
||||
if len(data) == 2:
|
||||
rssi = data[1]
|
||||
elif len(data) >= 4:
|
||||
rssi = (data[2] << 8) | data[3]
|
||||
else:
|
||||
return None
|
||||
|
||||
return {
|
||||
'level': rssi > self.threshold,
|
||||
'rssi': rssi
|
||||
}
|
||||
|
||||
# ==============================================================================
|
||||
# 5. Main Device Class (ChinweDevice)
|
||||
# ==============================================================================
|
||||
|
||||
class ChinweDevice(UniversalDriver):
|
||||
"""
|
||||
ChinWe 工作站主驱动
|
||||
继承自 UniversalDriver,管理所有子设备(泵、电机、传感器)
|
||||
"""
|
||||
|
||||
def __init__(self, port: str = "192.168.1.200:8899", baudrate: int = 9600,
|
||||
pump_ids: List[int] = None, motor_ids: List[int] = None,
|
||||
sensor_id: int = 6, sensor_threshold: int = 300,
|
||||
timeout: float = 10.0):
|
||||
"""
|
||||
初始化 ChinWe 工作站
|
||||
:param port: 串口号 或 IP:Port
|
||||
:param baudrate: 串口波特率
|
||||
:param pump_ids: 注射泵 ID列表 (默认 [1, 2, 3])
|
||||
:param motor_ids: 步进电机 ID列表 (默认 [4, 5])
|
||||
:param sensor_id: 液位传感器 ID (默认 6)
|
||||
:param sensor_threshold: 传感器液位判定阈值
|
||||
:param timeout: 通信超时时间 (默认 10秒)
|
||||
"""
|
||||
super().__init__()
|
||||
self.port = port
|
||||
self.baudrate = baudrate
|
||||
self.timeout = timeout
|
||||
self.mgr = None
|
||||
self._is_connected = False
|
||||
|
||||
# 默认配置
|
||||
if pump_ids is None: pump_ids = [1, 2, 3]
|
||||
if motor_ids is None: motor_ids = [4, 5]
|
||||
|
||||
# 配置信息
|
||||
self.pump_ids = pump_ids
|
||||
self.motor_ids = motor_ids
|
||||
self.sensor_id = sensor_id
|
||||
self.sensor_threshold = sensor_threshold
|
||||
|
||||
# 子设备实例容器
|
||||
self.pumps: Dict[int, SyringePump] = {}
|
||||
self.motors: Dict[int, EmmMotor] = {}
|
||||
self.sensor: Optional[XKCSensor] = None
|
||||
|
||||
# 轮询线程控制
|
||||
self._stop_event = threading.Event()
|
||||
self._poll_thread = None
|
||||
|
||||
# 实时状态缓存
|
||||
self.status_cache = {
|
||||
"sensor_rssi": 0,
|
||||
"sensor_level": False,
|
||||
"connected": False
|
||||
}
|
||||
|
||||
# 自动连接
|
||||
if self.port:
|
||||
self.connect()
|
||||
|
||||
def connect(self) -> bool:
|
||||
if self._is_connected: return True
|
||||
try:
|
||||
self.logger.info(f"Connecting to {self.port} (timeout={self.timeout})...")
|
||||
self.mgr = TransportManager(self.port, baudrate=self.baudrate, timeout=self.timeout, logger=self.logger)
|
||||
|
||||
# 初始化所有泵
|
||||
for pid in self.pump_ids:
|
||||
self.pumps[pid] = SyringePump(pid, self.mgr)
|
||||
|
||||
# 初始化所有电机
|
||||
for mid in self.motor_ids:
|
||||
self.motors[mid] = EmmMotor(mid, self.mgr)
|
||||
|
||||
# 初始化传感器
|
||||
self.sensor = XKCSensor(self.sensor_id, self.mgr, self.sensor_threshold)
|
||||
|
||||
self._is_connected = True
|
||||
self.status_cache["connected"] = True
|
||||
|
||||
# 启动轮询线程
|
||||
self._start_polling()
|
||||
return True
|
||||
except Exception as e:
|
||||
self.logger.error(f"Connection failed: {e}")
|
||||
self._is_connected = False
|
||||
self.status_cache["connected"] = False
|
||||
return False
|
||||
|
||||
def disconnect(self):
|
||||
self._stop_event.set()
|
||||
if self._poll_thread:
|
||||
self._poll_thread.join(timeout=2.0)
|
||||
|
||||
if self.mgr:
|
||||
self.mgr.close()
|
||||
|
||||
self._is_connected = False
|
||||
self.status_cache["connected"] = False
|
||||
self.logger.info("Disconnected.")
|
||||
|
||||
def _start_polling(self):
|
||||
"""启动传感器轮询线程"""
|
||||
if self._poll_thread and self._poll_thread.is_alive():
|
||||
return
|
||||
|
||||
self._stop_event.clear()
|
||||
self._poll_thread = threading.Thread(target=self._polling_loop, daemon=True, name="ChinwePoll")
|
||||
self._poll_thread.start()
|
||||
|
||||
def _polling_loop(self):
|
||||
"""轮询主循环"""
|
||||
self.logger.info("Sensor polling started.")
|
||||
error_count = 0
|
||||
while not self._stop_event.is_set():
|
||||
if not self._is_connected or not self.sensor:
|
||||
time.sleep(1)
|
||||
continue
|
||||
|
||||
try:
|
||||
# 获取传感器数据
|
||||
data = self.sensor.read_level()
|
||||
if data:
|
||||
self.status_cache["sensor_rssi"] = data['rssi']
|
||||
self.status_cache["sensor_level"] = data['level']
|
||||
error_count = 0
|
||||
else:
|
||||
error_count += 1
|
||||
|
||||
# 降低轮询频率防止总线拥塞
|
||||
time.sleep(0.2)
|
||||
|
||||
except Exception as e:
|
||||
error_count += 1
|
||||
if error_count > 10: # 连续错误记录日志
|
||||
# self.logger.error(f"Polling error: {e}")
|
||||
error_count = 0
|
||||
time.sleep(1)
|
||||
|
||||
# --- 对外暴露属性 (Properties) ---
|
||||
|
||||
@property
|
||||
def sensor_level(self) -> bool:
|
||||
return self.status_cache["sensor_level"]
|
||||
|
||||
@property
|
||||
def sensor_rssi(self) -> int:
|
||||
return self.status_cache["sensor_rssi"]
|
||||
|
||||
@property
|
||||
def is_connected(self) -> bool:
|
||||
"""获取连接状态"""
|
||||
return self._is_connected and self.serial_port and self.serial_port.is_open
|
||||
return self._is_connected
|
||||
|
||||
@property
|
||||
def voltage(self) -> float:
|
||||
"""获取电源电压值"""
|
||||
return self._voltage
|
||||
# --- 对外功能指令 (Actions) ---
|
||||
|
||||
@property
|
||||
def ec_value(self) -> float:
|
||||
"""获取电导率值 (ms/cm)"""
|
||||
return self._ec_value
|
||||
|
||||
@property
|
||||
def ec_adc_value(self) -> int:
|
||||
"""获取EC ADC原始值"""
|
||||
return self._ec_adc_value
|
||||
|
||||
|
||||
@property
|
||||
def device_status(self) -> Dict[str, any]:
|
||||
"""
|
||||
获取设备状态信息
|
||||
|
||||
Returns:
|
||||
包含设备状态的字典
|
||||
"""
|
||||
return {
|
||||
"connected": self.is_connected,
|
||||
"port": self.port,
|
||||
"baudrate": self.baudrate,
|
||||
"voltage": self.voltage,
|
||||
"ec_value": self.ec_value,
|
||||
"ec_adc_value": self.ec_adc_value
|
||||
}
|
||||
|
||||
def connect(self, port: Optional[str] = None, baudrate: Optional[int] = None) -> bool:
|
||||
"""
|
||||
连接到串口设备
|
||||
|
||||
Args:
|
||||
port: 串口名称,如果为None则使用初始化时的port或自动检测
|
||||
baudrate: 波特率,如果为None则使用初始化时的baudrate
|
||||
|
||||
Returns:
|
||||
连接是否成功
|
||||
"""
|
||||
if self.is_connected:
|
||||
def pump_initialize(self, pump_id: int, drain_port=0, output_port=0, speed=10):
|
||||
"""指定泵初始化"""
|
||||
pump_id = int(pump_id)
|
||||
if pump_id in self.pumps:
|
||||
self.pumps[pump_id].initialize(drain_port, output_port, speed)
|
||||
self.pumps[pump_id].wait_until_idle()
|
||||
return True
|
||||
|
||||
target_port = port or self.port
|
||||
target_baudrate = baudrate or self.baudrate
|
||||
|
||||
try:
|
||||
self.serial_port = serial.Serial(target_port, target_baudrate, timeout=0.5)
|
||||
self._is_connected = True
|
||||
self.port = target_port
|
||||
self.baudrate = target_baudrate
|
||||
connect_allow_times = 5
|
||||
while not self.serial_port.is_open and connect_allow_times > 0:
|
||||
time.sleep(0.5)
|
||||
connect_allow_times -= 1
|
||||
print(f"尝试连接到 {target_port} @ {target_baudrate},剩余尝试次数: {connect_allow_times}", self.debug)
|
||||
raise ValueError("串口未打开,请检查设备连接")
|
||||
print(f"已连接到 {target_port} @ {target_baudrate}", self.debug)
|
||||
threading.Thread(target=self._read_data, daemon=True).start()
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"ChinweDevice连接失败: {e}")
|
||||
self._is_connected = False
|
||||
return False
|
||||
|
||||
def disconnect(self) -> bool:
|
||||
def pump_aspirate(self, pump_id: int, volume: int, valve_port: int):
|
||||
"""
|
||||
断开串口连接
|
||||
|
||||
Returns:
|
||||
断开是否成功
|
||||
泵吸液 (阻塞)
|
||||
:param valve_port: 阀门端口 (1-8)
|
||||
"""
|
||||
if self.serial_port and self.serial_port.is_open:
|
||||
try:
|
||||
self.serial_port.close()
|
||||
self._is_connected = False
|
||||
print("已断开串口连接")
|
||||
pump_id = int(pump_id)
|
||||
valve_port = int(valve_port)
|
||||
if pump_id in self.pumps:
|
||||
pump = self.pumps[pump_id]
|
||||
# 1. 切换阀门
|
||||
pump.switch_valve(valve_port)
|
||||
pump.wait_until_idle()
|
||||
# 2. 吸液
|
||||
pump.aspirate(volume)
|
||||
pump.wait_until_idle()
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"断开连接失败: {e}")
|
||||
return False
|
||||
return True
|
||||
|
||||
def _send_motor_command(self, command: str) -> bool:
|
||||
"""
|
||||
发送电机控制命令
|
||||
|
||||
Args:
|
||||
command: 电机命令字符串,例如 "M 1 CW 1.5"
|
||||
|
||||
Returns:
|
||||
发送是否成功
|
||||
"""
|
||||
if not self.is_connected:
|
||||
print("设备未连接")
|
||||
return False
|
||||
|
||||
try:
|
||||
self.serial_port.write((command + "\n").encode('utf-8'))
|
||||
print(f"发送命令: {command}")
|
||||
def pump_dispense(self, pump_id: int, volume: int, valve_port: int):
|
||||
"""
|
||||
泵排液 (阻塞)
|
||||
:param valve_port: 阀门端口 (1-8)
|
||||
"""
|
||||
pump_id = int(pump_id)
|
||||
valve_port = int(valve_port)
|
||||
if pump_id in self.pumps:
|
||||
pump = self.pumps[pump_id]
|
||||
# 1. 切换阀门
|
||||
pump.switch_valve(valve_port)
|
||||
pump.wait_until_idle()
|
||||
# 2. 排液
|
||||
pump.dispense(volume)
|
||||
pump.wait_until_idle()
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"发送命令失败: {e}")
|
||||
return False
|
||||
|
||||
def rotate_motor(self, motor_id: int, turns: float, clockwise: bool = True) -> bool:
|
||||
def pump_valve(self, pump_id: int, port: int):
|
||||
"""泵切换阀门 (阻塞)"""
|
||||
pump_id = int(pump_id)
|
||||
port = int(port)
|
||||
if pump_id in self.pumps:
|
||||
pump = self.pumps[pump_id]
|
||||
pump.switch_valve(port)
|
||||
pump.wait_until_idle()
|
||||
return True
|
||||
return False
|
||||
|
||||
def motor_run_continuous(self, motor_id: int, speed: int, direction: str = "顺时针"):
|
||||
"""
|
||||
使电机转动指定圈数
|
||||
|
||||
Args:
|
||||
motor_id: 电机ID(1, 2, 3...)
|
||||
turns: 转动圈数,支持小数
|
||||
clockwise: True为顺时针,False为逆时针
|
||||
|
||||
Returns:
|
||||
命令发送是否成功
|
||||
电机一直旋转 (速度模式)
|
||||
:param direction: "顺时针" or "逆时针"
|
||||
"""
|
||||
if clockwise:
|
||||
command = f"M {motor_id} CW {turns}"
|
||||
else:
|
||||
command = f"M {motor_id} CCW {turns}"
|
||||
return self._send_motor_command(command)
|
||||
motor_id = int(motor_id)
|
||||
if motor_id not in self.motors: return False
|
||||
|
||||
def set_motor_speed(self, motor_id: int, speed: float) -> bool:
|
||||
dir_val = 0 if direction == "顺时针" else 1
|
||||
self.motors[motor_id].run_speed(speed, dir_val)
|
||||
return True
|
||||
|
||||
def motor_rotate_quarter(self, motor_id: int, speed: int = 60, direction: str = "顺时针"):
|
||||
"""
|
||||
设置电机转速(如果设备支持)
|
||||
|
||||
Args:
|
||||
motor_id: 电机ID(1, 2, 3...)
|
||||
speed: 转速值
|
||||
|
||||
Returns:
|
||||
命令发送是否成功
|
||||
电机旋转1/4圈 (阻塞)
|
||||
假设电机设置为 3200 脉冲/圈,1/4圈 = 800脉冲
|
||||
"""
|
||||
command = f"M {motor_id} SPEED {speed}"
|
||||
return self._send_motor_command(command)
|
||||
motor_id = int(motor_id)
|
||||
if motor_id not in self.motors: return False
|
||||
|
||||
def _read_data(self) -> List[str]:
|
||||
pulses = 800
|
||||
dir_val = 0 if direction == "顺时针" else 1
|
||||
|
||||
self.motors[motor_id].run_position(pulses, speed, dir_val, absolute=False)
|
||||
|
||||
# 预估时间阻塞 (单位: 分钟 -> 秒)
|
||||
# Time(s) = revs / (RPM/60). revs = 0.25. time = 15 / RPM.
|
||||
estimated_time = 15.0 / max(1, speed)
|
||||
time.sleep(estimated_time + 0.5)
|
||||
|
||||
return True
|
||||
|
||||
def motor_stop(self, motor_id: int):
|
||||
"""电机停止"""
|
||||
motor_id = int(motor_id)
|
||||
if motor_id in self.motors:
|
||||
self.motors[motor_id].stop()
|
||||
return True
|
||||
return False
|
||||
|
||||
def wait_sensor_level(self, target_state: str = "有液", timeout: int = 30) -> bool:
|
||||
"""
|
||||
读取串口数据并解析
|
||||
|
||||
Returns:
|
||||
读取到的数据行列表
|
||||
等待传感器达到指定电平
|
||||
:param target_state: "有液" or "无液"
|
||||
"""
|
||||
print("开始读取串口数据...")
|
||||
if not self.is_connected:
|
||||
return []
|
||||
target_bool = True if target_state == "有液" else False
|
||||
|
||||
data_lines = []
|
||||
try:
|
||||
while self.serial_port.in_waiting:
|
||||
time.sleep(0.1) # 等待数据稳定
|
||||
try:
|
||||
line = self.serial_port.readline().decode('utf-8', errors='ignore').strip()
|
||||
if line:
|
||||
data_lines.append(line)
|
||||
self._parse_sensor_data(line)
|
||||
except Exception as ex:
|
||||
print(f"解码数据错误: {ex}")
|
||||
except Exception as e:
|
||||
print(f"读取串口数据错误: {e}")
|
||||
self.logger.info(f"Wait sensor: {target_state} ({target_bool}), timeout: {timeout}")
|
||||
start = time.time()
|
||||
while time.time() - start < timeout:
|
||||
if self.sensor_level == target_bool:
|
||||
return True
|
||||
time.sleep(0.1)
|
||||
self.logger.warning("Wait sensor level timeout")
|
||||
return False
|
||||
|
||||
return data_lines
|
||||
|
||||
def _parse_sensor_data(self, line: str) -> None:
|
||||
def wait_time(self, duration: int) -> bool:
|
||||
"""
|
||||
解析传感器数据
|
||||
|
||||
Args:
|
||||
line: 接收到的数据行
|
||||
等待指定时间 (秒)
|
||||
:param duration: 秒
|
||||
"""
|
||||
# 解析电源电压
|
||||
if "电源电压" in line:
|
||||
try:
|
||||
val = float(line.split(":")[1].replace("V", "").strip())
|
||||
self._voltage = val
|
||||
if self.debug:
|
||||
print(f"电源电压更新: {val}V")
|
||||
except Exception:
|
||||
pass
|
||||
self.logger.info(f"Waiting for {duration} seconds...")
|
||||
time.sleep(duration)
|
||||
return True
|
||||
|
||||
# 解析电导率和ADC原始值(支持两种格式)
|
||||
if "电导率" in line and "ADC原始值" in line:
|
||||
try:
|
||||
# 支持格式如:电导率:2.50ms/cm, ADC原始值:2052
|
||||
ec_match = re.search(r"电导率[::]\s*([\d\.]+)", line)
|
||||
adc_match = re.search(r"ADC原始值[::]\s*(\d+)", line)
|
||||
if ec_match:
|
||||
ec_val = float(ec_match.group(1))
|
||||
self._ec_value = ec_val
|
||||
if self.debug:
|
||||
print(f"电导率更新: {ec_val:.2f} ms/cm")
|
||||
if adc_match:
|
||||
adc_val = int(adc_match.group(1))
|
||||
self._ec_adc_value = adc_val
|
||||
if self.debug:
|
||||
print(f"EC ADC原始值更新: {adc_val}")
|
||||
except Exception:
|
||||
pass
|
||||
# 仅电导率,无ADC原始值
|
||||
elif "电导率" in line:
|
||||
try:
|
||||
val = float(line.split(":")[1].replace("ms/cm", "").strip())
|
||||
self._ec_value = val
|
||||
if self.debug:
|
||||
print(f"电导率更新: {val:.2f} ms/cm")
|
||||
except Exception:
|
||||
pass
|
||||
# 仅ADC原始值(如有分开回传场景)
|
||||
elif "ADC原始值" in line:
|
||||
try:
|
||||
adc_val = int(line.split(":")[1].strip())
|
||||
self._ec_adc_value = adc_val
|
||||
if self.debug:
|
||||
print(f"EC ADC原始值更新: {adc_val}")
|
||||
except Exception:
|
||||
pass
|
||||
def execute_command_from_outer(self, command_dict: Dict[str, Any]) -> bool:
|
||||
"""支持标准 JSON 指令调用"""
|
||||
return super().execute_command_from_outer(command_dict)
|
||||
|
||||
def spin_when_ec_ge_0():
|
||||
pass
|
||||
|
||||
|
||||
def main():
|
||||
"""测试函数"""
|
||||
print("=== ChinWe设备测试 ===")
|
||||
|
||||
# 创建设备实例
|
||||
device = ChinweDevice("/dev/tty.usbserial-A5069RR4", debug=True)
|
||||
try:
|
||||
# 测试5: 发送电机命令
|
||||
print("\n5. 发送电机命令测试:")
|
||||
print(" 5.3 使用通用函数控制电机20顺时针转2圈:")
|
||||
device.rotate_motor(2, 20.0, clockwise=True)
|
||||
time.sleep(0.5)
|
||||
finally:
|
||||
time.sleep(10)
|
||||
# 测试7: 断开连接
|
||||
print("\n7. 断开连接:")
|
||||
device.disconnect()
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
# Test
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
dev = ChinweDevice(port="192.168.31.201:8899")
|
||||
try:
|
||||
if dev.is_connected:
|
||||
print(f"Status: Level={dev.sensor_level}, RSSI={dev.sensor_rssi}")
|
||||
|
||||
# Test pump 1
|
||||
# dev.pump_valve(1, 1)
|
||||
# dev.pump_move(1, 1000, "aspirate")
|
||||
|
||||
# Test motor 4
|
||||
# dev.motor_run(4, 60, 0, 2)
|
||||
|
||||
for _ in range(5):
|
||||
print(f"Level={dev.sensor_level}, RSSI={dev.sensor_rssi}")
|
||||
time.sleep(1)
|
||||
finally:
|
||||
dev.disconnect()
|
||||
|
||||
93
unilabos/devices/workstation/post_process/bottle_carriers.py
Normal file
93
unilabos/devices/workstation/post_process/bottle_carriers.py
Normal file
@@ -0,0 +1,93 @@
|
||||
from pylabrobot.resources import create_homogeneous_resources, Coordinate, ResourceHolder, create_ordered_items_2d
|
||||
|
||||
from unilabos.resources.itemized_carrier import BottleCarrier
|
||||
from unilabos.devices.workstation.post_process.bottles import POST_PROCESS_PolymerStation_Reagent_Bottle
|
||||
|
||||
# 命名约定:试剂瓶-Bottle,烧杯-Beaker,烧瓶-Flask,小瓶-Vial
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# 聚合站(PolymerStation)载体定义(统一入口)
|
||||
# ============================================================================
|
||||
|
||||
def POST_PROCESS_Raw_1BottleCarrier(name: str) -> BottleCarrier:
|
||||
"""聚合站-单试剂瓶载架
|
||||
|
||||
参数:
|
||||
- name: 载架名称前缀
|
||||
"""
|
||||
|
||||
# 载架尺寸 (mm)
|
||||
carrier_size_x = 127.8
|
||||
carrier_size_y = 85.5
|
||||
carrier_size_z = 20.0
|
||||
|
||||
# 烧杯/试剂瓶占位尺寸(使用圆形占位)
|
||||
beaker_diameter = 60.0
|
||||
|
||||
# 计算中央位置
|
||||
center_x = (carrier_size_x - beaker_diameter) / 2
|
||||
center_y = (carrier_size_y - beaker_diameter) / 2
|
||||
center_z = 5.0
|
||||
|
||||
carrier = BottleCarrier(
|
||||
name=name,
|
||||
size_x=carrier_size_x,
|
||||
size_y=carrier_size_y,
|
||||
size_z=carrier_size_z,
|
||||
sites=create_homogeneous_resources(
|
||||
klass=ResourceHolder,
|
||||
locations=[Coordinate(center_x, center_y, center_z)],
|
||||
resource_size_x=beaker_diameter,
|
||||
resource_size_y=beaker_diameter,
|
||||
name_prefix=name,
|
||||
),
|
||||
model="POST_PROCESS_Raw_1BottleCarrier",
|
||||
)
|
||||
carrier.num_items_x = 1
|
||||
carrier.num_items_y = 1
|
||||
carrier.num_items_z = 1
|
||||
# 统一后缀采用 "flask_1" 命名(可按需调整)
|
||||
carrier[0] = POST_PROCESS_PolymerStation_Reagent_Bottle(f"{name}_flask_1")
|
||||
return carrier
|
||||
|
||||
def POST_PROCESS_Reaction_1BottleCarrier(name: str) -> BottleCarrier:
|
||||
"""聚合站-单试剂瓶载架
|
||||
|
||||
参数:
|
||||
- name: 载架名称前缀
|
||||
"""
|
||||
|
||||
# 载架尺寸 (mm)
|
||||
carrier_size_x = 127.8
|
||||
carrier_size_y = 85.5
|
||||
carrier_size_z = 20.0
|
||||
|
||||
# 烧杯/试剂瓶占位尺寸(使用圆形占位)
|
||||
beaker_diameter = 60.0
|
||||
|
||||
# 计算中央位置
|
||||
center_x = (carrier_size_x - beaker_diameter) / 2
|
||||
center_y = (carrier_size_y - beaker_diameter) / 2
|
||||
center_z = 5.0
|
||||
|
||||
carrier = BottleCarrier(
|
||||
name=name,
|
||||
size_x=carrier_size_x,
|
||||
size_y=carrier_size_y,
|
||||
size_z=carrier_size_z,
|
||||
sites=create_homogeneous_resources(
|
||||
klass=ResourceHolder,
|
||||
locations=[Coordinate(center_x, center_y, center_z)],
|
||||
resource_size_x=beaker_diameter,
|
||||
resource_size_y=beaker_diameter,
|
||||
name_prefix=name,
|
||||
),
|
||||
model="POST_PROCESS_Reaction_1BottleCarrier",
|
||||
)
|
||||
carrier.num_items_x = 1
|
||||
carrier.num_items_y = 1
|
||||
carrier.num_items_z = 1
|
||||
# 统一后缀采用 "flask_1" 命名(可按需调整)
|
||||
carrier[0] = POST_PROCESS_PolymerStation_Reagent_Bottle(f"{name}_flask_1")
|
||||
return carrier
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user